diff --git a/internal/app/app.go b/internal/app/app.go index 5722fa7..68a41d4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -42,7 +42,7 @@ func (a *App) Initialize(cfg *config.Config) error { processors := a.initProcessors(cfg, repos) services := a.initServices(processors, repos, cfg) validators := a.initValidators() - middleware := a.initMiddleware(services) + middleware := a.initMiddleware(services, cfg) healthHandler := handler.NewHealthHandler() a.router = router.NewRouter( @@ -102,6 +102,8 @@ func (a *App) Initialize(cfg *config.Config) error { validators.campaignValidator, services.customerAuthService, validators.customerAuthValidator, + services.customerPointsService, + middleware.customerAuthMiddleware, ) return nil @@ -185,6 +187,7 @@ type repositories struct { rewardRepo repository.RewardRepository campaignRepo repository.CampaignRepository customerAuthRepo repository.CustomerAuthRepository + customerPointsRepo repository.CustomerPointsRepository otpRepo repository.OtpRepository txManager *repository.TxManager } @@ -229,6 +232,7 @@ func (a *App) initRepositories() *repositories { rewardRepo: repository.NewRewardRepository(a.db), campaignRepo: repository.NewCampaignRepository(a.db), customerAuthRepo: repository.NewCustomerAuthRepository(a.db), + customerPointsRepo: repository.NewCustomerPointsRepository(a.db), otpRepo: repository.NewOtpRepository(a.db), txManager: repository.NewTxManager(a.db), } @@ -269,6 +273,7 @@ type processors struct { rewardProcessor processor.RewardProcessor campaignProcessor processor.CampaignProcessor customerAuthProcessor processor.CustomerAuthProcessor + customerPointsProcessor *processor.CustomerPointsProcessor otpProcessor processor.OtpProcessor fileClient processor.FileClient inventoryMovementService service.InventoryMovementService @@ -315,6 +320,7 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor rewardProcessor: processor.NewRewardProcessor(repos.rewardRepo), campaignProcessor: processor.NewCampaignProcessor(repos.campaignRepo), customerAuthProcessor: processor.NewCustomerAuthProcessor(repos.customerAuthRepo, otpProcessor, repos.otpRepo, cfg.GetCustomerJWTSecret(), cfg.GetCustomerJWTExpiresTTL()), + customerPointsProcessor: processor.NewCustomerPointsProcessor(repos.customerPointsRepo), otpProcessor: otpProcessor, fileClient: fileClient, inventoryMovementService: inventoryMovementService, @@ -352,6 +358,7 @@ type services struct { rewardService service.RewardService campaignService service.CampaignService customerAuthService service.CustomerAuthService + customerPointsService service.CustomerPointsService } func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { @@ -386,6 +393,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con rewardService := service.NewRewardService(processors.rewardProcessor) campaignService := service.NewCampaignService(processors.campaignProcessor) customerAuthService := service.NewCustomerAuthService(processors.customerAuthProcessor) + customerPointsService := service.NewCustomerPointsService(processors.customerPointsProcessor) // Update order service with order ingredient transaction service orderService = service.NewOrderServiceImpl(processors.orderProcessor, repos.tableRepo, orderIngredientTransactionService, processors.orderIngredientTransactionProcessor, *repos.productRecipeRepo, repos.txManager) @@ -421,16 +429,19 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con rewardService: rewardService, campaignService: campaignService, customerAuthService: customerAuthService, + customerPointsService: customerPointsService, } } type middlewares struct { - authMiddleware *middleware.AuthMiddleware + authMiddleware *middleware.AuthMiddleware + customerAuthMiddleware *middleware.CustomerAuthMiddleware } -func (a *App) initMiddleware(services *services) *middlewares { +func (a *App) initMiddleware(services *services, cfg *config.Config) *middlewares { return &middlewares{ - authMiddleware: middleware.NewAuthMiddleware(services.authService), + authMiddleware: middleware.NewAuthMiddleware(services.authService), + customerAuthMiddleware: middleware.NewCustomerAuthMiddleware(cfg.GetCustomerJWTSecret()), } } diff --git a/internal/contract/customer_points_contract.go b/internal/contract/customer_points_contract.go index 04bdded..d7cd65a 100644 --- a/internal/contract/customer_points_contract.go +++ b/internal/contract/customer_points_contract.go @@ -6,6 +6,7 @@ import ( "github.com/google/uuid" ) +// Existing gamification contracts type CreateCustomerPointsRequest struct { CustomerID uuid.UUID `json:"customer_id" validate:"required"` Balance int64 `json:"balance" validate:"min=0"` @@ -47,3 +48,71 @@ type PaginatedCustomerPointsResponse struct { Limit int `json:"limit"` TotalPages int `json:"total_pages"` } + +// New customer API contracts +type GetCustomerPointsRequest struct { + // No additional fields needed - customer ID comes from JWT token +} + +type GetCustomerTokensRequest struct { + // No additional fields needed - customer ID comes from JWT token +} + +type GetCustomerWalletRequest struct { + // No additional fields needed - customer ID comes from JWT token +} + +// Response Contracts +type GetCustomerPointsResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data *GetCustomerPointsResponseData `json:"data,omitempty"` +} + +type GetCustomerPointsResponseData struct { + TotalPoints int64 `json:"total_points"` + PointsHistory []PointsHistoryItem `json:"points_history,omitempty"` + LastUpdated time.Time `json:"last_updated"` +} + +type PointsHistoryItem struct { + ID string `json:"id"` + Points int64 `json:"points"` + Type string `json:"type"` // EARNED, REDEEMED, EXPIRED + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` +} + +type GetCustomerTokensResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data *GetCustomerTokensResponseData `json:"data,omitempty"` +} + +type GetCustomerTokensResponseData struct { + TotalTokens int64 `json:"total_tokens"` + TokensHistory []TokensHistoryItem `json:"tokens_history,omitempty"` + LastUpdated time.Time `json:"last_updated"` +} + +type TokensHistoryItem struct { + ID string `json:"id"` + Tokens int64 `json:"tokens"` + Type string `json:"type"` // EARNED, REDEEMED, EXPIRED + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` +} + +type GetCustomerWalletResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data *GetCustomerWalletResponseData `json:"data,omitempty"` +} + +type GetCustomerWalletResponseData struct { + TotalPoints int64 `json:"total_points"` + TotalTokens int64 `json:"total_tokens"` + PointsHistory []PointsHistoryItem `json:"points_history,omitempty"` + TokensHistory []TokensHistoryItem `json:"tokens_history,omitempty"` + LastUpdated time.Time `json:"last_updated"` +} diff --git a/internal/handler/customer_points_handler.go b/internal/handler/customer_points_handler.go new file mode 100644 index 0000000..03b084f --- /dev/null +++ b/internal/handler/customer_points_handler.go @@ -0,0 +1,123 @@ +package handler + +import ( + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/logger" + "apskel-pos-be/internal/service" + "apskel-pos-be/internal/util" + + "github.com/gin-gonic/gin" +) + +type CustomerPointsHandler struct { + customerPointsService service.CustomerPointsService +} + +func NewCustomerPointsHandler(customerPointsService service.CustomerPointsService) *CustomerPointsHandler { + return &CustomerPointsHandler{ + customerPointsService: customerPointsService, + } +} + +func (h *CustomerPointsHandler) GetCustomerPoints(c *gin.Context) { + ctx := c.Request.Context() + + // Get customer ID from context (set by middleware) + customerID, exists := c.Get("customer_id") + if !exists { + logger.FromContext(ctx).Error("Customer ID not found in context") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Customer ID not found"), + }), "CustomerPointsHandler::GetCustomerPoints") + return + } + + customerIDStr, ok := customerID.(string) + if !ok { + logger.FromContext(ctx).Error("Invalid customer ID type in context") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Invalid customer ID"), + }), "CustomerPointsHandler::GetCustomerPoints") + return + } + + response, err := h.customerPointsService.GetCustomerPoints(ctx, customerIDStr) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerPoints -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()), + }), "CustomerPointsHandler::GetCustomerPoints") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerPoints") +} + +func (h *CustomerPointsHandler) GetCustomerTokens(c *gin.Context) { + ctx := c.Request.Context() + + // Get customer ID from context (set by middleware) + customerID, exists := c.Get("customer_id") + if !exists { + logger.FromContext(ctx).Error("Customer ID not found in context") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Customer ID not found"), + }), "CustomerPointsHandler::GetCustomerTokens") + return + } + + customerIDStr, ok := customerID.(string) + if !ok { + logger.FromContext(ctx).Error("Invalid customer ID type in context") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Invalid customer ID"), + }), "CustomerPointsHandler::GetCustomerTokens") + return + } + + response, err := h.customerPointsService.GetCustomerTokens(ctx, customerIDStr) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerTokens -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()), + }), "CustomerPointsHandler::GetCustomerTokens") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerTokens") +} + +func (h *CustomerPointsHandler) GetCustomerWallet(c *gin.Context) { + ctx := c.Request.Context() + + // Get customer ID from context (set by middleware) + customerID, exists := c.Get("customer_id") + if !exists { + logger.FromContext(ctx).Error("Customer ID not found in context") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Customer ID not found"), + }), "CustomerPointsHandler::GetCustomerWallet") + return + } + + customerIDStr, ok := customerID.(string) + if !ok { + logger.FromContext(ctx).Error("Invalid customer ID type in context") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Invalid customer ID"), + }), "CustomerPointsHandler::GetCustomerWallet") + return + } + + response, err := h.customerPointsService.GetCustomerWallet(ctx, customerIDStr) + if err != nil { + logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerWallet -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()), + }), "CustomerPointsHandler::GetCustomerWallet") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerWallet") +} diff --git a/internal/mappers/campaign_mapper.go b/internal/mappers/campaign_mapper.go index 6c5da7f..7978464 100644 --- a/internal/mappers/campaign_mapper.go +++ b/internal/mappers/campaign_mapper.go @@ -129,6 +129,7 @@ func ToCampaignRuleEntity(request *contract.CampaignRuleStruct, campaignID uuid. } return &entities.CampaignRule{ + ID: uuid.New(), // Generate unique UUID for each campaign rule CampaignID: campaignID, RuleType: entities.RuleType(request.RuleType), ConditionValue: request.ConditionValue, @@ -152,6 +153,7 @@ func ToCampaignRuleEntityFromUpdate(request *contract.CampaignRuleStruct, campai } return &entities.CampaignRule{ + ID: uuid.New(), // Generate new UUID for each campaign rule CampaignID: campaignID, RuleType: entities.RuleType(request.RuleType), ConditionValue: request.ConditionValue, diff --git a/internal/middleware/customer_auth_middleware.go b/internal/middleware/customer_auth_middleware.go new file mode 100644 index 0000000..5a2248b --- /dev/null +++ b/internal/middleware/customer_auth_middleware.go @@ -0,0 +1,129 @@ +package middleware + +import ( + "strings" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/util" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" +) + +type CustomerAuthMiddleware struct { + customerJWTSecret string +} + +func NewCustomerAuthMiddleware(customerJWTSecret string) *CustomerAuthMiddleware { + return &CustomerAuthMiddleware{ + customerJWTSecret: customerJWTSecret, + } +} + +func (m *CustomerAuthMiddleware) ValidateCustomerToken() gin.HandlerFunc { + return func(c *gin.Context) { + // Get Authorization header + authHeader := c.GetHeader("Authorization") + if authHeader == "" { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Authorization header is required"), + }), "CustomerAuthMiddleware::ValidateCustomerToken") + c.Abort() + return + } + + // Check if header starts with "Bearer " + if !strings.HasPrefix(authHeader, "Bearer ") { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Invalid authorization header format"), + }), "CustomerAuthMiddleware::ValidateCustomerToken") + c.Abort() + return + } + + // Extract token + tokenString := strings.TrimPrefix(authHeader, "Bearer ") + if tokenString == "" { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Token is required"), + }), "CustomerAuthMiddleware::ValidateCustomerToken") + c.Abort() + return + } + + // Parse and validate token + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + // Validate signing method + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Invalid token signing method") + } + return []byte(m.customerJWTSecret), nil + }) + + if err != nil { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Invalid token: "+err.Error()), + }), "CustomerAuthMiddleware::ValidateCustomerToken") + c.Abort() + return + } + + // Check if token is valid + if !token.Valid { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Token is not valid"), + }), "CustomerAuthMiddleware::ValidateCustomerToken") + c.Abort() + return + } + + // Extract claims + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Invalid token claims"), + }), "CustomerAuthMiddleware::ValidateCustomerToken") + c.Abort() + return + } + + // Validate token type (should be "access") + tokenType, ok := claims["type"].(string) + if !ok || tokenType != "access" { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Invalid token type"), + }), "CustomerAuthMiddleware::ValidateCustomerToken") + c.Abort() + return + } + + // Extract customer ID + customerID, ok := claims["customer_id"].(string) + if !ok || customerID == "" { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Customer ID not found in token"), + }), "CustomerAuthMiddleware::ValidateCustomerToken") + c.Abort() + return + } + + // Extract phone number + phoneNumber, ok := claims["phone_number"].(string) + if !ok || phoneNumber == "" { + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ + contract.NewResponseError(constants.ValidationErrorCode, constants.AuthHandlerEntity, "Phone number not found in token"), + }), "CustomerAuthMiddleware::ValidateCustomerToken") + c.Abort() + return + } + + // Set customer information in context + c.Set("customer_id", customerID) + c.Set("customer_phone", phoneNumber) + c.Set("customer_name", claims["name"]) + + // Continue to next handler + c.Next() + } +} diff --git a/internal/models/customer_points.go b/internal/models/customer_points.go index 7241b68..cfe9b45 100644 --- a/internal/models/customer_points.go +++ b/internal/models/customer_points.go @@ -6,6 +6,7 @@ import ( "github.com/google/uuid" ) +// Existing gamification models type CreateCustomerPointsRequest struct { CustomerID uuid.UUID `json:"customer_id" validate:"required"` Balance int64 `json:"balance" validate:"min=0"` @@ -16,11 +17,11 @@ type UpdateCustomerPointsRequest struct { } type AddCustomerPointsRequest struct { - Points int64 `json:"points" validate:"required,min=1"` + Balance int64 `json:"balance" validate:"required,min=1"` } type DeductCustomerPointsRequest struct { - Points int64 `json:"points" validate:"required,min=1"` + Balance int64 `json:"balance" validate:"required,min=1"` } type CustomerPointsResponse struct { @@ -33,9 +34,85 @@ type CustomerPointsResponse struct { } type ListCustomerPointsQuery struct { - Page int `query:"page" validate:"min=1"` - Limit int `query:"limit" validate:"min=1,max=100"` - Search string `query:"search"` - SortBy string `query:"sort_by" validate:"omitempty,oneof=balance created_at updated_at"` - SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` + Page int `json:"page" validate:"min=1"` + Limit int `json:"limit" validate:"min=1,max=100"` + Search string `json:"search"` + SortBy string `json:"sort_by" validate:"omitempty,oneof=balance created_at updated_at"` + SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"` +} + +type PaginatedCustomerPointsResponse struct { + Data []CustomerPointsResponse `json:"data"` + TotalCount int `json:"total_count"` + Page int `json:"page"` + Limit int `json:"limit"` + TotalPages int `json:"total_pages"` +} + +// New customer API models +type GetCustomerPointsRequest struct { + // No additional fields needed - customer ID comes from JWT token +} + +type GetCustomerTokensRequest struct { + // No additional fields needed - customer ID comes from JWT token +} + +type GetCustomerWalletRequest struct { + // No additional fields needed - customer ID comes from JWT token +} + +// Response Models +type GetCustomerPointsResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data *GetCustomerPointsResponseData `json:"data,omitempty"` +} + +type GetCustomerPointsResponseData struct { + TotalPoints int64 `json:"total_points"` + PointsHistory []PointsHistoryItem `json:"points_history,omitempty"` + LastUpdated time.Time `json:"last_updated"` +} + +type PointsHistoryItem struct { + ID string `json:"id"` + Points int64 `json:"points"` + Type string `json:"type"` // EARNED, REDEEMED, EXPIRED + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` +} + +type GetCustomerTokensResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data *GetCustomerTokensResponseData `json:"data,omitempty"` +} + +type GetCustomerTokensResponseData struct { + TotalTokens int64 `json:"total_tokens"` + TokensHistory []TokensHistoryItem `json:"tokens_history,omitempty"` + LastUpdated time.Time `json:"last_updated"` +} + +type TokensHistoryItem struct { + ID string `json:"id"` + Tokens int64 `json:"tokens"` + Type string `json:"type"` // EARNED, REDEEMED, EXPIRED + Description string `json:"description"` + CreatedAt time.Time `json:"created_at"` +} + +type GetCustomerWalletResponse struct { + Status string `json:"status"` + Message string `json:"message"` + Data *GetCustomerWalletResponseData `json:"data,omitempty"` +} + +type GetCustomerWalletResponseData struct { + TotalPoints int64 `json:"total_points"` + TotalTokens int64 `json:"total_tokens"` + PointsHistory []PointsHistoryItem `json:"points_history,omitempty"` + TokensHistory []TokensHistoryItem `json:"tokens_history,omitempty"` + LastUpdated time.Time `json:"last_updated"` } diff --git a/internal/processor/customer_points_processor.go b/internal/processor/customer_points_processor.go index fc9029d..4155f05 100644 --- a/internal/processor/customer_points_processor.go +++ b/internal/processor/customer_points_processor.go @@ -1,196 +1,222 @@ package processor import ( - "apskel-pos-be/internal/mappers" + "context" + "fmt" + "time" + "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" - "context" - "errors" - "fmt" "github.com/google/uuid" ) type CustomerPointsProcessor struct { - customerPointsRepo *repository.CustomerPointsRepository + customerPointsRepo repository.CustomerPointsRepository } -func NewCustomerPointsProcessor(customerPointsRepo *repository.CustomerPointsRepository) *CustomerPointsProcessor { +func NewCustomerPointsProcessor(customerPointsRepo repository.CustomerPointsRepository) *CustomerPointsProcessor { return &CustomerPointsProcessor{ customerPointsRepo: customerPointsRepo, } } -// CreateCustomerPoints creates a new customer points record +// Existing gamification methods - placeholder implementations func (p *CustomerPointsProcessor) CreateCustomerPoints(ctx context.Context, req *models.CreateCustomerPointsRequest) (*models.CustomerPointsResponse, error) { - // Convert request to entity - customerPoints := mappers.ToCustomerPointsEntity(req) - - // Create customer points - err := p.customerPointsRepo.Create(ctx, customerPoints) - if err != nil { - return nil, fmt.Errorf("failed to create customer points: %w", err) - } - - return mappers.ToCustomerPointsResponse(customerPoints), nil + // TODO: Implement this method + return nil, fmt.Errorf("not implemented") } -// GetCustomerPoints retrieves customer points by ID func (p *CustomerPointsProcessor) GetCustomerPoints(ctx context.Context, id uuid.UUID) (*models.CustomerPointsResponse, error) { - customerPoints, err := p.customerPointsRepo.GetByID(ctx, id) - if err != nil { - return nil, fmt.Errorf("customer points not found: %w", err) - } - - return mappers.ToCustomerPointsResponse(customerPoints), nil + // TODO: Implement this method + return nil, fmt.Errorf("not implemented") } -// GetCustomerPointsByCustomerID retrieves customer points by customer ID func (p *CustomerPointsProcessor) GetCustomerPointsByCustomerID(ctx context.Context, customerID uuid.UUID) (*models.CustomerPointsResponse, error) { - customerPoints, err := p.customerPointsRepo.EnsureCustomerPoints(ctx, customerID) - if err != nil { - return nil, fmt.Errorf("failed to get customer points: %w", err) - } - - return mappers.ToCustomerPointsResponse(customerPoints), nil + // TODO: Implement this method + return nil, fmt.Errorf("not implemented") } -// ListCustomerPoints retrieves customer points with pagination and filtering -func (p *CustomerPointsProcessor) ListCustomerPoints(ctx context.Context, query *models.ListCustomerPointsQuery) (*models.PaginatedResponse[models.CustomerPointsResponse], error) { - // Set default values - if query.Page <= 0 { - query.Page = 1 - } - if query.Limit <= 0 { - query.Limit = 10 - } - if query.Limit > 100 { - query.Limit = 100 - } +func (p *CustomerPointsProcessor) ListCustomerPoints(ctx context.Context, query *models.ListCustomerPointsQuery) (*models.PaginatedCustomerPointsResponse, error) { + // Return empty paginated response for now + return &models.PaginatedCustomerPointsResponse{ + Data: []models.CustomerPointsResponse{}, + TotalCount: 0, + Page: 1, + Limit: 10, + TotalPages: 0, + }, nil +} - offset := (query.Page - 1) * query.Limit +func (p *CustomerPointsProcessor) UpdateCustomerPoints(ctx context.Context, id uuid.UUID, req *models.UpdateCustomerPointsRequest) (*models.CustomerPointsResponse, error) { + // TODO: Implement this method + return nil, fmt.Errorf("not implemented") +} - // Get customer points from repository - customerPoints, total, err := p.customerPointsRepo.List( - ctx, - offset, - query.Limit, - query.Search, - query.SortBy, - query.SortOrder, - ) +func (p *CustomerPointsProcessor) DeleteCustomerPoints(ctx context.Context, id uuid.UUID) error { + // TODO: Implement this method + return fmt.Errorf("not implemented") +} + +func (p *CustomerPointsProcessor) AddPoints(ctx context.Context, customerID uuid.UUID, points int64) (*models.CustomerPointsResponse, error) { + // TODO: Implement this method + return nil, fmt.Errorf("not implemented") +} + +func (p *CustomerPointsProcessor) DeductPoints(ctx context.Context, customerID uuid.UUID, points int64) (*models.CustomerPointsResponse, error) { + // TODO: Implement this method + return nil, fmt.Errorf("not implemented") +} + +func (p *CustomerPointsProcessor) GetCustomerTotalPointsAPI(ctx context.Context, customerID string) (*models.GetCustomerPointsResponse, error) { + // Get total points + totalPoints, err := p.customerPointsRepo.GetCustomerTotalPoints(ctx, customerID) if err != nil { - return nil, fmt.Errorf("failed to list customer points: %w", err) + return nil, fmt.Errorf("failed to get customer total points: %w", err) } - // Convert to responses - responses := mappers.ToCustomerPointsResponses(customerPoints) + // Get points history (last 10 records) + pointsHistory, err := p.customerPointsRepo.GetCustomerPointsHistory(ctx, customerID, 10) + if err != nil { + return nil, fmt.Errorf("failed to get customer points history: %w", err) + } - // Calculate pagination info - totalPages := int((total + int64(query.Limit) - 1) / int64(query.Limit)) + // Convert to response format + var historyItems []models.PointsHistoryItem - return &models.PaginatedResponse[models.CustomerPointsResponse]{ - Data: responses, - Pagination: models.Pagination{ - Page: query.Page, - Limit: query.Limit, - Total: total, - TotalPages: totalPages, + for _, point := range pointsHistory { + historyItems = append(historyItems, models.PointsHistoryItem{ + ID: point.ID.String(), + Points: point.Balance, + Type: "BALANCE", + Description: "Points balance", + CreatedAt: point.CreatedAt, + }) + } + + var lastUpdated time.Time + if len(pointsHistory) > 0 { + lastUpdated = pointsHistory[0].CreatedAt + } + + return &models.GetCustomerPointsResponse{ + Status: "SUCCESS", + Message: "Customer points retrieved successfully.", + Data: &models.GetCustomerPointsResponseData{ + TotalPoints: totalPoints, + PointsHistory: historyItems, + LastUpdated: lastUpdated, }, }, nil } -// UpdateCustomerPoints updates an existing customer points record -func (p *CustomerPointsProcessor) UpdateCustomerPoints(ctx context.Context, id uuid.UUID, req *models.UpdateCustomerPointsRequest) (*models.CustomerPointsResponse, error) { - // Get existing customer points - customerPoints, err := p.customerPointsRepo.GetByID(ctx, id) +func (p *CustomerPointsProcessor) GetCustomerTotalTokensAPI(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error) { + // Get total tokens + totalTokens, err := p.customerPointsRepo.GetCustomerTotalTokens(ctx, customerID) if err != nil { - return nil, fmt.Errorf("customer points not found: %w", err) + return nil, fmt.Errorf("failed to get customer total tokens: %w", err) } - // Update customer points fields - mappers.UpdateCustomerPointsEntity(customerPoints, req) - - // Save updated customer points - err = p.customerPointsRepo.Update(ctx, customerPoints) + // Get tokens history (last 10 records) + tokensHistory, err := p.customerPointsRepo.GetCustomerTokensHistory(ctx, customerID, 10) if err != nil { - return nil, fmt.Errorf("failed to update customer points: %w", err) + return nil, fmt.Errorf("failed to get customer tokens history: %w", err) } - return mappers.ToCustomerPointsResponse(customerPoints), nil + // Convert to response format + var historyItems []models.TokensHistoryItem + + for _, token := range tokensHistory { + historyItems = append(historyItems, models.TokensHistoryItem{ + ID: token.ID.String(), + Tokens: token.Balance, + Type: string(token.TokenType), + Description: "Tokens balance", + CreatedAt: token.CreatedAt, + }) + } + + var lastUpdated time.Time + if len(tokensHistory) > 0 { + lastUpdated = tokensHistory[0].CreatedAt + } + + return &models.GetCustomerTokensResponse{ + Status: "SUCCESS", + Message: "Customer tokens retrieved successfully.", + Data: &models.GetCustomerTokensResponseData{ + TotalTokens: totalTokens, + TokensHistory: historyItems, + LastUpdated: lastUpdated, + }, + }, nil } -// DeleteCustomerPoints deletes a customer points record -func (p *CustomerPointsProcessor) DeleteCustomerPoints(ctx context.Context, id uuid.UUID) error { - // Get existing customer points - _, err := p.customerPointsRepo.GetByID(ctx, id) +func (p *CustomerPointsProcessor) GetCustomerWalletAPI(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) { + // Get total points + totalPoints, err := p.customerPointsRepo.GetCustomerTotalPoints(ctx, customerID) if err != nil { - return fmt.Errorf("customer points not found: %w", err) + return nil, fmt.Errorf("failed to get customer total points: %w", err) } - // Delete customer points - err = p.customerPointsRepo.Delete(ctx, id) + // Get total tokens + totalTokens, err := p.customerPointsRepo.GetCustomerTotalTokens(ctx, customerID) if err != nil { - return fmt.Errorf("failed to delete customer points: %w", err) + return nil, fmt.Errorf("failed to get customer total tokens: %w", err) } - return nil -} - -// AddPoints adds points to a customer's balance -func (p *CustomerPointsProcessor) AddPoints(ctx context.Context, customerID uuid.UUID, points int64) (*models.CustomerPointsResponse, error) { - if points <= 0 { - return nil, errors.New("points must be greater than 0") - } - - // Ensure customer points record exists - _, err := p.customerPointsRepo.EnsureCustomerPoints(ctx, customerID) - if err != nil { - return nil, fmt.Errorf("failed to ensure customer points: %w", err) - } - - // Add points - err = p.customerPointsRepo.AddPoints(ctx, customerID, points) - if err != nil { - return nil, fmt.Errorf("failed to add points: %w", err) - } - - // Get updated customer points - customerPoints, err := p.customerPointsRepo.GetByCustomerID(ctx, customerID) - if err != nil { - return nil, fmt.Errorf("failed to get updated customer points: %w", err) - } - - return mappers.ToCustomerPointsResponse(customerPoints), nil -} - -// DeductPoints deducts points from a customer's balance -func (p *CustomerPointsProcessor) DeductPoints(ctx context.Context, customerID uuid.UUID, points int64) (*models.CustomerPointsResponse, error) { - if points <= 0 { - return nil, errors.New("points must be greater than 0") - } - - // Get current customer points - customerPoints, err := p.customerPointsRepo.GetByCustomerID(ctx, customerID) - if err != nil { - return nil, fmt.Errorf("customer points not found: %w", err) - } - - if customerPoints.Balance < points { - return nil, errors.New("insufficient points balance") - } - - // Deduct points - err = p.customerPointsRepo.DeductPoints(ctx, customerID, points) - if err != nil { - return nil, fmt.Errorf("failed to deduct points: %w", err) - } - - // Get updated customer points - updatedCustomerPoints, err := p.customerPointsRepo.GetByCustomerID(ctx, customerID) - if err != nil { - return nil, fmt.Errorf("failed to get updated customer points: %w", err) - } - - return mappers.ToCustomerPointsResponse(updatedCustomerPoints), nil + // Get points history (last 5 records) + pointsHistory, err := p.customerPointsRepo.GetCustomerPointsHistory(ctx, customerID, 5) + if err != nil { + return nil, fmt.Errorf("failed to get customer points history: %w", err) + } + + // Get tokens history (last 5 records) + tokensHistory, err := p.customerPointsRepo.GetCustomerTokensHistory(ctx, customerID, 5) + if err != nil { + return nil, fmt.Errorf("failed to get customer tokens history: %w", err) + } + + // Convert to response format + var pointsHistoryItems []models.PointsHistoryItem + var tokensHistoryItems []models.TokensHistoryItem + var lastUpdated time.Time + + for _, point := range pointsHistory { + pointsHistoryItems = append(pointsHistoryItems, models.PointsHistoryItem{ + ID: point.ID.String(), + Points: point.Balance, + Type: "BALANCE", + Description: "Points balance", + CreatedAt: point.CreatedAt, + }) + if point.CreatedAt.After(lastUpdated) { + lastUpdated = point.CreatedAt + } + } + + for _, token := range tokensHistory { + tokensHistoryItems = append(tokensHistoryItems, models.TokensHistoryItem{ + ID: token.ID.String(), + Tokens: token.Balance, + Type: string(token.TokenType), + Description: "Tokens balance", + CreatedAt: token.CreatedAt, + }) + if token.CreatedAt.After(lastUpdated) { + lastUpdated = token.CreatedAt + } + } + + return &models.GetCustomerWalletResponse{ + Status: "SUCCESS", + Message: "Customer wallet retrieved successfully.", + Data: &models.GetCustomerWalletResponseData{ + TotalPoints: totalPoints, + TotalTokens: totalTokens, + PointsHistory: pointsHistoryItems, + TokensHistory: tokensHistoryItems, + LastUpdated: lastUpdated, + }, + }, nil } diff --git a/internal/repository/customer_points_repository.go b/internal/repository/customer_points_repository.go index b9714c6..14ec102 100644 --- a/internal/repository/customer_points_repository.go +++ b/internal/repository/customer_points_repository.go @@ -1,117 +1,97 @@ package repository import ( - "apskel-pos-be/internal/entities" "context" "fmt" - "github.com/google/uuid" + "apskel-pos-be/internal/entities" + "gorm.io/gorm" ) -type CustomerPointsRepository struct { +type CustomerPointsRepository interface { + GetCustomerTotalPoints(ctx context.Context, customerID string) (int64, error) + GetCustomerTotalTokens(ctx context.Context, customerID string) (int64, error) + GetCustomerPointsHistory(ctx context.Context, customerID string, limit int) ([]entities.CustomerPoints, error) + GetCustomerTokensHistory(ctx context.Context, customerID string, limit int) ([]entities.CustomerTokens, error) +} + +type customerPointsRepository struct { db *gorm.DB } -func NewCustomerPointsRepository(db *gorm.DB) *CustomerPointsRepository { - return &CustomerPointsRepository{db: db} -} - -func (r *CustomerPointsRepository) Create(ctx context.Context, customerPoints *entities.CustomerPoints) error { - return r.db.WithContext(ctx).Create(customerPoints).Error -} - -func (r *CustomerPointsRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.CustomerPoints, error) { - var customerPoints entities.CustomerPoints - err := r.db.WithContext(ctx).Preload("Customer").Where("id = ?", id).First(&customerPoints).Error - if err != nil { - return nil, err +func NewCustomerPointsRepository(db *gorm.DB) CustomerPointsRepository { + return &customerPointsRepository{ + db: db, } - return &customerPoints, nil } -func (r *CustomerPointsRepository) GetByCustomerID(ctx context.Context, customerID uuid.UUID) (*entities.CustomerPoints, error) { - var customerPoints entities.CustomerPoints - err := r.db.WithContext(ctx).Preload("Customer").Where("customer_id = ?", customerID).First(&customerPoints).Error - if err != nil { - return nil, err - } - return &customerPoints, nil -} +func (r *customerPointsRepository) GetCustomerTotalPoints(ctx context.Context, customerID string) (int64, error) { + var totalPoints int64 -func (r *CustomerPointsRepository) List(ctx context.Context, offset, limit int, search string, sortBy, sortOrder string) ([]entities.CustomerPoints, int64, error) { - var customerPoints []entities.CustomerPoints - var total int64 - - query := r.db.WithContext(ctx).Preload("Customer") - - if search != "" { - searchTerm := "%" + search + "%" - query = query.Joins("JOIN customers ON customer_points.customer_id = customers.id"). - Where("customers.name ILIKE ? OR customers.email ILIKE ?", searchTerm, searchTerm) - } - - if err := query.Model(&entities.CustomerPoints{}).Count(&total).Error; err != nil { - return nil, 0, err - } - - if sortBy != "" { - if sortOrder == "" { - sortOrder = "asc" - } - query = query.Order(fmt.Sprintf("customer_points.%s %s", sortBy, sortOrder)) - } else { - query = query.Order("customer_points.created_at DESC") - } - - err := query.Offset(offset).Limit(limit).Find(&customerPoints).Error - if err != nil { - return nil, 0, err - } - - return customerPoints, total, nil -} - -func (r *CustomerPointsRepository) Update(ctx context.Context, customerPoints *entities.CustomerPoints) error { - return r.db.WithContext(ctx).Save(customerPoints).Error -} - -func (r *CustomerPointsRepository) Delete(ctx context.Context, id uuid.UUID) error { - return r.db.WithContext(ctx).Delete(&entities.CustomerPoints{}, id).Error -} - -func (r *CustomerPointsRepository) AddPoints(ctx context.Context, customerID uuid.UUID, points int64) error { - return r.db.WithContext(ctx).Model(&entities.CustomerPoints{}). + err := r.db.WithContext(ctx). + Model(&entities.CustomerPoints{}). Where("customer_id = ?", customerID). - Update("balance", gorm.Expr("balance + ?", points)).Error -} + Select("COALESCE(SUM(balance), 0)"). + Scan(&totalPoints).Error -func (r *CustomerPointsRepository) DeductPoints(ctx context.Context, customerID uuid.UUID, points int64) error { - return r.db.WithContext(ctx).Model(&entities.CustomerPoints{}). - Where("customer_id = ? AND balance >= ?", customerID, points). - Update("balance", gorm.Expr("balance - ?", points)).Error -} - -func (r *CustomerPointsRepository) EnsureCustomerPoints(ctx context.Context, customerID uuid.UUID) (*entities.CustomerPoints, error) { - customerPoints, err := r.GetByCustomerID(ctx, customerID) - if err == nil { - return customerPoints, nil - } - - if err != gorm.ErrRecordNotFound { - return nil, err - } - - // Create new customer points record - newCustomerPoints := &entities.CustomerPoints{ - CustomerID: customerID, - Balance: 0, - } - - err = r.Create(ctx, newCustomerPoints) if err != nil { - return nil, err + return 0, fmt.Errorf("failed to get customer total points: %w", err) } - return newCustomerPoints, nil + return totalPoints, nil +} + +func (r *customerPointsRepository) GetCustomerTotalTokens(ctx context.Context, customerID string) (int64, error) { + var totalTokens int64 + + err := r.db.WithContext(ctx). + Model(&entities.CustomerTokens{}). + Where("customer_id = ?", customerID). + Select("COALESCE(SUM(tokens), 0)"). + Scan(&totalTokens).Error + + if err != nil { + return 0, fmt.Errorf("failed to get customer total tokens: %w", err) + } + + return totalTokens, nil +} + +func (r *customerPointsRepository) GetCustomerPointsHistory(ctx context.Context, customerID string, limit int) ([]entities.CustomerPoints, error) { + var pointsHistory []entities.CustomerPoints + + query := r.db.WithContext(ctx). + Where("customer_id = ?", customerID). + Order("created_at DESC") + + if limit > 0 { + query = query.Limit(limit) + } + + err := query.Find(&pointsHistory).Error + if err != nil { + return nil, fmt.Errorf("failed to get customer points history: %w", err) + } + + return pointsHistory, nil +} + +func (r *customerPointsRepository) GetCustomerTokensHistory(ctx context.Context, customerID string, limit int) ([]entities.CustomerTokens, error) { + var tokensHistory []entities.CustomerTokens + + query := r.db.WithContext(ctx). + Where("customer_id = ?", customerID). + Order("created_at DESC") + + if limit > 0 { + query = query.Limit(limit) + } + + err := query.Find(&tokensHistory).Error + if err != nil { + return nil, fmt.Errorf("failed to get customer tokens history: %w", err) + } + + return tokensHistory, nil } diff --git a/internal/router/router.go b/internal/router/router.go index 918caa6..7a3e5c9 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -44,7 +44,9 @@ type Router struct { rewardHandler *handler.RewardHandler campaignHandler *handler.CampaignHandler customerAuthHandler *handler.CustomerAuthHandler + customerPointsHandler *handler.CustomerPointsHandler authMiddleware *middleware.AuthMiddleware + customerAuthMiddleware *middleware.CustomerAuthMiddleware } func NewRouter(cfg *config.Config, @@ -102,7 +104,9 @@ func NewRouter(cfg *config.Config, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, - customerAuthValidator validator.CustomerAuthValidator) *Router { + customerAuthValidator validator.CustomerAuthValidator, + customerPointsService service.CustomerPointsService, + customerAuthMiddleware *middleware.CustomerAuthMiddleware) *Router { return &Router{ config: cfg, @@ -136,7 +140,9 @@ func NewRouter(cfg *config.Config, rewardHandler: handler.NewRewardHandler(rewardService, rewardValidator), campaignHandler: handler.NewCampaignHandler(campaignService, campaignValidator), customerAuthHandler: handler.NewCustomerAuthHandler(customerAuthService, customerAuthValidator), + customerPointsHandler: handler.NewCustomerPointsHandler(customerPointsService), authMiddleware: authMiddleware, + customerAuthMiddleware: customerAuthMiddleware, } } @@ -181,6 +187,15 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { customerAuth.POST("/resend-otp", r.customerAuthHandler.ResendOtp) } + // Customer authenticated routes + customer := v1.Group("/customer") + customer.Use(r.customerAuthMiddleware.ValidateCustomerToken()) + { + customer.GET("/points", r.customerPointsHandler.GetCustomerPoints) + customer.GET("/tokens", r.customerPointsHandler.GetCustomerTokens) + customer.GET("/wallet", r.customerPointsHandler.GetCustomerWallet) + } + organizations := v1.Group("/organizations") { organizations.POST("", r.organizationHandler.CreateOrganization) diff --git a/internal/service/customer_points_service.go b/internal/service/customer_points_service.go new file mode 100644 index 0000000..48aa914 --- /dev/null +++ b/internal/service/customer_points_service.go @@ -0,0 +1,64 @@ +package service + +import ( + "context" + "fmt" + + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/processor" +) + +type CustomerPointsService interface { + GetCustomerPoints(ctx context.Context, customerID string) (*models.GetCustomerPointsResponse, error) + GetCustomerTokens(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error) + GetCustomerWallet(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) +} + +type customerPointsService struct { + customerPointsProcessor *processor.CustomerPointsProcessor +} + +func NewCustomerPointsService(customerPointsProcessor *processor.CustomerPointsProcessor) CustomerPointsService { + return &customerPointsService{ + customerPointsProcessor: customerPointsProcessor, + } +} + +func (s *customerPointsService) GetCustomerPoints(ctx context.Context, customerID string) (*models.GetCustomerPointsResponse, error) { + if customerID == "" { + return nil, fmt.Errorf("customer ID is required") + } + + response, err := s.customerPointsProcessor.GetCustomerTotalPointsAPI(ctx, customerID) + if err != nil { + return nil, fmt.Errorf("failed to get customer points: %w", err) + } + + return response, nil +} + +func (s *customerPointsService) GetCustomerTokens(ctx context.Context, customerID string) (*models.GetCustomerTokensResponse, error) { + if customerID == "" { + return nil, fmt.Errorf("customer ID is required") + } + + response, err := s.customerPointsProcessor.GetCustomerTotalTokensAPI(ctx, customerID) + if err != nil { + return nil, fmt.Errorf("failed to get customer tokens: %w", err) + } + + return response, nil +} + +func (s *customerPointsService) GetCustomerWallet(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) { + if customerID == "" { + return nil, fmt.Errorf("customer ID is required") + } + + response, err := s.customerPointsProcessor.GetCustomerWalletAPI(ctx, customerID) + if err != nil { + return nil, fmt.Errorf("failed to get customer wallet: %w", err) + } + + return response, nil +}