Merge pull request #10 from rede5/codex/create-b2b-reputation-system
Add rating and dashboard endpoints
This commit is contained in:
commit
a1a65ab831
5 changed files with 315 additions and 0 deletions
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue