Add rating and dashboard endpoints

This commit is contained in:
Tiago Yamamoto 2025-12-18 13:19:21 -03:00
parent 989f465613
commit cd376339b9
5 changed files with 315 additions and 0 deletions

View file

@ -196,3 +196,45 @@ type CartSummary struct {
TotalCents int64 `json:"total_cents"` TotalCents int64 `json:"total_cents"`
DiscountReason string `json:"discount_reason,omitempty"` DiscountReason string `json:"discount_reason,omitempty"`
} }
// Review captures the buyer feedback for a completed order.
type Review struct {
ID uuid.UUID `db:"id" json:"id"`
OrderID uuid.UUID `db:"order_id" json:"order_id"`
BuyerID uuid.UUID `db:"buyer_id" json:"buyer_id"`
SellerID uuid.UUID `db:"seller_id" json:"seller_id"`
Rating int `db:"rating" json:"rating"`
Comment string `db:"comment" json:"comment"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// CompanyRating exposes the aggregate score for a seller or pharmacy.
type CompanyRating struct {
CompanyID uuid.UUID `json:"company_id"`
AverageScore float64 `json:"average_score"`
TotalReviews int64 `json:"total_reviews"`
}
// TopProduct aggregates seller performance per SKU.
type TopProduct struct {
ProductID uuid.UUID `json:"product_id"`
Name string `json:"name"`
TotalQuantity int64 `json:"total_quantity"`
RevenueCents int64 `json:"revenue_cents"`
}
// SellerDashboard summarizes commercial metrics for sellers.
type SellerDashboard struct {
SellerID uuid.UUID `json:"seller_id"`
TotalSalesCents int64 `json:"total_sales_cents"`
OrdersCount int64 `json:"orders_count"`
TopProducts []TopProduct `json:"top_products"`
LowStockAlerts []Product `json:"low_stock_alerts"`
}
// AdminDashboard surfaces platform-wide KPIs.
type AdminDashboard struct {
GMVCents int64 `json:"gmv_cents"`
NewCompanies int64 `json:"new_companies"`
WindowStartAt time.Time `json:"window_start_at"`
}

View file

@ -179,6 +179,28 @@ func (h *Handler) GetMyCompany(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, company) writeJSON(w, http.StatusOK, company)
} }
// GetCompanyRating exposes the average score for a company.
func (h *Handler) GetCompanyRating(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/rating") {
http.NotFound(w, r)
return
}
id, err := parseUUIDFromPath(r.URL.Path)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
rating, err := h.svc.GetCompanyRating(r.Context(), id)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, rating)
}
// CreateProduct godoc // CreateProduct godoc
// @Summary Cadastro de produto com rastreabilidade de lote // @Summary Cadastro de produto com rastreabilidade de lote
// @Tags Produtos // @Tags Produtos
@ -365,6 +387,29 @@ func (h *Handler) UpdateOrderStatus(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent) w.WriteHeader(http.StatusNoContent)
} }
// CreateReview allows buyers to rate the seller after delivery.
func (h *Handler) CreateReview(w http.ResponseWriter, r *http.Request) {
claims, ok := middleware.GetClaims(r.Context())
if !ok || claims.CompanyID == nil {
writeError(w, http.StatusBadRequest, errors.New("missing buyer context"))
return
}
var req createReviewRequest
if err := decodeJSON(r.Context(), r, &req); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
review, err := h.svc.CreateReview(r.Context(), *claims.CompanyID, req.OrderID, req.Rating, req.Comment)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, http.StatusCreated, review)
}
// AddToCart appends an item to the authenticated buyer cart respecting stock. // AddToCart appends an item to the authenticated buyer cart respecting stock.
func (h *Handler) AddToCart(w http.ResponseWriter, r *http.Request) { func (h *Handler) AddToCart(w http.ResponseWriter, r *http.Request) {
claims, ok := middleware.GetClaims(r.Context()) claims, ok := middleware.GetClaims(r.Context())
@ -532,6 +577,65 @@ func (h *Handler) HandlePaymentWebhook(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, summary) writeJSON(w, http.StatusOK, summary)
} }
// GetSellerDashboard aggregates KPIs for the authenticated seller or the requested company.
func (h *Handler) GetSellerDashboard(w http.ResponseWriter, r *http.Request) {
requester, err := getRequester(r)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
sellerID := requester.CompanyID
if sid := r.URL.Query().Get("seller_id"); sid != "" {
id, err := uuid.FromString(sid)
if err != nil {
writeError(w, http.StatusBadRequest, errors.New("invalid seller_id"))
return
}
sellerID = &id
}
if sellerID == nil {
writeError(w, http.StatusBadRequest, errors.New("seller context is required"))
return
}
if !strings.EqualFold(requester.Role, "Admin") && requester.CompanyID != nil && *sellerID != *requester.CompanyID {
writeError(w, http.StatusForbidden, errors.New("not allowed to view other sellers"))
return
}
dashboard, err := h.svc.GetSellerDashboard(r.Context(), *sellerID)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, dashboard)
}
// GetAdminDashboard exposes platform-wide aggregated metrics.
func (h *Handler) GetAdminDashboard(w http.ResponseWriter, r *http.Request) {
requester, err := getRequester(r)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if !strings.EqualFold(requester.Role, "Admin") {
writeError(w, http.StatusForbidden, errors.New("admin role required"))
return
}
dashboard, err := h.svc.GetAdminDashboard(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
writeJSON(w, http.StatusOK, dashboard)
}
// CreateUser handles the creation of platform users. // CreateUser handles the creation of platform users.
func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) { func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) {
requester, err := getRequester(r) requester, err := getRequester(r)
@ -779,6 +883,12 @@ type addCartItemRequest struct {
Quantity int64 `json:"quantity"` Quantity int64 `json:"quantity"`
} }
type createReviewRequest struct {
OrderID uuid.UUID `json:"order_id"`
Rating int `json:"rating"`
Comment string `json:"comment"`
}
type updateUserRequest struct { type updateUserRequest struct {
CompanyID *uuid.UUID `json:"company_id,omitempty"` CompanyID *uuid.UUID `json:"company_id,omitempty"`
Role *string `json:"role,omitempty"` Role *string `json:"role,omitempty"`

View file

@ -458,6 +458,97 @@ func (r *Repository) DeleteCartItem(ctx context.Context, id uuid.UUID, buyerID u
return nil return nil
} }
func (r *Repository) CreateReview(ctx context.Context, review *domain.Review) error {
now := time.Now().UTC()
review.CreatedAt = now
query := `INSERT INTO reviews (id, order_id, buyer_id, seller_id, rating, comment, created_at)
VALUES (:id, :order_id, :buyer_id, :seller_id, :rating, :comment, :created_at)`
_, err := r.db.NamedExecContext(ctx, query, review)
return err
}
func (r *Repository) GetCompanyRating(ctx context.Context, companyID uuid.UUID) (*domain.CompanyRating, error) {
var row struct {
Avg *float64 `db:"avg"`
Count int64 `db:"count"`
}
query := `SELECT AVG(rating)::float AS avg, COUNT(*) AS count FROM reviews WHERE seller_id = $1`
if err := r.db.GetContext(ctx, &row, query, companyID); err != nil {
return nil, err
}
avg := 0.0
if row.Avg != nil {
avg = *row.Avg
}
return &domain.CompanyRating{CompanyID: companyID, AverageScore: avg, TotalReviews: row.Count}, nil
}
func (r *Repository) SellerDashboard(ctx context.Context, sellerID uuid.UUID) (*domain.SellerDashboard, error) {
var sales struct {
Total *int64 `db:"total"`
Orders int64 `db:"orders"`
}
baseStatuses := []string{string(domain.OrderStatusPaid), string(domain.OrderStatusInvoiced), string(domain.OrderStatusDelivered)}
if err := r.db.GetContext(ctx, &sales, `SELECT COALESCE(SUM(total_cents), 0) AS total, COUNT(*) AS orders FROM orders WHERE seller_id = $1 AND status = ANY($2)`, sellerID, baseStatuses); err != nil {
return nil, err
}
var topProducts []domain.TopProduct
topQuery := `SELECT oi.product_id, p.name, SUM(oi.quantity) AS total_quantity, SUM(oi.quantity * oi.unit_cents) AS revenue_cents
FROM order_items oi
JOIN orders o ON o.id = oi.order_id
JOIN products p ON p.id = oi.product_id
WHERE o.seller_id = $1 AND o.status = ANY($2)
GROUP BY oi.product_id, p.name
ORDER BY total_quantity DESC
LIMIT 5`
if err := r.db.SelectContext(ctx, &topProducts, topQuery, sellerID, baseStatuses); err != nil {
return nil, err
}
var lowStock []domain.Product
if err := r.db.SelectContext(ctx, &lowStock, `SELECT id, seller_id, name, description, batch, expires_at, price_cents, stock, created_at, updated_at FROM products WHERE seller_id = $1 AND stock <= 10 ORDER BY stock ASC, updated_at DESC`, sellerID); err != nil {
return nil, err
}
total := int64(0)
if sales.Total != nil {
total = *sales.Total
}
return &domain.SellerDashboard{
SellerID: sellerID,
TotalSalesCents: total,
OrdersCount: sales.Orders,
TopProducts: topProducts,
LowStockAlerts: lowStock,
}, nil
}
func (r *Repository) AdminDashboard(ctx context.Context, since time.Time) (*domain.AdminDashboard, error) {
var gmv *int64
if err := r.db.GetContext(ctx, &gmv, `SELECT COALESCE(SUM(total_cents), 0) FROM orders WHERE status = ANY($1)`, []string{string(domain.OrderStatusPaid), string(domain.OrderStatusInvoiced), string(domain.OrderStatusDelivered)}); err != nil {
return nil, err
}
var newCompanies int64
if err := r.db.GetContext(ctx, &newCompanies, `SELECT COUNT(*) FROM companies WHERE created_at >= $1`, since); err != nil {
return nil, err
}
totalGMV := int64(0)
if gmv != nil {
totalGMV = *gmv
}
return &domain.AdminDashboard{GMVCents: totalGMV, NewCompanies: newCompanies, WindowStartAt: since}, nil
}
// InitSchema applies a minimal schema for development environments. // InitSchema applies a minimal schema for development environments.
func (r *Repository) InitSchema(ctx context.Context) error { func (r *Repository) InitSchema(ctx context.Context) error {
schema := ` schema := `
@ -546,6 +637,18 @@ CREATE TABLE IF NOT EXISTS cart_items (
UNIQUE (buyer_id, product_id) UNIQUE (buyer_id, product_id)
); );
CREATE TABLE IF NOT EXISTS reviews (
id UUID PRIMARY KEY,
order_id UUID NOT NULL UNIQUE REFERENCES orders(id),
buyer_id UUID NOT NULL REFERENCES companies(id),
seller_id UUID NOT NULL REFERENCES companies(id),
rating INT NOT NULL CHECK (rating BETWEEN 1 AND 5),
comment TEXT,
created_at TIMESTAMPTZ NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_reviews_seller_id ON reviews (seller_id);
CREATE TABLE IF NOT EXISTS shipments ( CREATE TABLE IF NOT EXISTS shipments (
id UUID PRIMARY KEY, id UUID PRIMARY KEY,
order_id UUID NOT NULL UNIQUE REFERENCES orders(id), order_id UUID NOT NULL UNIQUE REFERENCES orders(id),

View file

@ -66,6 +66,7 @@ func New(cfg config.Config) (*Server, error) {
mux.Handle("GET /api/companies", chain(http.HandlerFunc(h.ListCompanies), middleware.Logger, middleware.Gzip)) mux.Handle("GET /api/companies", chain(http.HandlerFunc(h.ListCompanies), middleware.Logger, middleware.Gzip))
mux.Handle("PATCH /api/v1/companies/", chain(http.HandlerFunc(h.VerifyCompany), middleware.Logger, middleware.Gzip, adminOnly)) mux.Handle("PATCH /api/v1/companies/", chain(http.HandlerFunc(h.VerifyCompany), middleware.Logger, middleware.Gzip, adminOnly))
mux.Handle("GET /api/v1/companies/me", chain(http.HandlerFunc(h.GetMyCompany), middleware.Logger, middleware.Gzip, auth)) mux.Handle("GET /api/v1/companies/me", chain(http.HandlerFunc(h.GetMyCompany), middleware.Logger, middleware.Gzip, auth))
mux.Handle("GET /api/v1/companies/", chain(http.HandlerFunc(h.GetCompanyRating), middleware.Logger, middleware.Gzip))
mux.Handle("POST /api/products", chain(http.HandlerFunc(h.CreateProduct), middleware.Logger, middleware.Gzip)) mux.Handle("POST /api/products", chain(http.HandlerFunc(h.CreateProduct), middleware.Logger, middleware.Gzip))
mux.Handle("GET /api/products", chain(http.HandlerFunc(h.ListProducts), middleware.Logger, middleware.Gzip)) mux.Handle("GET /api/products", chain(http.HandlerFunc(h.ListProducts), middleware.Logger, middleware.Gzip))
@ -83,6 +84,10 @@ func New(cfg config.Config) (*Server, error) {
mux.Handle("POST /api/v1/payments/webhook", chain(http.HandlerFunc(h.HandlePaymentWebhook), middleware.Logger, middleware.Gzip)) mux.Handle("POST /api/v1/payments/webhook", chain(http.HandlerFunc(h.HandlePaymentWebhook), middleware.Logger, middleware.Gzip))
mux.Handle("POST /api/v1/reviews", chain(http.HandlerFunc(h.CreateReview), middleware.Logger, middleware.Gzip, auth))
mux.Handle("GET /api/v1/dashboard/seller", chain(http.HandlerFunc(h.GetSellerDashboard), middleware.Logger, middleware.Gzip, auth))
mux.Handle("GET /api/v1/dashboard/admin", chain(http.HandlerFunc(h.GetAdminDashboard), middleware.Logger, middleware.Gzip, adminOnly))
mux.Handle("POST /api/v1/auth/register", chain(http.HandlerFunc(h.Register), middleware.Logger, middleware.Gzip)) mux.Handle("POST /api/v1/auth/register", chain(http.HandlerFunc(h.Register), middleware.Logger, middleware.Gzip))
mux.Handle("POST /api/v1/auth/login", chain(http.HandlerFunc(h.Login), middleware.Logger, middleware.Gzip)) mux.Handle("POST /api/v1/auth/login", chain(http.HandlerFunc(h.Login), middleware.Logger, middleware.Gzip))

View file

@ -43,6 +43,11 @@ type Repository interface {
AddCartItem(ctx context.Context, item *domain.CartItem) (*domain.CartItem, error) AddCartItem(ctx context.Context, item *domain.CartItem) (*domain.CartItem, error)
ListCartItems(ctx context.Context, buyerID uuid.UUID) ([]domain.CartItem, error) ListCartItems(ctx context.Context, buyerID uuid.UUID) ([]domain.CartItem, error)
DeleteCartItem(ctx context.Context, id uuid.UUID, buyerID uuid.UUID) error DeleteCartItem(ctx context.Context, id uuid.UUID, buyerID uuid.UUID) error
CreateReview(ctx context.Context, review *domain.Review) error
GetCompanyRating(ctx context.Context, companyID uuid.UUID) (*domain.CompanyRating, error)
SellerDashboard(ctx context.Context, sellerID uuid.UUID) (*domain.SellerDashboard, error)
AdminDashboard(ctx context.Context, since time.Time) (*domain.AdminDashboard, error)
} }
// PaymentGateway abstracts Mercado Pago integration. // PaymentGateway abstracts Mercado Pago integration.
@ -296,6 +301,56 @@ func (s *Service) cartSummary(ctx context.Context, buyerID uuid.UUID) (*domain.C
return summary, nil return summary, nil
} }
// CreateReview stores a buyer rating ensuring the order is delivered and owned by the requester.
func (s *Service) CreateReview(ctx context.Context, buyerID, orderID uuid.UUID, rating int, comment string) (*domain.Review, error) {
if rating < 1 || rating > 5 {
return nil, errors.New("rating must be between 1 and 5")
}
order, err := s.repo.GetOrder(ctx, orderID)
if err != nil {
return nil, err
}
if order.Status != domain.OrderStatusDelivered {
return nil, errors.New("only delivered orders can be reviewed")
}
if order.BuyerID != buyerID {
return nil, errors.New("order does not belong to buyer")
}
review := &domain.Review{
ID: uuid.Must(uuid.NewV7()),
OrderID: orderID,
BuyerID: buyerID,
SellerID: order.SellerID,
Rating: rating,
Comment: comment,
}
if err := s.repo.CreateReview(ctx, review); err != nil {
return nil, err
}
return review, nil
}
// GetCompanyRating returns the average rating for a seller or pharmacy.
func (s *Service) GetCompanyRating(ctx context.Context, companyID uuid.UUID) (*domain.CompanyRating, error) {
return s.repo.GetCompanyRating(ctx, companyID)
}
// GetSellerDashboard aggregates commercial KPIs for the seller.
func (s *Service) GetSellerDashboard(ctx context.Context, sellerID uuid.UUID) (*domain.SellerDashboard, error) {
return s.repo.SellerDashboard(ctx, sellerID)
}
// GetAdminDashboard exposes marketplace-wide metrics for the last 30 days.
func (s *Service) GetAdminDashboard(ctx context.Context) (*domain.AdminDashboard, error) {
since := time.Now().AddDate(0, 0, -30)
return s.repo.AdminDashboard(ctx, since)
}
// RegisterAccount creates a company when needed and persists a user bound to it. // RegisterAccount creates a company when needed and persists a user bound to it.
func (s *Service) RegisterAccount(ctx context.Context, company *domain.Company, user *domain.User, password string) error { func (s *Service) RegisterAccount(ctx context.Context, company *domain.Company, user *domain.User, password string) error {
if company != nil { if company != nil {