diff --git a/backend/internal/domain/models.go b/backend/internal/domain/models.go index 2f52f6a..8430175 100644 --- a/backend/internal/domain/models.go +++ b/backend/internal/domain/models.go @@ -196,3 +196,45 @@ type CartSummary struct { TotalCents int64 `json:"total_cents"` 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"` +} diff --git a/backend/internal/http/handler/handler.go b/backend/internal/http/handler/handler.go index ae8ed8d..8fe262d 100644 --- a/backend/internal/http/handler/handler.go +++ b/backend/internal/http/handler/handler.go @@ -179,6 +179,28 @@ func (h *Handler) GetMyCompany(w http.ResponseWriter, r *http.Request) { 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 // @Summary Cadastro de produto com rastreabilidade de lote // @Tags Produtos @@ -365,6 +387,29 @@ func (h *Handler) UpdateOrderStatus(w http.ResponseWriter, r *http.Request) { 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. func (h *Handler) AddToCart(w http.ResponseWriter, r *http.Request) { 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) } +// 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. func (h *Handler) CreateUser(w http.ResponseWriter, r *http.Request) { requester, err := getRequester(r) @@ -779,6 +883,12 @@ type addCartItemRequest struct { Quantity int64 `json:"quantity"` } +type createReviewRequest struct { + OrderID uuid.UUID `json:"order_id"` + Rating int `json:"rating"` + Comment string `json:"comment"` +} + type updateUserRequest struct { CompanyID *uuid.UUID `json:"company_id,omitempty"` Role *string `json:"role,omitempty"` diff --git a/backend/internal/repository/postgres/postgres.go b/backend/internal/repository/postgres/postgres.go index 9b1291a..a1d28ca 100644 --- a/backend/internal/repository/postgres/postgres.go +++ b/backend/internal/repository/postgres/postgres.go @@ -458,6 +458,97 @@ func (r *Repository) DeleteCartItem(ctx context.Context, id uuid.UUID, buyerID u 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. func (r *Repository) InitSchema(ctx context.Context) error { schema := ` @@ -546,6 +637,18 @@ CREATE TABLE IF NOT EXISTS cart_items ( 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 ( id UUID PRIMARY KEY, order_id UUID NOT NULL UNIQUE REFERENCES orders(id), diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go index e73f34d..3588897 100644 --- a/backend/internal/server/server.go +++ b/backend/internal/server/server.go @@ -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("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/", 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("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/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/login", chain(http.HandlerFunc(h.Login), middleware.Logger, middleware.Gzip)) diff --git a/backend/internal/usecase/usecase.go b/backend/internal/usecase/usecase.go index c227ce0..95b4a75 100644 --- a/backend/internal/usecase/usecase.go +++ b/backend/internal/usecase/usecase.go @@ -43,6 +43,11 @@ type Repository interface { AddCartItem(ctx context.Context, item *domain.CartItem) (*domain.CartItem, error) ListCartItems(ctx context.Context, buyerID uuid.UUID) ([]domain.CartItem, 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. @@ -296,6 +301,56 @@ func (s *Service) cartSummary(ctx context.Context, buyerID uuid.UUID) (*domain.C 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. func (s *Service) RegisterAccount(ctx context.Context, company *domain.Company, user *domain.User, password string) error { if company != nil {