diff --git a/cmd/web/router/routes.go b/cmd/web/router/routes.go index b087183..fa4c3fb 100644 --- a/cmd/web/router/routes.go +++ b/cmd/web/router/routes.go @@ -265,6 +265,7 @@ func RegisterPublicRoutes(e *echo.Echo, customerHandler *customer.Handler, tende feedbackGP.GET("", feedbackHandler.PublicList) feedbackGP.GET("/tender/:tender_id", feedbackHandler.PublicGetByTenderID) feedbackGP.GET("/:id", feedbackHandler.PublicGet) + feedbackGP.GET("/stats/customer", feedbackHandler.GetCustomerStats) feedbackGP.GET("/stats/company", feedbackHandler.GetCompanyStats) } diff --git a/internal/feedback/form.go b/internal/feedback/form.go index 06a39ee..4e19579 100644 --- a/internal/feedback/form.go +++ b/internal/feedback/form.go @@ -87,3 +87,12 @@ type StatsResponse struct { TotalFeedback int64 `json:"total_feedback"` LastUpdated int64 `json:"last_updated"` } + +// CustomerStatsResponse represents feedback statistics for a single customer. +type CustomerStatsResponse struct { + CustomerID string `json:"customer_id"` + TotalLikes int64 `json:"total_likes"` + TotalDislikes int64 `json:"total_dislikes"` + TotalFeedback int64 `json:"total_feedback"` + LastUpdated int64 `json:"last_updated"` +} diff --git a/internal/feedback/handler.go b/internal/feedback/handler.go index f1e8d32..81bb12a 100644 --- a/internal/feedback/handler.go +++ b/internal/feedback/handler.go @@ -195,7 +195,7 @@ func (h *Handler) Toggle(c echo.Context) error { // ListFeedback retrieves feedback with pagination and filtering // @Summary List feedback with filters -// @Description Retrieve paginated list of feedback with optional filtering by various criteria. This endpoint is for public use and filters results based on the authenticated user's company. +// @Description Retrieve paginated list of feedback for the authenticated customer with optional filters. // @Tags Feedback // @Accept json // @Produce json @@ -214,16 +214,16 @@ func (h *Handler) Toggle(c echo.Context) error { // @Security BearerAuth // @Router /api/v1/feedback [get] func (h *Handler) PublicList(c echo.Context) error { - companyID, err := user.GetCompanyIDFromContext(c) + customerID, err := customer.GetCustomerIDFromContext(c) if err != nil { - return response.BadRequest(c, "Company ID required", "User must be associated with a company") + return response.BadRequest(c, "Customer ID required", "User must be associated with a customer") } form, err := response.Parse[SearchForm](c) if err != nil { return response.ValidationError(c, "Invalid request data", err.Error()) } - form.Company = &companyID + form.Customer = &customerID pagination, err := response.NewPagination(c) if err != nil { @@ -287,12 +287,12 @@ func (h *Handler) PublicGetByTenderID(c echo.Context) error { return response.BadRequest(c, "Tender ID is required", "Tender ID parameter is missing") } - companyID, err := user.GetCompanyIDFromContext(c) + customerID, err := customer.GetCustomerIDFromContext(c) if err != nil { - return response.BadRequest(c, "Company ID required", "User must be associated with a company") + return response.BadRequest(c, "Customer ID required", "User must be associated with a customer") } - feedback, err := h.service.GetByTenderID(c.Request().Context(), tenderID, companyID) + feedback, err := h.service.GetByTenderIDForCustomer(c.Request().Context(), tenderID, customerID) if err != nil { return response.BadRequest(c, "Failed to get feedback", "") } @@ -300,28 +300,44 @@ func (h *Handler) PublicGetByTenderID(c echo.Context) error { return response.Success(c, feedback, "Feedback retrieved successfully") } -// GetCompanyFeedbackStats retrieves feedback statistics for the authenticated user's company -// @Summary Get company feedback statistics -// @Description Retrieve comprehensive feedback statistics for the authenticated user's company including total likes, dislikes, and percentage calculations. +// GetCustomerStats retrieves feedback statistics for the authenticated customer. +// @Summary Get customer feedback statistics +// @Description Retrieve feedback statistics (likes, dislikes, totals) for the authenticated customer only. // @Tags Feedback // @Accept json // @Produce json -// @Success 200 {object} response.APIResponse{data=StatsResponse} "Company feedback statistics retrieved successfully" -// @Failure 400 {object} response.APIResponse{error=string,message=string} "Bad request - Missing company association" +// @Success 200 {object} response.APIResponse{data=CustomerStatsResponse} "Customer feedback statistics retrieved successfully" +// @Failure 400 {object} response.APIResponse{error=string,message=string} "Bad request - Missing customer association" +// @Failure 401 {object} response.APIResponse{error=string,message=string} "Unauthorized - User not authenticated" +// @Failure 500 {object} response.APIResponse{error=string,message=string} "Internal server error" +// @Security BearerAuth +// @Router /api/v1/feedback/stats/customer [get] +func (h *Handler) GetCustomerStats(c echo.Context) error { + customerID, err := customer.GetCustomerIDFromContext(c) + if err != nil { + return response.BadRequest(c, "Customer ID required", "User must be associated with a customer") + } + + stats, err := h.service.CustomerStats(c.Request().Context(), customerID) + if err != nil { + return response.InternalServerError(c, "Failed to retrieve customer stats") + } + + return response.Success(c, stats, "Customer stats retrieved successfully") +} + +// GetCompanyStats returns customer-scoped stats for backward compatibility with clients using /stats/company. +// @Summary Get feedback statistics (customer-scoped, legacy path) +// @Description Same as GET /feedback/stats/customer. Prefer /stats/customer for new integrations. +// @Tags Feedback +// @Accept json +// @Produce json +// @Success 200 {object} response.APIResponse{data=CustomerStatsResponse} "Customer feedback statistics retrieved successfully" +// @Failure 400 {object} response.APIResponse{error=string,message=string} "Bad request - Missing customer association" // @Failure 401 {object} response.APIResponse{error=string,message=string} "Unauthorized - User not authenticated" // @Failure 500 {object} response.APIResponse{error=string,message=string} "Internal server error" // @Security BearerAuth // @Router /api/v1/feedback/stats/company [get] func (h *Handler) GetCompanyStats(c echo.Context) error { - companyID, err := user.GetCompanyIDFromContext(c) - if err != nil { - return response.BadRequest(c, "Company ID required", "User must be associated with a company") - } - - stats, err := h.service.CompanyStats(c.Request().Context(), companyID) - if err != nil { - return response.InternalServerError(c, "Failed to retrieve company stats") - } - - return response.Success(c, stats, "Company stats retrieved successfully") + return h.GetCustomerStats(c) } diff --git a/internal/feedback/repository.go b/internal/feedback/repository.go index 321d138..d61f9f4 100644 --- a/internal/feedback/repository.go +++ b/internal/feedback/repository.go @@ -19,9 +19,12 @@ type Repository interface { Search(ctx context.Context, criteria SearchForm, pagination *response.Pagination) (*orm.PaginatedResult[Feedback], error) GetByID(ctx context.Context, id string) (*Feedback, error) GetByCompanyID(ctx context.Context, companyID string, tender string) (*Feedback, error) + GetByCustomerID(ctx context.Context, customerID string, tenderID string) (*Feedback, error) GetByTenderID(ctx context.Context, tenderID, companyID string) (*Feedback, error) + GetByTenderIDForCustomer(ctx context.Context, tenderID, customerID string) (*Feedback, error) Delete(ctx context.Context, id string) error CompanyStats(ctx context.Context, companyID string) (*StatsResponse, error) + CustomerStats(ctx context.Context, customerID string) (*CustomerStatsResponse, error) } // feedbackRepository implements FeedbackRepository interface using MongoDB ORM @@ -42,6 +45,11 @@ func NewRepository(mongoManager *orm.ConnectionManager, logger logger.Logger) Re {Key: "tender_id", Value: 1}, {Key: "company_id", Value: 1}, }), + *orm.NewIndex("customer_id_idx", bson.D{{Key: "customer_id", Value: 1}}), + *orm.NewIndex("tender_customer_idx", bson.D{ + {Key: "tender_id", Value: 1}, + {Key: "customer_id", Value: 1}, + }), *orm.NewIndex("tender_type_idx", bson.D{ {Key: "tender_id", Value: 1}, {Key: "feedback_type", Value: 1}, @@ -140,6 +148,23 @@ func (r *feedbackRepository) GetByCompanyID(ctx context.Context, companyID strin return result, nil } +// GetByCustomerID retrieves feedback by customer ID and tender ID. +func (r *feedbackRepository) GetByCustomerID(ctx context.Context, customerID string, tenderID string) (*Feedback, error) { + filter := bson.M{"customer_id": customerID, "tender_id": tenderID} + + result, err := r.repo.FindOne(ctx, filter) + if err != nil { + r.logger.Error("Failed to get feedback by customer ID and tender ID", map[string]interface{}{ + "customer_id": customerID, + "tender_id": tenderID, + "error": err.Error(), + }) + return nil, err + } + + return result, nil +} + // GetByTenderID retrieves feedback by tender ID and company ID func (r *feedbackRepository) GetByTenderID(ctx context.Context, tenderID, companyID string) (*Feedback, error) { filter := bson.M{"tender_id": tenderID, "company_id": companyID} @@ -157,6 +182,23 @@ func (r *feedbackRepository) GetByTenderID(ctx context.Context, tenderID, compan return result, nil } +// GetByTenderIDForCustomer retrieves feedback by tender ID and customer ID. +func (r *feedbackRepository) GetByTenderIDForCustomer(ctx context.Context, tenderID, customerID string) (*Feedback, error) { + filter := bson.M{"tender_id": tenderID, "customer_id": customerID} + + result, err := r.repo.FindOne(ctx, filter) + if err != nil { + r.logger.Error("Failed to get feedback by tender ID and customer ID", map[string]interface{}{ + "tender_id": tenderID, + "customer_id": customerID, + "error": err.Error(), + }) + return nil, err + } + + return result, nil +} + // Delete deletes a feedback by ID func (r *feedbackRepository) Delete(ctx context.Context, id string) error { err := r.repo.Delete(ctx, id) @@ -199,20 +241,59 @@ func (r *feedbackRepository) Search(ctx context.Context, criteria SearchForm, pa return result, nil } -// Stats calculates feedback statistics for a company +// CompanyStats calculates feedback statistics for a company. func (r *feedbackRepository) CompanyStats(ctx context.Context, companyID string) (*StatsResponse, error) { + counts, lastUpdated, err := r.aggregateFeedbackCounts(ctx, bson.M{"company_id": companyID}) + if err != nil { + r.logger.Error("Failed to get feedback stats", map[string]interface{}{ + "company_id": companyID, + "error": err.Error(), + }) + return nil, err + } + + return &StatsResponse{ + CompanyID: companyID, + TotalFeedback: counts.totalFeedback, + TotalLikes: counts.totalLikes, + TotalDislikes: counts.totalDislikes, + LastUpdated: lastUpdated, + }, nil +} + +// CustomerStats calculates feedback statistics for a customer. +func (r *feedbackRepository) CustomerStats(ctx context.Context, customerID string) (*CustomerStatsResponse, error) { + counts, lastUpdated, err := r.aggregateFeedbackCounts(ctx, bson.M{"customer_id": customerID}) + if err != nil { + r.logger.Error("Failed to get customer feedback stats", map[string]interface{}{ + "customer_id": customerID, + "error": err.Error(), + }) + return nil, err + } + + return &CustomerStatsResponse{ + CustomerID: customerID, + TotalFeedback: counts.totalFeedback, + TotalLikes: counts.totalLikes, + TotalDislikes: counts.totalDislikes, + LastUpdated: lastUpdated, + }, nil +} + +type feedbackCountAggregate struct { + totalFeedback int64 + totalLikes int64 + totalDislikes int64 +} + +func (r *feedbackRepository) aggregateFeedbackCounts(ctx context.Context, match bson.M) (feedbackCountAggregate, int64, error) { pipeline := mongo.Pipeline{ - { - {Key: "$match", Value: bson.M{ - "company_id": companyID, - }}, - }, + {{Key: "$match", Value: match}}, { {Key: "$group", Value: bson.M{ "_id": nil, - "total_feedback": bson.M{ - "$sum": 1, - }, + "total_feedback": bson.M{"$sum": 1}, "total_likes": bson.M{ "$sum": bson.M{ "$cond": []interface{}{ @@ -238,38 +319,37 @@ func (r *feedbackRepository) CompanyStats(ctx context.Context, companyID string) results, err := r.repo.Aggregate(ctx, pipeline) if err != nil { - r.logger.Error("Failed to get feedback stats", map[string]interface{}{ - "company_id": companyID, - "error": err.Error(), - }) - return nil, err + return feedbackCountAggregate{}, time.Now().Unix(), err } - stats := &StatsResponse{ - CompanyID: companyID, - TotalFeedback: 0, - TotalLikes: 0, - TotalDislikes: 0, - LastUpdated: time.Now().Unix(), + counts := feedbackCountAggregate{} + lastUpdated := time.Now().Unix() + if len(results) == 0 { + return counts, lastUpdated, nil } - if len(results) > 0 { - result := results[0] - if totalFeedback, ok := result["total_feedback"].(int32); ok { - stats.TotalFeedback = int64(totalFeedback) - } - if totalLikes, ok := result["total_likes"].(int32); ok { - stats.TotalLikes = int64(totalLikes) - } - if totalDislikes, ok := result["total_dislikes"].(int32); ok { - stats.TotalDislikes = int64(totalDislikes) - } - if lastUpdated, ok := result["last_updated"].(int64); ok { - stats.LastUpdated = lastUpdated - } + result := results[0] + counts.totalFeedback = aggregateInt64(result["total_feedback"]) + counts.totalLikes = aggregateInt64(result["total_likes"]) + counts.totalDislikes = aggregateInt64(result["total_dislikes"]) + if v, ok := result["last_updated"].(int64); ok && v > 0 { + lastUpdated = v } - return stats, nil + return counts, lastUpdated, nil +} + +func aggregateInt64(value interface{}) int64 { + switch v := value.(type) { + case int32: + return int64(v) + case int64: + return v + case float64: + return int64(v) + default: + return 0 + } } // buildSearchFilter builds MongoDB filter from search criteria diff --git a/internal/feedback/service.go b/internal/feedback/service.go index d17a6c5..4201786 100644 --- a/internal/feedback/service.go +++ b/internal/feedback/service.go @@ -14,8 +14,10 @@ type Service interface { Search(ctx context.Context, criteria SearchForm, pagination *response.Pagination) (*FeedbackListResponse, error) Get(ctx context.Context, id string) (*FeedbackResponse, error) GetByTenderID(ctx context.Context, tenderID, companyID string) (*FeedbackResponse, error) + GetByTenderIDForCustomer(ctx context.Context, tenderID, customerID string) (*FeedbackResponse, error) Delete(ctx context.Context, id string) error CompanyStats(ctx context.Context, companyID string) (*StatsResponse, error) + CustomerStats(ctx context.Context, customerID string) (*CustomerStatsResponse, error) getDetails(ctx context.Context, tenderID, companyID string) (*tenderResponse, *companyResponse) } @@ -38,7 +40,7 @@ func NewService(feedbackRepo Repository, tenderService tender.Service, companySe } func (s *feedbackService) Toggle(ctx context.Context, req ToggleFeedbackForm, customerID *string, companyID *string) (*FeedbackResponse, error) { - existing, err := s.feedbackRepo.GetByCompanyID(ctx, *companyID, req.Tender) + existing, err := s.feedbackRepo.GetByCustomerID(ctx, *customerID, req.Tender) if err != nil { feedback := &Feedback{ @@ -51,9 +53,10 @@ func (s *feedbackService) Toggle(ctx context.Context, req ToggleFeedbackForm, cu err = s.feedbackRepo.Create(ctx, feedback) if err != nil { s.logger.Error("Failed to create new feedback", map[string]interface{}{ - "tender_id": req.Tender, - "company_id": *companyID, - "error": err.Error(), + "tender_id": req.Tender, + "customer_id": *customerID, + "company_id": *companyID, + "error": err.Error(), }) return nil, err } @@ -66,10 +69,10 @@ func (s *feedbackService) Toggle(ctx context.Context, req ToggleFeedbackForm, cu if existing.FeedbackType == req.Type { err = s.feedbackRepo.Delete(ctx, existing.GetID()) if err != nil { - s.logger.Error("Failed to update existing feedback", map[string]interface{}{ + s.logger.Error("Failed to delete existing feedback", map[string]interface{}{ "feedback_id": existing.GetID(), "tender_id": req.Tender, - "company_id": *companyID, + "customer_id": *customerID, "error": err.Error(), }) return nil, err @@ -97,7 +100,7 @@ func (s *feedbackService) Toggle(ctx context.Context, req ToggleFeedbackForm, cu s.logger.Error("Failed to update existing feedback", map[string]interface{}{ "feedback_id": existing.GetID(), "tender_id": req.Tender, - "company_id": *companyID, + "customer_id": *customerID, "error": err.Error(), }) return nil, err @@ -177,6 +180,22 @@ func (s *feedbackService) GetByTenderID(ctx context.Context, tenderID, companyID return feedback.ToResponse(tender, company), nil } +func (s *feedbackService) GetByTenderIDForCustomer(ctx context.Context, tenderID, customerID string) (*FeedbackResponse, error) { + feedback, err := s.feedbackRepo.GetByTenderIDForCustomer(ctx, tenderID, customerID) + if err != nil { + s.logger.Error("Failed to get feedback by tender ID for customer", map[string]interface{}{ + "tender_id": tenderID, + "customer_id": customerID, + "error": err.Error(), + }) + return nil, err + } + + tender, company := s.getDetails(ctx, feedback.TenderID, feedbackCompanyID(*feedback)) + + return feedback.ToResponse(tender, company), nil +} + func feedbackCompanyID(f Feedback) string { if f.CompanyID == nil { return "" @@ -346,3 +365,27 @@ func (s *feedbackService) CompanyStats(ctx context.Context, companyID string) (* return stats, nil } + +func (s *feedbackService) CustomerStats(ctx context.Context, customerID string) (*CustomerStatsResponse, error) { + s.logger.Info("Getting customer feedback statistics", map[string]interface{}{ + "customer_id": customerID, + }) + + stats, err := s.feedbackRepo.CustomerStats(ctx, customerID) + if err != nil { + s.logger.Error("Failed to get customer stats", map[string]interface{}{ + "customer_id": customerID, + "error": err.Error(), + }) + return nil, err + } + + s.logger.Info("Customer stats retrieved successfully", map[string]interface{}{ + "customer_id": customerID, + "total_feedback": stats.TotalFeedback, + "total_likes": stats.TotalLikes, + "total_dislikes": stats.TotalDislikes, + }) + + return stats, nil +}