Implement Feedback Management System with CRUD Operations and API Enhancements

- Introduced a new feedback management system, including the creation of feedback entities, services, and repositories.
- Implemented CRUD operations for feedback, allowing users to submit likes and dislikes on tenders.
- Developed administrative endpoints for listing and retrieving feedback with comprehensive filtering options.
- Enhanced public API to allow users to view feedback related to tenders.
- Updated Swagger documentation to reflect new feedback endpoints and their functionalities.
- Removed obsolete configuration file `config.yaml` as part of the transition to a modular configuration system.
- Ensured adherence to Clean Architecture principles throughout the implementation.
This commit is contained in:
n.nakhostin
2025-08-16 16:00:03 +03:30
parent 126913365f
commit 21b736b55b
15 changed files with 3548 additions and 108 deletions
+32 -32
View File
@@ -5,7 +5,7 @@ import (
"errors"
"time"
"tm/pkg/logger"
mongopkg "tm/pkg/mongo"
"tm/pkg/mongo"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
@@ -39,36 +39,36 @@ type Repository interface {
// companyRepository implements the Repository interface using the MongoDB ORM
type companyRepository struct {
ormRepo mongopkg.Repository[Company]
ormRepo mongo.Repository[Company]
logger logger.Logger
}
// NewCompanyRepository creates a new company repository
func NewCompanyRepository(mongoManager *mongopkg.ConnectionManager, logger logger.Logger) Repository {
func NewCompanyRepository(mongoManager *mongo.ConnectionManager, logger logger.Logger) Repository {
// Create indexes using the ORM's index management
indexes := []mongopkg.Index{
*mongopkg.CreateUniqueIndex("name_idx", bson.D{{Key: "name", Value: 1}}),
*mongopkg.CreateUniqueIndex("registration_number_idx", bson.D{{Key: "registration_number", Value: 1}}),
*mongopkg.CreateUniqueIndex("tax_id_idx", bson.D{{Key: "tax_id", Value: 1}}),
*mongopkg.NewIndex("type_idx", bson.D{{Key: "type", Value: 1}}),
*mongopkg.NewIndex("status_idx", bson.D{{Key: "status", Value: 1}}),
*mongopkg.NewIndex("industry_idx", bson.D{{Key: "industry", Value: 1}}),
*mongopkg.NewIndex("is_verified_idx", bson.D{{Key: "is_verified", Value: 1}}),
*mongopkg.NewIndex("is_compliant_idx", bson.D{{Key: "is_compliant", Value: 1}}),
*mongopkg.NewIndex("language_idx", bson.D{{Key: "language", Value: 1}}),
*mongopkg.NewIndex("currency_idx", bson.D{{Key: "currency", Value: 1}}),
*mongopkg.NewIndex("employee_count_idx", bson.D{{Key: "employee_count", Value: 1}}),
*mongopkg.NewIndex("annual_revenue_idx", bson.D{{Key: "annual_revenue", Value: 1}}),
*mongopkg.NewIndex("founded_year_idx", bson.D{{Key: "founded_year", Value: 1}}),
*mongopkg.NewIndex("created_at_idx", bson.D{{Key: "created_at", Value: -1}}),
indexes := []mongo.Index{
*mongo.CreateUniqueIndex("name_idx", bson.D{{Key: "name", Value: 1}}),
*mongo.CreateUniqueIndex("registration_number_idx", bson.D{{Key: "registration_number", Value: 1}}),
*mongo.CreateUniqueIndex("tax_id_idx", bson.D{{Key: "tax_id", Value: 1}}),
*mongo.NewIndex("type_idx", bson.D{{Key: "type", Value: 1}}),
*mongo.NewIndex("status_idx", bson.D{{Key: "status", Value: 1}}),
*mongo.NewIndex("industry_idx", bson.D{{Key: "industry", Value: 1}}),
*mongo.NewIndex("is_verified_idx", bson.D{{Key: "is_verified", Value: 1}}),
*mongo.NewIndex("is_compliant_idx", bson.D{{Key: "is_compliant", Value: 1}}),
*mongo.NewIndex("language_idx", bson.D{{Key: "language", Value: 1}}),
*mongo.NewIndex("currency_idx", bson.D{{Key: "currency", Value: 1}}),
*mongo.NewIndex("employee_count_idx", bson.D{{Key: "employee_count", Value: 1}}),
*mongo.NewIndex("annual_revenue_idx", bson.D{{Key: "annual_revenue", Value: 1}}),
*mongo.NewIndex("founded_year_idx", bson.D{{Key: "founded_year", Value: 1}}),
*mongo.NewIndex("created_at_idx", bson.D{{Key: "created_at", Value: -1}}),
// Tag indexes for efficient search
*mongopkg.NewIndex("cpv_codes_idx", bson.D{{Key: "tags.cpv_codes", Value: 1}}),
*mongopkg.NewIndex("categories_idx", bson.D{{Key: "tags.categories", Value: 1}}),
*mongopkg.NewIndex("keywords_idx", bson.D{{Key: "tags.keywords", Value: 1}}),
*mongopkg.NewIndex("specializations_idx", bson.D{{Key: "tags.specializations", Value: 1}}),
*mongopkg.NewIndex("certifications_idx", bson.D{{Key: "tags.certifications", Value: 1}}),
*mongo.NewIndex("cpv_codes_idx", bson.D{{Key: "tags.cpv_codes", Value: 1}}),
*mongo.NewIndex("categories_idx", bson.D{{Key: "tags.categories", Value: 1}}),
*mongo.NewIndex("keywords_idx", bson.D{{Key: "tags.keywords", Value: 1}}),
*mongo.NewIndex("specializations_idx", bson.D{{Key: "tags.specializations", Value: 1}}),
*mongo.NewIndex("certifications_idx", bson.D{{Key: "tags.certifications", Value: 1}}),
// Text index for search
*mongopkg.CreateTextIndex("search_idx", "name", "description", "industry"),
*mongo.CreateTextIndex("search_idx", "name", "description", "industry"),
}
// Create indexes
@@ -80,7 +80,7 @@ func NewCompanyRepository(mongoManager *mongopkg.ConnectionManager, logger logge
}
// Create ORM repository
ormRepo := mongopkg.NewRepository[Company](mongoManager.GetCollection("companies"), logger)
ormRepo := mongo.NewRepository[Company](mongoManager.GetCollection("companies"), logger)
return &companyRepository{
ormRepo: ormRepo,
@@ -119,7 +119,7 @@ func (r *companyRepository) Create(ctx context.Context, company *Company) error
func (r *companyRepository) GetByID(ctx context.Context, id string) (*Company, error) {
company, err := r.ormRepo.FindByID(ctx, id)
if err != nil {
if errors.Is(err, mongopkg.ErrDocumentNotFound) {
if errors.Is(err, mongo.ErrDocumentNotFound) {
return nil, errors.New("company not found")
}
r.logger.Error("Failed to get company by ID", map[string]interface{}{
@@ -137,7 +137,7 @@ func (r *companyRepository) GetByName(ctx context.Context, name string) (*Compan
filter := bson.M{"name": name}
company, err := r.ormRepo.FindOne(ctx, filter)
if err != nil {
if errors.Is(err, mongopkg.ErrDocumentNotFound) {
if errors.Is(err, mongo.ErrDocumentNotFound) {
return nil, errors.New("company not found")
}
r.logger.Error("Failed to get company by name", map[string]interface{}{
@@ -155,7 +155,7 @@ func (r *companyRepository) GetByRegistrationNumber(ctx context.Context, registr
filter := bson.M{"registration_number": registrationNumber}
company, err := r.ormRepo.FindOne(ctx, filter)
if err != nil {
if errors.Is(err, mongopkg.ErrDocumentNotFound) {
if errors.Is(err, mongo.ErrDocumentNotFound) {
return nil, errors.New("company not found")
}
r.logger.Error("Failed to get company by registration number", map[string]interface{}{
@@ -173,7 +173,7 @@ func (r *companyRepository) GetByTaxID(ctx context.Context, taxID string) (*Comp
filter := bson.M{"tax_id": taxID}
company, err := r.ormRepo.FindOne(ctx, filter)
if err != nil {
if errors.Is(err, mongopkg.ErrDocumentNotFound) {
if errors.Is(err, mongo.ErrDocumentNotFound) {
return nil, errors.New("company not found")
}
r.logger.Error("Failed to get company by tax ID", map[string]interface{}{
@@ -289,7 +289,7 @@ func (r *companyRepository) Delete(ctx context.Context, id string) error {
// List retrieves companies with pagination
func (r *companyRepository) List(ctx context.Context, limit, offset int) ([]*Company, error) {
// Build pagination
pagination := mongopkg.NewPaginationBuilder().
pagination := mongo.NewPaginationBuilder().
Limit(limit).
Skip(offset).
SortDesc("created_at").
@@ -407,7 +407,7 @@ func (r *companyRepository) Search(ctx context.Context, search string, companyTy
}
// Build pagination
pagination := mongopkg.NewPaginationBuilder().
pagination := mongo.NewPaginationBuilder().
Limit(limit).
Skip(offset)
@@ -755,7 +755,7 @@ func (r *companyRepository) SearchByTags(ctx context.Context, cpvCodes []string,
filter["status"] = bson.M{"$ne": CompanyStatusInactive}
// Build pagination
pagination := mongopkg.NewPaginationBuilder().
pagination := mongo.NewPaginationBuilder().
Limit(limit).
Skip(offset).
SortDesc("created_at").
+71
View File
@@ -0,0 +1,71 @@
package feedback
import (
"time"
"tm/pkg/mongo"
"tm/pkg/response"
)
// FeedbackType represents the type of feedback
type FeedbackType string
const (
FeedbackTypeLike FeedbackType = "like"
FeedbackTypeDislike FeedbackType = "dislike"
)
// Feedback represents feedback given to a tender by a user
type Feedback struct {
mongo.Model `bson:",inline"`
TenderID string `bson:"tender_id" json:"tender_id" validate:"required"`
CustomerID *string `bson:"customer_id,omitempty" json:"customer_id,omitempty"`
CompanyID *string `bson:"company_id,omitempty" json:"company_id,omitempty"`
FeedbackType FeedbackType `bson:"feedback_type" json:"feedback_type" validate:"required,oneof=like dislike"`
}
// FeedbackSummary represents aggregated feedback statistics for a tender
type FeedbackSummary struct {
TenderID string `bson:"tender_id" json:"tender_id"`
TotalLikes int64 `bson:"total_likes" json:"total_likes"`
TotalDislikes int64 `bson:"total_dislikes" json:"total_dislikes"`
LastUpdated int64 `bson:"last_updated" json:"last_updated"` // Unix timestamp
}
// FeedbackSearchCriteria represents search criteria for feedback
type FeedbackSearchCriteria struct {
TenderID *string `json:"tender_id,omitempty"`
CustomerID *string `json:"customer_id,omitempty"`
CompanyID *string `json:"company_id,omitempty"`
FeedbackType *FeedbackType `json:"feedback_type,omitempty"`
DateFrom *int64 `json:"date_from,omitempty"` // Unix timestamp
DateTo *int64 `json:"date_to,omitempty"` // Unix timestamp
Limit int `json:"limit,omitempty"`
Offset int `json:"offset,omitempty"`
}
// FeedbackListResponse represents a paginated list of feedback
type FeedbackListResponse struct {
Feedback []*FeedbackResponse `json:"feedback"`
Meta *response.Meta `json:"meta"`
}
// GetID returns the feedback ID
func (f *Feedback) GetID() string {
return f.ID.Hex()
}
// IsLike returns true if the feedback is a like
func (f *Feedback) IsLike() bool {
return f.FeedbackType == FeedbackTypeLike
}
// IsDislike returns true if the feedback is a dislike
func (f *Feedback) IsDislike() bool {
return f.FeedbackType == FeedbackTypeDislike
}
// UpdateFeedbackType updates the feedback type and updated timestamp
func (f *Feedback) UpdateFeedbackType(feedbackType FeedbackType) {
f.FeedbackType = feedbackType
f.UpdatedAt = time.Now().Unix()
}
+19
View File
@@ -0,0 +1,19 @@
package feedback
import "errors"
// Custom errors for feedback domain
var (
ErrFeedbackNotFound = errors.New("feedback not found")
ErrFeedbackAlreadyExists = errors.New("feedback already exists for this tender and user")
ErrInvalidFeedbackType = errors.New("invalid feedback type")
ErrInvalidRating = errors.New("invalid rating value")
ErrInvalidComment = errors.New("invalid comment")
ErrInvalidDateRange = errors.New("invalid date range: date_from must be before date_to")
ErrUnauthorizedAccess = errors.New("unauthorized access to feedback")
ErrFeedbackDeleted = errors.New("feedback has been deleted")
ErrTenderNotFound = errors.New("tender not found")
ErrUserNotFound = errors.New("user not found")
ErrCustomerNotFound = errors.New("customer not found")
ErrCompanyNotFound = errors.New("company not found")
)
+61
View File
@@ -0,0 +1,61 @@
package feedback
import (
"time"
)
// ListFeedbackForm represents the form for listing feedback
type ListFeedbackForm struct {
Feedbacks []FeedbackResponse `json:"feedbacks"`
DateFrom *int64 `json:"date_from,omitempty" validate:"omitempty,min=0"`
DateTo *int64 `json:"date_to,omitempty" validate:"omitempty,min=0"`
Limit int `json:"limit" validate:"required,min=1,max=100"`
Offset int `json:"offset" validate:"required,min=0"`
}
// ToggleFeedbackForm represents the form for toggling feedback (like/dislike)
type ToggleFeedbackForm struct {
TenderID string `json:"tender_id" validate:"required"`
FeedbackType FeedbackType `json:"feedback_type" validate:"required,oneof=like dislike"`
}
// FeedbackResponse represents the response for feedback operations
type FeedbackResponse struct {
ID string `json:"id"`
FeedbackType FeedbackType `json:"feedback_type"`
UpdatedAt int64 `json:"updated_at"`
CreatedAt int64 `json:"created_at"`
TenderID string `json:"tender"`
CustomerID *string `json:"customer_id"`
CompanyId *string `json:"company_id"`
}
// GetDateFromTime returns the date_from as time.Time
func (f *ListFeedbackForm) GetDateFromTime() *time.Time {
if f.DateFrom == nil {
return nil
}
t := time.Unix(*f.DateFrom, 0)
return &t
}
// GetDateToTime returns the date_to as time.Time
func (f *ListFeedbackForm) GetDateToTime() *time.Time {
if f.DateTo == nil {
return nil
}
t := time.Unix(*f.DateTo, 0)
return &t
}
func (f *Feedback) ToResponse() *FeedbackResponse {
return &FeedbackResponse{
ID: f.ID.Hex(),
FeedbackType: f.FeedbackType,
TenderID: f.TenderID,
CustomerID: f.CustomerID,
CompanyId: f.CompanyID,
UpdatedAt: f.UpdatedAt,
CreatedAt: f.CreatedAt,
}
}
+348
View File
@@ -0,0 +1,348 @@
package feedback
import (
"strconv"
"tm/internal/customer"
"tm/internal/user"
"tm/pkg/logger"
"tm/pkg/response"
"github.com/asaskevich/govalidator"
"github.com/labstack/echo/v4"
)
// Handler handles HTTP requests for feedback operations
type Handler struct {
service FeedbackService
logger logger.Logger
userHandler *user.Handler
customerHandler *customer.Handler
}
// NewFeedbackHandler creates a new feedback handler
func NewFeedbackHandler(
service FeedbackService,
userHandler *user.Handler,
customerHandler *customer.Handler,
logger logger.Logger,
) *Handler {
return &Handler{
service: service,
logger: logger,
userHandler: userHandler,
customerHandler: customerHandler,
}
}
// *************** ADMIN ***************
// 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 administrative use and provides comprehensive filtering options.
// @Tags Admin-Feedback
// @Accept json
// @Produce json
// @Param tender_id query string false "Filter by tender ID (MongoDB ObjectID format)"
// @Param customer_id query string false "Filter by customer ID (MongoDB ObjectID format)"
// @Param company_id query string false "Filter by company ID (MongoDB ObjectID format)"
// @Param feedback_type query string false "Filter by feedback type" Enums(like, dislike)
// @Param date_from query int64 false "Filter by creation date from (Unix timestamp in seconds)"
// @Param date_to query int64 false "Filter by creation date to (Unix timestamp in seconds)"
// @Param limit query int false "Number of items per page (default: 20, max: 100)" minimum(1) maximum(100)
// @Param offset query int false "Number of items to skip for pagination (default: 0)" minimum(0)
// @Success 200 {object} response.APIResponse{data=FeedbackListResponse} "Feedback list retrieved successfully"
// @Failure 400 {object} response.APIResponse{error=string,message=string} "Bad request - Invalid query parameters"
// @Failure 422 {object} response.APIResponse{error=string,message=string} "Validation error - Invalid filter criteria"
// @Failure 500 {object} response.APIResponse{error=string,message=string} "Internal server error"
// @Security BearerAuth
// @Router /admin/v1/feedback [get]
func (h *Handler) ListFeedback(c echo.Context) error {
// Parse query parameters
limitStr := c.QueryParam("limit")
offsetStr := c.QueryParam("offset")
limit := 20 // default limit
offset := 0 // default offset
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
limit = l
}
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
offset = o
}
// Build search criteria
criteria := FeedbackSearchCriteria{
Limit: limit,
Offset: offset,
}
// Parse optional filters
if tenderID := c.QueryParam("tender_id"); tenderID != "" {
criteria.TenderID = &tenderID
}
if customerID := c.QueryParam("customer_id"); customerID != "" {
criteria.CustomerID = &customerID
}
if companyID := c.QueryParam("company_id"); companyID != "" {
criteria.CompanyID = &companyID
}
if feedbackType := c.QueryParam("feedback_type"); feedbackType != "" {
ft := FeedbackType(feedbackType)
criteria.FeedbackType = &ft
}
if dateFrom := c.QueryParam("date_from"); dateFrom != "" {
if df, err := strconv.ParseInt(dateFrom, 10, 64); err == nil {
criteria.DateFrom = &df
}
}
if dateTo := c.QueryParam("date_to"); dateTo != "" {
if dt, err := strconv.ParseInt(dateTo, 10, 64); err == nil {
criteria.DateTo = &dt
}
}
// Validate date range
if criteria.DateFrom != nil && criteria.DateTo != nil && *criteria.DateFrom >= *criteria.DateTo {
return response.ValidationError(c, "Invalid date range", "date_from must be before date_to")
}
// Get enhanced feedback list with related data
feedbackList, err := h.service.ListFeedback(c.Request().Context(), criteria, limit, offset)
if err != nil {
return response.InternalServerError(c, "Failed to retrieve feedback list")
}
return response.Success(c, feedbackList.Feedback, "feedback list retrieved successfully")
}
// GetFeedbackByID retrieves feedback by ID with loaded related data
// @Summary Get feedback by ID
// @Description Retrieve feedback details by its unique identifier with loaded tender, customer, and company information. This endpoint provides comprehensive feedback data including related entity details.
// @Tags Admin-Feedback
// @Accept json
// @Produce json
// @Param id path string true "Feedback ID (MongoDB ObjectID format)" minlength(24) maxlength(24)
// @Success 200 {object} response.APIResponse{data=FeedbackResponse} "feedback retrieved successfully"
// @Failure 400 {object} response.APIResponse{error=string,message=string} "Bad request - Invalid feedback ID format"
// @Failure 404 {object} response.APIResponse{error=string,message=string} "Feedback not found"
// @Failure 500 {object} response.APIResponse{error=string,message=string} "Internal server error"
// @Security BearerAuth
// @Router /admin/v1/feedback/{id} [get]
func (h *Handler) GetFeedback(c echo.Context) error {
feedbackID := c.Param("id")
if feedbackID == "" {
return response.BadRequest(c, "Feedback ID is required", "Feedback ID parameter is missing")
}
feedback, err := h.service.GetFeedback(c.Request().Context(), feedbackID)
if err != nil {
if err == ErrFeedbackNotFound {
return response.NotFound(c, "Feedback not found")
}
if err == ErrFeedbackDeleted {
return response.NotFound(c, "Feedback has been deleted")
}
return response.InternalServerError(c, "Failed to retrieve feedback")
}
return response.Success(c, feedback, "feedback retrieved successfully")
}
// SoftDeleteFeedback marks feedback as deleted without removing it
// @Summary Soft delete feedback
// @Description Mark feedback as deleted without permanently removing it from the database. The feedback will be marked as deleted and hidden from normal queries but can be restored if needed.
// @Tags Admin-Feedback
// @Accept json
// @Produce json
// @Param id path string true "Feedback ID (MongoDB ObjectID format)" minlength(24) maxlength(24)
// @Success 200 {object} response.APIResponse{message=string} "Feedback soft deleted successfully"
// @Failure 400 {object} response.APIResponse{error=string,message=string} "Bad request - Invalid feedback ID format"
// @Failure 401 {object} response.APIResponse{error=string,message=string} "Unauthorized - User not authenticated"
// @Failure 403 {object} response.APIResponse{error=string,message=string} "Forbidden - User not authorized to delete this feedback"
// @Failure 404 {object} response.APIResponse{error=string,message=string} "Not found - Feedback not found"
// @Failure 500 {object} response.APIResponse{error=string,message=string} "Internal server error"
// @Security BearerAuth
// @Router /admin/v1/feedback/{id} [delete]
func (h *Handler) DeleteFeedback(c echo.Context) error {
id := c.Param("id")
if id == "" {
return response.BadRequest(c, "Feedback ID is required", "")
}
// Call service
err := h.service.DeleteFeedback(c.Request().Context(), id)
if err != nil {
switch err {
case ErrFeedbackNotFound:
return response.BadRequest(c, "Feedback not found", "")
case ErrUnauthorizedAccess:
return response.Forbidden(c, "You are not authorized to delete this feedback")
default:
return response.InternalServerError(c, "Failed to soft delete feedback")
}
}
return response.Success(c, nil, "Feedback soft deleted successfully")
}
// *************** PUBLIC ***************
// ToggleFeedback toggles between like and dislike for a tender
// @Summary Toggle feedback for a tender
// @Description Toggle between like and dislike for a tender, or create a like if no feedback exists. This endpoint allows users to express their preference for tenders and automatically handles the toggle logic.
// @Tags Feedback
// @Accept json
// @Produce json
// @Param feedback body ToggleFeedbackForm true "Feedback toggle request containing tender ID"
// @Success 200 {object} response.APIResponse{data=FeedbackResponse} "Feedback toggled successfully"
// @Failure 400 {object} response.APIResponse{error=string,message=string} "Bad request - Invalid input data or missing customer/company association"
// @Failure 401 {object} response.APIResponse{error=string,message=string} "Unauthorized - User not authenticated"
// @Failure 422 {object} response.APIResponse{error=string,message=string} "Validation error - Invalid request data format"
// @Failure 500 {object} response.APIResponse{error=string,message=string} "Internal server error"
// @Security BearerAuth
// @Router /api/v1/feedback [post]
func (h *Handler) ToggleFeedback(c echo.Context) error {
form, err := response.Parse[ToggleFeedbackForm](c)
if err != nil {
return response.ValidationError(c, "Invalid request data", err.Error())
}
// Validate form using govalidator
if _, err := govalidator.ValidateStruct(form); err != nil {
return response.ValidationError(c, "Validation failed", err.Error())
}
// Get customer ID from context
customerID, err := customer.GetCustomerIDFromContext(c)
if err != nil {
return response.BadRequest(c, "Customer ID required", "User must be associated with a customer")
}
// Get company ID from context
companyID, err := user.GetCompanyIDFromContext(c)
if err != nil {
return response.BadRequest(c, "Company ID required", "User must be associated with a company")
}
// Call service
result, err := h.service.ToggleFeedback(c.Request().Context(), *form, &customerID, &companyID)
if err != nil {
return response.InternalServerError(c, "Failed to toggle feedback")
}
return response.Success(c, result, "Feedback toggled successfully")
}
// 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.
// @Tags Feedback
// @Accept json
// @Produce json
// @Param tender_id query string false "Filter by tender ID (MongoDB ObjectID format)"
// @Param customer_id query string false "Filter by customer ID (MongoDB ObjectID format)"
// @Param company_id query string false "Filter by company ID (MongoDB ObjectID format)"
// @Param feedback_type query string false "Filter by feedback type" Enums(like, dislike)
// @Param date_from query int64 false "Filter by creation date from (Unix timestamp in seconds)" minimum(0)
// @Param date_to query int64 false "Filter by creation date to (Unix timestamp in seconds)" minimum(0)
// @Param limit query int false "Number of items per page (default: 20, max: 100)" minimum(1) maximum(100)
// @Param offset query int false "Number of items to skip for pagination (default: 0)" minimum(0)
// @Success 200 {object} response.APIResponse{data=FeedbackListResponse} "Feedback list retrieved successfully"
// @Failure 400 {object} response.APIResponse{error=string,message=string} "Bad request - Invalid query parameters or missing company association"
// @Failure 422 {object} response.APIResponse{error=string,message=string} "Validation error - Invalid filter criteria"
// @Failure 500 {object} response.APIResponse{error=string,message=string} "Internal server error"
// @Security BearerAuth
// @Router /api/v1/feedback [get]
func (h *Handler) PublicListFeedback(c echo.Context) error {
// Parse query parameters
limitStr := c.QueryParam("limit")
offsetStr := c.QueryParam("offset")
limit := 20
offset := 0
if l, err := strconv.Atoi(limitStr); err == nil && l > 0 && l <= 100 {
limit = l
}
if o, err := strconv.Atoi(offsetStr); err == nil && o >= 0 {
offset = o
}
companyID, err := user.GetCompanyIDFromContext(c)
if err != nil {
return response.BadRequest(c, "Company ID required", "User must be associated with a company")
}
// Build search criteria
criteria := FeedbackSearchCriteria{
Limit: limit,
Offset: offset,
CompanyID: &companyID,
}
if feedbackType := c.QueryParam("feedback_type"); feedbackType != "" {
ft := FeedbackType(feedbackType)
if ft != FeedbackTypeLike && ft != FeedbackTypeDislike {
return response.BadRequest(c, "Invalid feedback type", "Must be 'like' or 'dislike'")
}
criteria.FeedbackType = &ft
}
if dateFrom := c.QueryParam("date_from"); dateFrom != "" {
if df, err := strconv.ParseInt(dateFrom, 10, 64); err == nil && df >= 0 {
criteria.DateFrom = &df
}
}
if dateTo := c.QueryParam("date_to"); dateTo != "" {
if dt, err := strconv.ParseInt(dateTo, 10, 64); err == nil && dt >= 0 {
criteria.DateTo = &dt
}
}
// Validate date range
if criteria.DateFrom != nil && criteria.DateTo != nil && *criteria.DateFrom >= *criteria.DateTo {
return response.ValidationError(c, "Invalid date range", "date_from must be before date_to")
}
// Call service
result, err := h.service.ListFeedback(c.Request().Context(), criteria, limit, offset)
if err != nil {
return response.InternalServerError(c, "Failed to list feedback")
}
return response.Success(c, result, "Feedback list retrieved successfully")
}
// PublicGetFeedback retrieves feedback by ID
// @Summary Get feedback by ID
// @Description Retrieve feedback details by its unique identifier. This endpoint provides basic feedback information for public users.
// @Tags Feedback
// @Accept json
// @Produce json
// @Param id path string true "Feedback ID (MongoDB ObjectID format)" minlength(24) maxlength(24)
// @Success 200 {object} response.APIResponse{data=FeedbackResponse} "Feedback retrieved successfully"
// @Failure 400 {object} response.APIResponse{error=string,message=string} "Bad request - Invalid feedback ID format"
// @Failure 404 {object} response.APIResponse{error=string,message=string} "Feedback not found"
// @Failure 500 {object} response.APIResponse{error=string,message=string} "Internal server error"
// @Security BearerAuth
// @Router /api/v1/feedback/{id} [get]
func (h *Handler) PublicGetFeedback(c echo.Context) error {
feedbackID := c.Param("id")
if feedbackID == "" {
return response.BadRequest(c, "Feedback ID is required", "Feedback ID parameter is missing")
}
feedback, err := h.service.GetFeedback(c.Request().Context(), feedbackID)
if err != nil {
return response.BadRequest(c, "Failed to get feedback", "")
}
return response.Success(c, feedback, "Feedback retrieved successfully")
}
+432
View File
@@ -0,0 +1,432 @@
package feedback
import (
"context"
"time"
"tm/pkg/logger"
"tm/pkg/mongo"
"go.mongodb.org/mongo-driver/bson"
driver "go.mongodb.org/mongo-driver/mongo"
)
// FeedbackRepository interface defines feedback data access methods
type Repository interface {
// Basic CRUD operations
Create(ctx context.Context, feedback *Feedback) error
Update(ctx context.Context, feedback *Feedback) error
GetByID(ctx context.Context, id string) (*Feedback, error)
GetByCompanyID(ctx context.Context, companyID string, tender string) (*Feedback, error)
Delete(ctx context.Context, id string) error
List(ctx context.Context, criteria FeedbackSearchCriteria, limit, offset int) ([]*Feedback, int64, error)
Search(ctx context.Context, criteria FeedbackSearchCriteria) ([]*Feedback, error)
GetFeedbackSummary(ctx context.Context, tenderID string) (*FeedbackSummary, error)
GetFeedbackCountByType(ctx context.Context) (map[FeedbackType]int64, error)
}
// feedbackRepository implements FeedbackRepository interface using MongoDB ORM
type feedbackRepository struct {
repo mongo.Repository[Feedback]
logger logger.Logger
}
// NewFeedbackRepository creates a new feedback repository
func NewFeedbackRepository(mongoManager *mongo.ConnectionManager, logger logger.Logger) Repository {
// Create indexes for feedback collection
feedbackIndexes := []mongo.Index{
*mongo.NewIndex("tender_id_idx", bson.D{{Key: "tender_id", Value: 1}}),
*mongo.NewIndex("customer_id_idx", bson.D{{Key: "customer_id", Value: 1}}),
*mongo.NewIndex("company_id_idx", bson.D{{Key: "company_id", Value: 1}}),
*mongo.NewIndex("feedback_type_idx", bson.D{{Key: "feedback_type", Value: 1}}),
// Compound indexes for common queries
*mongo.NewIndex("tender_customer_idx", bson.D{
{Key: "tender_id", Value: 1},
{Key: "customer_id", Value: 1},
}),
*mongo.NewIndex("tender_type_idx", bson.D{
{Key: "tender_id", Value: 1},
{Key: "feedback_type", Value: 1},
}),
}
// Create indexes
if err := mongoManager.CreateIndexes("feedback", feedbackIndexes); err != nil {
logger.Warn("Failed to create feedback indexes", map[string]interface{}{
"error": err.Error(),
})
}
// Create ORM repository
repo := mongo.NewRepository[Feedback](mongoManager.GetCollection("feedback"), logger)
return &feedbackRepository{
repo: repo,
logger: logger,
}
}
// Create creates a new feedback
func (r *feedbackRepository) Create(ctx context.Context, feedback *Feedback) error {
// Set timestamps
now := time.Now().Unix()
feedback.CreatedAt = now
feedback.UpdatedAt = now
err := r.repo.Create(ctx, feedback)
if err != nil {
r.logger.Error("Failed to create feedback", map[string]interface{}{
"error": err.Error(),
"tender_id": feedback.TenderID,
})
return err
}
r.logger.Info("Feedback created successfully", map[string]interface{}{
"feedback_id": feedback.GetID(),
"tender_id": feedback.TenderID,
"type": feedback.FeedbackType,
})
return nil
}
// Update updates an existing feedback
func (r *feedbackRepository) Update(ctx context.Context, feedback *Feedback) error {
feedback.UpdatedAt = time.Now().Unix()
err := r.repo.Update(ctx, feedback)
if err != nil {
r.logger.Error("Failed to update feedback", map[string]interface{}{
"feedback_id": feedback.GetID(),
"error": err.Error(),
})
return err
}
r.logger.Info("Feedback updated successfully", map[string]interface{}{
"feedback_id": feedback.GetID(),
"tender_id": feedback.TenderID,
})
return nil
}
// GetByID retrieves feedback by ID
func (r *feedbackRepository) GetByID(ctx context.Context, id string) (*Feedback, error) {
feedback, err := r.repo.FindByID(ctx, id)
if err != nil {
r.logger.Error("Failed to get feedback by ID", map[string]interface{}{
"feedback_id": id,
"error": err.Error(),
})
return nil, err
}
return feedback, nil
}
// GetByCompanyID retrieves feedback by company ID and tender ID
func (r *feedbackRepository) GetByCompanyID(ctx context.Context, companyID string, tenderID string) (*Feedback, error) {
filter := bson.M{"company_id": companyID, "tender_id": tenderID}
result, err := r.repo.FindOne(ctx, filter)
if err != nil {
r.logger.Error("Failed to get feedback by company ID and tender ID", map[string]interface{}{
"company_id": companyID,
"tender_id": tenderID,
"error": err.Error(),
})
return nil, err
}
return result, nil
}
// Delete permanently deletes feedback by ID
func (r *feedbackRepository) Delete(ctx context.Context, id string) error {
err := r.repo.Delete(ctx, id)
if err != nil {
r.logger.Error("Failed to delete feedback", map[string]interface{}{
"feedback_id": id,
"error": err.Error(),
})
return err
}
r.logger.Info("Feedback deleted successfully", map[string]interface{}{
"feedback_id": id,
})
return nil
}
// List retrieves feedback with pagination and filtering
func (r *feedbackRepository) List(ctx context.Context, criteria FeedbackSearchCriteria, limit, offset int) ([]*Feedback, int64, error) {
filter := r.buildSearchFilter(criteria)
pagination := mongo.Pagination{
Limit: limit,
Skip: offset,
SortField: "created_at",
SortOrder: -1,
}
result, err := r.repo.FindAll(ctx, filter, pagination)
if err != nil {
r.logger.Error("Failed to list feedback", map[string]interface{}{
"error": err.Error(),
})
return nil, 0, err
}
// Convert []TenderFeedback to []*TenderFeedback
feedback := make([]*Feedback, len(result.Items))
for i := range result.Items {
feedback[i] = &result.Items[i]
}
return feedback, result.TotalCount, nil
}
// GetByFeedbackType retrieves feedback by type
func (r *feedbackRepository) GetByFeedbackType(ctx context.Context, feedbackType FeedbackType, limit, offset int) ([]*Feedback, int64, error) {
filter := bson.M{"feedback_type": feedbackType}
pagination := mongo.Pagination{
Limit: limit,
Skip: offset,
SortField: "created_at",
SortOrder: -1,
}
result, err := r.repo.FindAll(ctx, filter, pagination)
if err != nil {
r.logger.Error("Failed to get feedback by type", map[string]interface{}{
"feedback_type": feedbackType,
"error": err.Error(),
})
return nil, 0, err
}
// Convert []TenderFeedback to []*TenderFeedback
feedback := make([]*Feedback, len(result.Items))
for i := range result.Items {
feedback[i] = &result.Items[i]
}
return feedback, result.TotalCount, nil
}
// Search searches feedback based on criteria
func (r *feedbackRepository) Search(ctx context.Context, criteria FeedbackSearchCriteria) ([]*Feedback, error) {
filter := r.buildSearchFilter(criteria)
pagination := mongo.Pagination{
Limit: 100,
SortField: "created_at",
SortOrder: -1,
}
result, err := r.repo.FindAll(ctx, filter, pagination)
if err != nil {
r.logger.Error("Failed to search feedback", map[string]interface{}{
"error": err.Error(),
})
return nil, err
}
// Convert []TenderFeedback to []*TenderFeedback
feedback := make([]*Feedback, len(result.Items))
for i := range result.Items {
feedback[i] = &result.Items[i]
}
return feedback, nil
}
// GetFeedbackSummary calculates feedback summary for a tender
func (r *feedbackRepository) GetFeedbackSummary(ctx context.Context, tenderID string) (*FeedbackSummary, error) {
pipeline := driver.Pipeline{
{
{Key: "$match", Value: bson.M{
"tender_id": tenderID,
}},
},
{
{Key: "$group", Value: bson.M{
"_id": nil,
"total_likes": bson.M{
"$sum": bson.M{
"$cond": []interface{}{
bson.M{"$eq": []string{"$feedback_type", string(FeedbackTypeLike)}},
1,
0,
},
},
},
"total_dislikes": bson.M{
"$sum": bson.M{
"$cond": []interface{}{
bson.M{"$eq": []string{"$feedback_type", string(FeedbackTypeDislike)}},
1,
0,
},
},
},
"total_comments": bson.M{
"$sum": bson.M{
"$cond": []interface{}{
bson.M{"$ne": []interface{}{"$comment", nil}},
1,
0,
},
},
},
"total_ratings": bson.M{
"$sum": bson.M{
"$cond": []interface{}{
bson.M{"$gt": []interface{}{"$rating", 0}},
1,
0,
},
},
},
"rating_sum": bson.M{
"$sum": bson.M{
"$cond": []interface{}{
bson.M{"$gt": []interface{}{"$rating", 0}},
"$rating",
0,
},
},
},
"last_updated": bson.M{"$max": "$updated_at"},
}},
},
}
results, err := r.repo.Aggregate(ctx, pipeline)
if err != nil {
r.logger.Error("Failed to get feedback summary", map[string]interface{}{
"tender_id": tenderID,
"error": err.Error(),
})
return nil, err
}
summary := &FeedbackSummary{
TenderID: tenderID,
TotalLikes: 0,
TotalDislikes: 0,
LastUpdated: time.Now().Unix(),
}
if len(results) > 0 {
result := results[0]
if totalLikes, ok := result["total_likes"].(int32); ok {
summary.TotalLikes = int64(totalLikes)
}
if totalDislikes, ok := result["total_dislikes"].(int32); ok {
summary.TotalDislikes = int64(totalDislikes)
}
if lastUpdated, ok := result["last_updated"].(int64); ok {
summary.LastUpdated = lastUpdated
}
}
return summary, nil
}
// GetFeedbackCountByType gets feedback count grouped by type
func (r *feedbackRepository) GetFeedbackCountByType(ctx context.Context) (map[FeedbackType]int64, error) {
pipeline := driver.Pipeline{
{
{Key: "$group", Value: bson.M{
"_id": "$feedback_type",
"count": bson.M{"$sum": 1},
}},
},
}
results, err := r.repo.Aggregate(ctx, pipeline)
if err != nil {
return nil, err
}
counts := make(map[FeedbackType]int64)
for _, result := range results {
if feedbackType, ok := result["_id"].(string); ok {
if count, ok := result["count"].(int32); ok {
counts[FeedbackType(feedbackType)] = int64(count)
}
}
}
return counts, nil
}
// GetCustomerFeedbackHistory gets the feedback history for a specific customer
func (r *feedbackRepository) GetCustomerFeedbackHistory(ctx context.Context, customerID string, limit, offset int) ([]*Feedback, int64, error) {
filter := bson.M{
"customer_id": customerID,
}
pagination := mongo.Pagination{
Limit: limit,
Skip: offset,
SortField: "created_at",
SortOrder: -1,
}
result, err := r.repo.FindAll(ctx, filter, pagination)
if err != nil {
r.logger.Error("Failed to get customer feedback history", map[string]interface{}{
"customer_id": customerID,
"error": err.Error(),
})
return nil, 0, err
}
// Convert []TenderFeedback to []*TenderFeedback
feedback := make([]*Feedback, len(result.Items))
for i := range result.Items {
feedback[i] = &result.Items[i]
}
return feedback, result.TotalCount, nil
}
// buildSearchFilter builds MongoDB filter from search criteria
func (r *feedbackRepository) buildSearchFilter(criteria FeedbackSearchCriteria) bson.M {
filter := bson.M{}
// Always exclude deleted feedback unless specifically requested
if criteria.TenderID != nil {
filter["tender_id"] = *criteria.TenderID
}
if criteria.CustomerID != nil {
filter["customer_id"] = *criteria.CustomerID
}
if criteria.CompanyID != nil {
filter["company_id"] = *criteria.CompanyID
}
if criteria.FeedbackType != nil {
filter["feedback_type"] = *criteria.FeedbackType
}
if criteria.DateFrom != nil || criteria.DateTo != nil {
dateFilter := bson.M{}
if criteria.DateFrom != nil {
dateFilter["$gte"] = *criteria.DateFrom
}
if criteria.DateTo != nil {
dateFilter["$lte"] = *criteria.DateTo
}
filter["created_at"] = dateFilter
}
return filter
}
+120
View File
@@ -0,0 +1,120 @@
package feedback
import (
"context"
"tm/pkg/logger"
"tm/pkg/response"
)
// FeedbackService interface defines feedback business logic methods
type FeedbackService interface {
ToggleFeedback(ctx context.Context, req ToggleFeedbackForm, customerID *string, companyID *string) (*FeedbackResponse, error)
GetFeedback(ctx context.Context, id string) (*FeedbackResponse, error)
ListFeedback(ctx context.Context, criteria FeedbackSearchCriteria, limit, offset int) (*FeedbackListResponse, error)
DeleteFeedback(ctx context.Context, id string) error
}
// feedbackService implements FeedbackService interface
type feedbackService struct {
feedbackRepo Repository
logger logger.Logger
}
// NewFeedbackService creates a new feedback service
func NewFeedbackService(feedbackRepo Repository, logger logger.Logger) FeedbackService {
return &feedbackService{
feedbackRepo: feedbackRepo,
logger: logger,
}
}
func (s *feedbackService) ToggleFeedback(ctx context.Context, req ToggleFeedbackForm, customerID *string, companyID *string) (*FeedbackResponse, error) {
existing, err := s.feedbackRepo.GetByCompanyID(ctx, *companyID, req.TenderID)
if err != nil {
feedback := &Feedback{
TenderID: req.TenderID,
FeedbackType: req.FeedbackType,
CompanyID: companyID,
CustomerID: customerID,
}
err = s.feedbackRepo.Create(ctx, feedback)
if err != nil {
s.logger.Error("Failed to create new feedback", map[string]interface{}{
"tender_id": req.TenderID,
"company_id": *companyID,
"error": err.Error(),
})
return nil, err
}
return feedback.ToResponse(), nil
}
if existing.FeedbackType == FeedbackTypeLike {
existing.FeedbackType = FeedbackTypeDislike
} else {
existing.FeedbackType = FeedbackTypeLike
}
err = s.feedbackRepo.Update(ctx, existing)
if err != nil {
s.logger.Error("Failed to update existing feedback", map[string]interface{}{
"feedback_id": existing.GetID(),
"tender_id": req.TenderID,
"company_id": *companyID,
"error": err.Error(),
})
return nil, err
}
return existing.ToResponse(), nil
}
func (s *feedbackService) GetFeedback(ctx context.Context, id string) (*FeedbackResponse, error) {
feedback, err := s.feedbackRepo.GetByID(ctx, id)
if err != nil {
s.logger.Error("Failed to get feedback", map[string]interface{}{
"id": id,
"error": err.Error(),
})
return nil, err
}
return feedback.ToResponse(), nil
}
func (s *feedbackService) ListFeedback(ctx context.Context, criteria FeedbackSearchCriteria, limit, offset int) (*FeedbackListResponse, error) {
feedbacks, total, err := s.feedbackRepo.List(ctx, criteria, limit, offset)
if err != nil {
s.logger.Error("Failed to list feedback", map[string]interface{}{
"criteria": criteria,
"limit": limit,
"offset": offset,
"error": err.Error(),
})
return nil, err
}
meta := &response.Meta{
Total: int(total),
Limit: limit,
Offset: offset,
Page: (offset / limit) + 1,
Pages: int((total + int64(limit) - 1) / int64(limit)),
}
result := make([]*FeedbackResponse, len(feedbacks))
for i, feedback := range feedbacks {
result[i] = feedback.ToResponse()
}
return &FeedbackListResponse{
Feedback: result,
Meta: meta,
}, nil
}
func (s *feedbackService) DeleteFeedback(ctx context.Context, id string) error {
return s.feedbackRepo.Delete(ctx, id)
}
+3 -1
View File
@@ -421,8 +421,8 @@ func (h *TenderHandler) GetScrapingJob(c echo.Context) error {
// @Success 200 {object} response.APIResponse{data=map[string]interface{}}
// @Failure 400 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Router /admin/scraping/jobs [get]
// @Security BearerAuth
// @Router /admin/scraping/jobs [get]
func (h *TenderHandler) ListScrapingJobs(c echo.Context) error {
// Parse pagination parameters
limit := 20
@@ -506,6 +506,7 @@ func (h *TenderHandler) CancelScrapingJob(c echo.Context) error {
// @Success 200 {object} response.APIResponse{data=map[string]interface{}}
// @Failure 400 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Security BearerAuth
// @Router /api/v1/tenders [get]
func (h *TenderHandler) GetPublicTenders(c echo.Context) error {
// Parse pagination parameters (more restrictive for public API)
@@ -611,6 +612,7 @@ func (h *TenderHandler) GetPublicTenders(c echo.Context) error {
// @Success 200 {object} response.APIResponse{data=map[string]interface{}}
// @Failure 404 {object} response.APIResponse
// @Failure 500 {object} response.APIResponse
// @Security BearerAuth
// @Router /api/v1/tenders/{id} [get]
func (h *TenderHandler) GetPublicTenderDetails(c echo.Context) error {
id := c.Param("id")