diff --git a/internal/app/app.go b/internal/app/app.go index 0485697..0c7ad27 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -96,6 +96,10 @@ func (a *App) Initialize(cfg *config.Config) error { validators.orderIngredientTransactionValidator, services.gamificationService, validators.gamificationValidator, + services.rewardService, + validators.rewardValidator, + services.campaignService, + validators.campaignValidator, ) return nil @@ -176,6 +180,8 @@ type repositories struct { gamePrizeRepo *repository.GamePrizeRepository gamePlayRepo *repository.GamePlayRepository omsetTrackerRepo *repository.OmsetTrackerRepository + rewardRepo repository.RewardRepository + campaignRepo repository.CampaignRepository txManager *repository.TxManager } @@ -216,6 +222,8 @@ func (a *App) initRepositories() *repositories { gamePrizeRepo: repository.NewGamePrizeRepository(a.db), gamePlayRepo: repository.NewGamePlayRepository(a.db), omsetTrackerRepo: repository.NewOmsetTrackerRepository(a.db), + rewardRepo: repository.NewRewardRepository(a.db), + campaignRepo: repository.NewCampaignRepository(a.db), txManager: repository.NewTxManager(a.db), } } @@ -252,6 +260,8 @@ type processors struct { gamePrizeProcessor *processor.GamePrizeProcessor gamePlayProcessor *processor.GamePlayProcessor omsetTrackerProcessor *processor.OmsetTrackerProcessor + rewardProcessor processor.RewardProcessor + campaignProcessor processor.CampaignProcessor fileClient processor.FileClient inventoryMovementService service.InventoryMovementService } @@ -292,6 +302,8 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor gamePrizeProcessor: processor.NewGamePrizeProcessor(repos.gamePrizeRepo), gamePlayProcessor: processor.NewGamePlayProcessor(repos.gamePlayRepo, repos.gameRepo, repos.gamePrizeRepo, repos.customerTokensRepo, repos.customerPointsRepo), omsetTrackerProcessor: processor.NewOmsetTrackerProcessor(repos.omsetTrackerRepo), + rewardProcessor: processor.NewRewardProcessor(repos.rewardRepo), + campaignProcessor: processor.NewCampaignProcessor(repos.campaignRepo), fileClient: fileClient, inventoryMovementService: inventoryMovementService, } @@ -325,6 +337,8 @@ type services struct { accountService service.AccountService orderIngredientTransactionService *service.OrderIngredientTransactionService gamificationService service.GamificationService + rewardService service.RewardService + campaignService service.CampaignService } func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { @@ -356,6 +370,8 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con accountService := service.NewAccountService(processors.accountProcessor) orderIngredientTransactionService := service.NewOrderIngredientTransactionService(processors.orderIngredientTransactionProcessor, repos.txManager) gamificationService := service.NewGamificationService(processors.customerPointsProcessor, processors.customerTokensProcessor, processors.tierProcessor, processors.gameProcessor, processors.gamePrizeProcessor, processors.gamePlayProcessor, processors.omsetTrackerProcessor) + rewardService := service.NewRewardService(processors.rewardProcessor) + campaignService := service.NewCampaignService(processors.campaignProcessor) // Update order service with order ingredient transaction service orderService = service.NewOrderServiceImpl(processors.orderProcessor, repos.tableRepo, orderIngredientTransactionService, processors.orderIngredientTransactionProcessor, *repos.productRecipeRepo, repos.txManager) @@ -388,6 +404,8 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con accountService: accountService, orderIngredientTransactionService: orderIngredientTransactionService, gamificationService: gamificationService, + rewardService: rewardService, + campaignService: campaignService, } } @@ -422,6 +440,8 @@ type validators struct { accountValidator *validator.AccountValidatorImpl orderIngredientTransactionValidator *validator.OrderIngredientTransactionValidatorImpl gamificationValidator *validator.GamificationValidatorImpl + rewardValidator validator.RewardValidator + campaignValidator validator.CampaignValidator } func (a *App) initValidators() *validators { @@ -446,5 +466,7 @@ func (a *App) initValidators() *validators { accountValidator: validator.NewAccountValidator().(*validator.AccountValidatorImpl), orderIngredientTransactionValidator: validator.NewOrderIngredientTransactionValidator().(*validator.OrderIngredientTransactionValidatorImpl), gamificationValidator: validator.NewGamificationValidator(), + rewardValidator: validator.NewRewardValidator(), + campaignValidator: validator.NewCampaignValidator(), } } diff --git a/internal/constants/error.go b/internal/constants/error.go index 33aafd6..3cffc40 100644 --- a/internal/constants/error.go +++ b/internal/constants/error.go @@ -50,6 +50,9 @@ const ( GamePrizeEntity = "game_prize" GamePlayEntity = "game_play" OmsetTrackerEntity = "omset_tracker" + RewardEntity = "reward" + CampaignEntity = "campaign" + CampaignRuleEntity = "campaign_rule" ) var HttpErrorMap = map[string]int{ diff --git a/internal/contract/campaign_contract.go b/internal/contract/campaign_contract.go new file mode 100644 index 0000000..28b8ddd --- /dev/null +++ b/internal/contract/campaign_contract.go @@ -0,0 +1,150 @@ +package contract + +import ( + "time" + + "github.com/google/uuid" +) + +// Request Contracts +type CreateCampaignRequest struct { + Name string `json:"name" binding:"required,min=1,max=150"` + Description *string `json:"description,omitempty"` + Type string `json:"type" binding:"required,oneof=REWARD POINTS TOKENS MIXED"` + StartDate time.Time `json:"start_date" binding:"required"` + EndDate time.Time `json:"end_date" binding:"required"` + IsActive bool `json:"is_active"` + ShowOnApp bool `json:"show_on_app"` + Position int `json:"position" binding:"min=0"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` + Rules []CampaignRuleStruct `json:"rules" binding:"required,min=1"` +} + +type UpdateCampaignRequest struct { + ID uuid.UUID `json:"id" binding:"required"` + Name string `json:"name" binding:"required,min=1,max=150"` + Description *string `json:"description,omitempty"` + Type string `json:"type" binding:"required,oneof=REWARD POINTS TOKENS MIXED"` + StartDate time.Time `json:"start_date" binding:"required"` + EndDate time.Time `json:"end_date" binding:"required"` + IsActive bool `json:"is_active"` + ShowOnApp bool `json:"show_on_app"` + Position int `json:"position" binding:"min=0"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` + Rules []CampaignRuleStruct `json:"rules" binding:"required,min=1"` +} + +type ListCampaignsRequest struct { + Page int `form:"page" binding:"min=1"` + Limit int `form:"limit" binding:"min=1,max=100"` + Search string `form:"search"` + Type string `form:"type"` + IsActive *bool `form:"is_active"` + ShowOnApp *bool `form:"show_on_app"` + StartDate *time.Time `form:"start_date"` + EndDate *time.Time `form:"end_date"` +} + +type GetCampaignRequest struct { + ID uuid.UUID `uri:"id" binding:"required"` +} + +type DeleteCampaignRequest struct { + ID uuid.UUID `uri:"id" binding:"required"` +} + +// Campaign Rule Request Contracts +type CreateCampaignRuleRequest struct { + CampaignID uuid.UUID `json:"campaign_id" binding:"required"` + RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"` + ConditionValue *string `json:"condition_value,omitempty"` + RewardType string `json:"reward_type" binding:"required,oneof=POINTS TOKENS REWARD"` + RewardValue *int64 `json:"reward_value,omitempty"` + RewardSubtype *string `json:"reward_subtype,omitempty"` + RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` +} + +type UpdateCampaignRuleRequest struct { + ID uuid.UUID `json:"id" binding:"required"` + CampaignID uuid.UUID `json:"campaign_id" binding:"required"` + RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"` + ConditionValue *string `json:"condition_value,omitempty"` + RewardType string `json:"reward_type" binding:"required,oneof=POINTS TOKENS REWARD"` + RewardValue *int64 `json:"reward_value,omitempty"` + RewardSubtype *string `json:"reward_subtype,omitempty"` + RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` +} + +type ListCampaignRulesRequest struct { + Page int `form:"page" binding:"min=1"` + Limit int `form:"limit" binding:"min=1,max=100"` + CampaignID string `form:"campaign_id"` + RuleType string `form:"rule_type"` + RewardType string `form:"reward_type"` +} + +type GetCampaignRuleRequest struct { + ID uuid.UUID `uri:"id" binding:"required"` +} + +type DeleteCampaignRuleRequest struct { + ID uuid.UUID `uri:"id" binding:"required"` +} + +// Response Contracts +type CampaignResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Type string `json:"type"` + StartDate time.Time `json:"start_date"` + EndDate time.Time `json:"end_date"` + IsActive bool `json:"is_active"` + ShowOnApp bool `json:"show_on_app"` + Position int `json:"position"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` + Rules []CampaignRuleResponse `json:"rules,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type CampaignRuleResponse struct { + ID uuid.UUID `json:"id"` + CampaignID uuid.UUID `json:"campaign_id"` + RuleType string `json:"rule_type"` + ConditionValue *string `json:"condition_value,omitempty"` + RewardType string `json:"reward_type"` + RewardValue *int64 `json:"reward_value,omitempty"` + RewardSubtype *string `json:"reward_subtype,omitempty"` + RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ListCampaignsResponse struct { + Campaigns []CampaignResponse `json:"campaigns"` + Total int64 `json:"total"` + Page int `json:"page"` + Limit int `json:"limit"` +} + +type ListCampaignRulesResponse struct { + Rules []CampaignRuleResponse `json:"rules"` + Total int64 `json:"total"` + Page int `json:"page"` + Limit int `json:"limit"` +} + +// Helper structs +type CampaignRuleStruct struct { + RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"` + ConditionValue *string `json:"condition_value,omitempty"` + RewardType string `json:"reward_type" binding:"required,oneof=POINTS TOKENS REWARD"` + RewardValue *int64 `json:"reward_value,omitempty"` + RewardSubtype *string `json:"reward_subtype,omitempty"` + RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` +} diff --git a/internal/contract/reward_contract.go b/internal/contract/reward_contract.go new file mode 100644 index 0000000..968d26b --- /dev/null +++ b/internal/contract/reward_contract.go @@ -0,0 +1,82 @@ +package contract + +import ( + "time" + + "github.com/google/uuid" +) + +// Request Contracts +type CreateRewardRequest struct { + Name string `json:"name" binding:"required,min=1,max=150"` + RewardType string `json:"reward_type" binding:"required,oneof=VOUCHER PHYSICAL DIGITAL"` + CostPoints int64 `json:"cost_points" binding:"required,min=1"` + Stock *int `json:"stock,omitempty"` + MaxPerCustomer int `json:"max_per_customer" binding:"min=1"` + Tnc *TermsAndConditionsStruct `json:"tnc,omitempty"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` + Images *[]string `json:"images,omitempty"` +} + +type UpdateRewardRequest struct { + ID uuid.UUID `json:"id" binding:"required"` + Name string `json:"name" binding:"required,min=1,max=150"` + RewardType string `json:"reward_type" binding:"required,oneof=VOUCHER PHYSICAL DIGITAL BALANCE"` + CostPoints int64 `json:"cost_points" binding:"required,min=1"` + Stock *int `json:"stock,omitempty"` + MaxPerCustomer int `json:"max_per_customer" binding:"min=1"` + Tnc *TermsAndConditionsStruct `json:"tnc,omitempty"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` + Images *[]string `json:"images,omitempty"` +} + +type ListRewardsRequest struct { + Page int `form:"page" binding:"min=1"` + Limit int `form:"limit" binding:"min=1,max=100"` + Search string `form:"search"` + RewardType string `form:"reward_type"` + MinPoints *int64 `form:"min_points"` + MaxPoints *int64 `form:"max_points"` + HasStock *bool `form:"has_stock"` +} + +type GetRewardRequest struct { + ID uuid.UUID `uri:"id" binding:"required"` +} + +type DeleteRewardRequest struct { + ID uuid.UUID `uri:"id" binding:"required"` +} + +// Response Contracts +type RewardResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + RewardType string `json:"reward_type"` + CostPoints int64 `json:"cost_points"` + Stock *int `json:"stock,omitempty"` + MaxPerCustomer int `json:"max_per_customer"` + Tnc *TermsAndConditionsStruct `json:"tnc,omitempty"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` + Images *[]string `json:"images,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ListRewardsResponse struct { + Rewards []RewardResponse `json:"rewards"` + Total int64 `json:"total"` + Page int `json:"page"` + Limit int `json:"limit"` +} + +// Helper structs +type TermsAndConditionsStruct struct { + Sections []TncSectionStruct `json:"sections"` + ExpiryDays int `json:"expiry_days"` +} + +type TncSectionStruct struct { + Title string `json:"title"` + Rules []string `json:"rules"` +} diff --git a/internal/entities/campaign.go b/internal/entities/campaign.go new file mode 100644 index 0000000..ea4c703 --- /dev/null +++ b/internal/entities/campaign.go @@ -0,0 +1,131 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type CampaignType string + +const ( + CampaignTypeReward CampaignType = "REWARD" + CampaignTypePoints CampaignType = "POINTS" + CampaignTypeTokens CampaignType = "TOKENS" + CampaignTypeMixed CampaignType = "MIXED" +) + +type RuleType string + +const ( + RuleTypeTier RuleType = "TIER" + RuleTypeSpend RuleType = "SPEND" + RuleTypeProduct RuleType = "PRODUCT" + RuleTypeCategory RuleType = "CATEGORY" + RuleTypeDay RuleType = "DAY" + RuleTypeLocation RuleType = "LOCATION" +) + +type CampaignRewardType string + +const ( + CampaignRewardTypePoints CampaignRewardType = "POINTS" + CampaignRewardTypeTokens CampaignRewardType = "TOKENS" + CampaignRewardTypeReward CampaignRewardType = "REWARD" +) + +type RewardSubtype string + +const ( + RewardSubtypeMultiplier RewardSubtype = "MULTIPLIER" + RewardSubtypeSpin RewardSubtype = "SPIN" + RewardSubtypeBonus RewardSubtype = "BONUS" + RewardSubtypePhysical RewardSubtype = "PHYSICAL" +) + +type Campaign struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + Name string `gorm:"type:varchar(150);not null" json:"name"` + Description *string `gorm:"type:text" json:"description,omitempty"` + Type CampaignType `gorm:"type:varchar(50);not null" json:"type"` + StartDate time.Time `gorm:"type:timestamp;not null" json:"start_date"` + EndDate time.Time `gorm:"type:timestamp;not null" json:"end_date"` + IsActive bool `gorm:"type:boolean;default:true" json:"is_active"` + ShowOnApp bool `gorm:"type:boolean;default:true" json:"show_on_app"` + Position int `gorm:"type:int;default:0" json:"position"` + Metadata *map[string]interface{} `gorm:"type:jsonb" json:"metadata,omitempty"` + CreatedAt time.Time `gorm:"type:timestamp;default:now()" json:"created_at"` + UpdatedAt time.Time `gorm:"type:timestamp;default:now()" json:"updated_at"` + + // Relations + Rules []CampaignRule `gorm:"foreignKey:CampaignID;constraint:OnDelete:CASCADE" json:"rules,omitempty"` +} + +func (Campaign) TableName() string { + return "campaigns" +} + +func (c *Campaign) BeforeCreate(tx *gorm.DB) error { + if c.ID == uuid.Nil { + c.ID = uuid.New() + } + return nil +} + +func (c *Campaign) BeforeUpdate(tx *gorm.DB) error { + c.UpdatedAt = time.Now() + return nil +} + +type CampaignRule struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + CampaignID uuid.UUID `gorm:"type:uuid;not null" json:"campaign_id"` + RuleType RuleType `gorm:"type:varchar(50);not null" json:"rule_type"` + ConditionValue *string `gorm:"type:varchar(255)" json:"condition_value,omitempty"` + RewardType CampaignRewardType `gorm:"type:varchar(50);not null" json:"reward_type"` + RewardValue *int64 `gorm:"type:bigint" json:"reward_value,omitempty"` + RewardSubtype *RewardSubtype `gorm:"type:varchar(50)" json:"reward_subtype,omitempty"` + RewardRefID *uuid.UUID `gorm:"type:uuid" json:"reward_ref_id,omitempty"` + Metadata *map[string]interface{} `gorm:"type:jsonb" json:"metadata,omitempty"` + CreatedAt time.Time `gorm:"type:timestamp;default:now()" json:"created_at"` + UpdatedAt time.Time `gorm:"type:timestamp;default:now()" json:"updated_at"` + + // Relations + Campaign Campaign `gorm:"foreignKey:CampaignID" json:"campaign,omitempty"` +} + +func (CampaignRule) TableName() string { + return "campaign_rules" +} + +func (cr *CampaignRule) BeforeCreate(tx *gorm.DB) error { + if cr.ID == uuid.Nil { + cr.ID = uuid.New() + } + return nil +} + +func (cr *CampaignRule) BeforeUpdate(tx *gorm.DB) error { + cr.UpdatedAt = time.Now() + return nil +} + +type ListCampaignsRequest struct { + Page int `form:"page" binding:"min=1"` + Limit int `form:"limit" binding:"min=1,max=100"` + Search string `form:"search"` + Type string `form:"type"` + IsActive *bool `form:"is_active"` + ShowOnApp *bool `form:"show_on_app"` + StartDate *time.Time `form:"start_date"` + EndDate *time.Time `form:"end_date"` +} + +type ListCampaignRulesRequest struct { + Page int `form:"page" binding:"min=1"` + Limit int `form:"limit" binding:"min=1,max=100"` + CampaignID string `form:"campaign_id"` + RuleType string `form:"rule_type"` + RewardType string `form:"reward_type"` +} diff --git a/internal/entities/entities.go b/internal/entities/entities.go index d46008f..8a50afc 100644 --- a/internal/entities/entities.go +++ b/internal/entities/entities.go @@ -31,6 +31,9 @@ func GetAllEntities() []interface{} { &GamePrize{}, &GamePlay{}, &OmsetTracker{}, + &Reward{}, + &Campaign{}, + &CampaignRule{}, // Analytics entities are not database tables, they are query results } } diff --git a/internal/entities/reward.go b/internal/entities/reward.go new file mode 100644 index 0000000..2254ff0 --- /dev/null +++ b/internal/entities/reward.go @@ -0,0 +1,67 @@ +package entities + +import ( + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type RewardType string + +const ( + RewardTypeVoucher RewardType = "VOUCHER" + RewardTypePhysical RewardType = "PHYSICAL" + RewardTypeDigital RewardType = "DIGITAL" + RewardTypeBalance RewardType = "BALANCE" +) + +type TermsAndConditions struct { + Sections []TncSection `json:"sections"` + ExpiryDays int `json:"expiry_days"` +} + +type TncSection struct { + Title string `json:"title"` + Rules []string `json:"rules"` +} + +type Reward struct { + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + Name string `gorm:"type:varchar(150);not null" json:"name"` + RewardType RewardType `gorm:"type:varchar(50);not null" json:"reward_type"` + CostPoints int64 `gorm:"type:bigint;not null" json:"cost_points"` + Stock *int `gorm:"type:int" json:"stock,omitempty"` + MaxPerCustomer int `gorm:"type:int;default:1" json:"max_per_customer"` + Tnc *TermsAndConditions `gorm:"type:jsonb" json:"tnc,omitempty"` + Metadata *map[string]interface{} `gorm:"type:jsonb" json:"metadata,omitempty"` + Images *[]string `gorm:"type:jsonb" json:"images,omitempty"` + CreatedAt time.Time `gorm:"type:timestamp;default:now()" json:"created_at"` + UpdatedAt time.Time `gorm:"type:timestamp;default:now()" json:"updated_at"` +} + +func (Reward) TableName() string { + return "rewards" +} + +func (r *Reward) BeforeCreate(tx *gorm.DB) error { + if r.ID == uuid.Nil { + r.ID = uuid.New() + } + return nil +} + +func (r *Reward) BeforeUpdate(tx *gorm.DB) error { + r.UpdatedAt = time.Now() + return nil +} + +type ListRewardsRequest struct { + Page int `form:"page" binding:"min=1"` + Limit int `form:"limit" binding:"min=1,max=100"` + Search string `form:"search"` + RewardType string `form:"reward_type"` + MinPoints *int64 `form:"min_points"` + MaxPoints *int64 `form:"max_points"` + HasStock *bool `form:"has_stock"` +} diff --git a/internal/handler/campaign_handler.go b/internal/handler/campaign_handler.go new file mode 100644 index 0000000..4546e99 --- /dev/null +++ b/internal/handler/campaign_handler.go @@ -0,0 +1,179 @@ +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" + "apskel-pos-be/internal/validator" + + "github.com/gin-gonic/gin" +) + +type CampaignHandler struct { + campaignService service.CampaignService + campaignValidator validator.CampaignValidator +} + +func NewCampaignHandler(campaignService service.CampaignService, campaignValidator validator.CampaignValidator) *CampaignHandler { + return &CampaignHandler{ + campaignService: campaignService, + campaignValidator: campaignValidator, + } +} + +func (h *CampaignHandler) CreateCampaign(c *gin.Context) { + ctx := c.Request.Context() + + var req contract.CreateCampaignRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("CampaignHandler::CreateCampaign -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CampaignHandler::CreateCampaign") + return + } + + validationError, validationErrorCode := h.campaignValidator.ValidateCreateCampaignRequest(&req) + if validationError != nil { + logger.FromContext(c.Request.Context()).WithError(validationError).Error("CampaignHandler::CreateCampaign -> request validation failed") + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CampaignHandler::CreateCampaign") + return + } + + response, err := h.campaignService.CreateCampaign(ctx, &req) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("CampaignHandler::CreateCampaign -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CampaignEntity, err.Error())}), "CampaignHandler::CreateCampaign") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CampaignHandler::CreateCampaign") +} + +func (h *CampaignHandler) GetCampaign(c *gin.Context) { + ctx := c.Request.Context() + + idStr := c.Param("id") + if idStr == "" { + logger.FromContext(c.Request.Context()).Error("CampaignHandler::GetCampaign -> missing ID parameter") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.CampaignEntity, "ID parameter is required") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CampaignHandler::GetCampaign") + return + } + + response, err := h.campaignService.GetCampaign(ctx, idStr) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("CampaignHandler::GetCampaign -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CampaignEntity, err.Error())}), "CampaignHandler::GetCampaign") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CampaignHandler::GetCampaign") +} + +func (h *CampaignHandler) ListCampaigns(c *gin.Context) { + ctx := c.Request.Context() + + var req contract.ListCampaignsRequest + if err := c.ShouldBindQuery(&req); err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("CampaignHandler::ListCampaigns -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CampaignHandler::ListCampaigns") + return + } + + validationError, validationErrorCode := h.campaignValidator.ValidateListCampaignsRequest(&req) + if validationError != nil { + logger.FromContext(c.Request.Context()).WithError(validationError).Error("CampaignHandler::ListCampaigns -> request validation failed") + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CampaignHandler::ListCampaigns") + return + } + + response, err := h.campaignService.ListCampaigns(ctx, &req) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("CampaignHandler::ListCampaigns -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CampaignEntity, err.Error())}), "CampaignHandler::ListCampaigns") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CampaignHandler::ListCampaigns") +} + +func (h *CampaignHandler) UpdateCampaign(c *gin.Context) { + ctx := c.Request.Context() + + var req contract.UpdateCampaignRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("CampaignHandler::UpdateCampaign -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CampaignHandler::UpdateCampaign") + return + } + + validationError, validationErrorCode := h.campaignValidator.ValidateUpdateCampaignRequest(&req) + if validationError != nil { + logger.FromContext(c.Request.Context()).WithError(validationError).Error("CampaignHandler::UpdateCampaign -> request validation failed") + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CampaignHandler::UpdateCampaign") + return + } + + response, err := h.campaignService.UpdateCampaign(ctx, &req) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("CampaignHandler::UpdateCampaign -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CampaignEntity, err.Error())}), "CampaignHandler::UpdateCampaign") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CampaignHandler::UpdateCampaign") +} + +func (h *CampaignHandler) DeleteCampaign(c *gin.Context) { + ctx := c.Request.Context() + + idStr := c.Param("id") + if idStr == "" { + logger.FromContext(c.Request.Context()).Error("CampaignHandler::DeleteCampaign -> missing ID parameter") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.CampaignEntity, "ID parameter is required") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "CampaignHandler::DeleteCampaign") + return + } + + err := h.campaignService.DeleteCampaign(ctx, idStr) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("CampaignHandler::DeleteCampaign -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CampaignEntity, err.Error())}), "CampaignHandler::DeleteCampaign") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse("Campaign deleted successfully"), "CampaignHandler::DeleteCampaign") +} + +func (h *CampaignHandler) GetActiveCampaigns(c *gin.Context) { + ctx := c.Request.Context() + + response, err := h.campaignService.GetActiveCampaigns(ctx) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("CampaignHandler::GetActiveCampaigns -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CampaignEntity, err.Error())}), "CampaignHandler::GetActiveCampaigns") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CampaignHandler::GetActiveCampaigns") +} + +func (h *CampaignHandler) GetCampaignsForApp(c *gin.Context) { + ctx := c.Request.Context() + + response, err := h.campaignService.GetCampaignsForApp(ctx) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("CampaignHandler::GetCampaignsForApp -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.CampaignEntity, err.Error())}), "CampaignHandler::GetCampaignsForApp") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CampaignHandler::GetCampaignsForApp") +} diff --git a/internal/handler/reward_handler.go b/internal/handler/reward_handler.go new file mode 100644 index 0000000..4988667 --- /dev/null +++ b/internal/handler/reward_handler.go @@ -0,0 +1,206 @@ +package handler + +import ( + "strconv" + + "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" + "apskel-pos-be/internal/validator" + + "github.com/gin-gonic/gin" +) + +type RewardHandler struct { + rewardService service.RewardService + rewardValidator validator.RewardValidator +} + +func NewRewardHandler(rewardService service.RewardService, rewardValidator validator.RewardValidator) *RewardHandler { + return &RewardHandler{ + rewardService: rewardService, + rewardValidator: rewardValidator, + } +} + +func (h *RewardHandler) CreateReward(c *gin.Context) { + ctx := c.Request.Context() + + var req contract.CreateRewardRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::CreateReward -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::CreateReward") + return + } + + validationError, validationErrorCode := h.rewardValidator.ValidateCreateRewardRequest(&req) + if validationError != nil { + logger.FromContext(c.Request.Context()).WithError(validationError).Error("RewardHandler::CreateReward -> request validation failed") + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::CreateReward") + return + } + + response, err := h.rewardService.CreateReward(ctx, &req) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::CreateReward -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RewardEntity, err.Error())}), "RewardHandler::CreateReward") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "RewardHandler::CreateReward") +} + +func (h *RewardHandler) GetReward(c *gin.Context) { + ctx := c.Request.Context() + + idStr := c.Param("id") + if idStr == "" { + logger.FromContext(c.Request.Context()).Error("RewardHandler::GetReward -> missing ID parameter") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RewardEntity, "ID parameter is required") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::GetReward") + return + } + + response, err := h.rewardService.GetReward(ctx, idStr) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::GetReward -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RewardEntity, err.Error())}), "RewardHandler::GetReward") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "RewardHandler::GetReward") +} + +func (h *RewardHandler) ListRewards(c *gin.Context) { + ctx := c.Request.Context() + + var req contract.ListRewardsRequest + if err := c.ShouldBindQuery(&req); err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::ListRewards -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::ListRewards") + return + } + + validationError, validationErrorCode := h.rewardValidator.ValidateListRewardsRequest(&req) + if validationError != nil { + logger.FromContext(c.Request.Context()).WithError(validationError).Error("RewardHandler::ListRewards -> request validation failed") + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::ListRewards") + return + } + + response, err := h.rewardService.ListRewards(ctx, &req) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::ListRewards -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RewardEntity, err.Error())}), "RewardHandler::ListRewards") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "RewardHandler::ListRewards") +} + +func (h *RewardHandler) UpdateReward(c *gin.Context) { + ctx := c.Request.Context() + + var req contract.UpdateRewardRequest + if err := c.ShouldBindJSON(&req); err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::UpdateReward -> request binding failed") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::UpdateReward") + return + } + + validationError, validationErrorCode := h.rewardValidator.ValidateUpdateRewardRequest(&req) + if validationError != nil { + logger.FromContext(c.Request.Context()).WithError(validationError).Error("RewardHandler::UpdateReward -> request validation failed") + validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::UpdateReward") + return + } + + response, err := h.rewardService.UpdateReward(ctx, &req) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::UpdateReward -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RewardEntity, err.Error())}), "RewardHandler::UpdateReward") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "RewardHandler::UpdateReward") +} + +func (h *RewardHandler) DeleteReward(c *gin.Context) { + ctx := c.Request.Context() + + idStr := c.Param("id") + if idStr == "" { + logger.FromContext(c.Request.Context()).Error("RewardHandler::DeleteReward -> missing ID parameter") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RewardEntity, "ID parameter is required") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::DeleteReward") + return + } + + err := h.rewardService.DeleteReward(ctx, idStr) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::DeleteReward -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RewardEntity, err.Error())}), "RewardHandler::DeleteReward") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse("Reward deleted successfully"), "RewardHandler::DeleteReward") +} + +func (h *RewardHandler) UpdateRewardStock(c *gin.Context) { + ctx := c.Request.Context() + + idStr := c.Param("id") + if idStr == "" { + logger.FromContext(c.Request.Context()).Error("RewardHandler::UpdateRewardStock -> missing ID parameter") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RewardEntity, "ID parameter is required") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::UpdateRewardStock") + return + } + + stockStr := c.Param("stock") + stock, err := strconv.Atoi(stockStr) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::UpdateRewardStock -> invalid stock parameter") + validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.RewardEntity, "Invalid stock parameter") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::UpdateRewardStock") + return + } + + err = h.rewardService.UpdateRewardStock(ctx, idStr, stock) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::UpdateRewardStock -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RewardEntity, err.Error())}), "RewardHandler::UpdateRewardStock") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse("Reward stock updated successfully"), "RewardHandler::UpdateRewardStock") +} + +func (h *RewardHandler) GetRewardsByType(c *gin.Context) { + ctx := c.Request.Context() + + rewardType := c.Param("type") + if rewardType == "" { + logger.FromContext(c.Request.Context()).Error("RewardHandler::GetRewardsByType -> missing type parameter") + validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RewardEntity, "Type parameter is required") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::GetRewardsByType") + return + } + + response, err := h.rewardService.GetRewardsByType(ctx, rewardType) + if err != nil { + logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::GetRewardsByType -> service call failed") + util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RewardEntity, err.Error())}), "RewardHandler::GetRewardsByType") + return + } + + util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "RewardHandler::GetRewardsByType") +} diff --git a/internal/mappers/campaign_mapper.go b/internal/mappers/campaign_mapper.go new file mode 100644 index 0000000..6c5da7f --- /dev/null +++ b/internal/mappers/campaign_mapper.go @@ -0,0 +1,190 @@ +package mappers + +import ( + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" + + "github.com/google/uuid" +) + +func ToCampaignResponse(entity *entities.Campaign) *models.CampaignResponse { + if entity == nil { + return nil + } + + var rules []models.CampaignRuleResponse + if entity.Rules != nil { + for _, rule := range entity.Rules { + rules = append(rules, *ToCampaignRuleResponse(&rule)) + } + } + + return &models.CampaignResponse{ + ID: entity.ID, + Name: entity.Name, + Description: entity.Description, + Type: string(entity.Type), + StartDate: entity.StartDate, + EndDate: entity.EndDate, + IsActive: entity.IsActive, + ShowOnApp: entity.ShowOnApp, + Position: entity.Position, + Metadata: entity.Metadata, + Rules: rules, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, + } +} + +func ToCampaignRuleResponse(entity *entities.CampaignRule) *models.CampaignRuleResponse { + if entity == nil { + return nil + } + + var rewardSubtype *string + if entity.RewardSubtype != nil { + subtype := string(*entity.RewardSubtype) + rewardSubtype = &subtype + } + + return &models.CampaignRuleResponse{ + ID: entity.ID, + CampaignID: entity.CampaignID, + RuleType: string(entity.RuleType), + ConditionValue: entity.ConditionValue, + RewardType: string(entity.RewardType), + RewardValue: entity.RewardValue, + RewardSubtype: rewardSubtype, + RewardRefID: entity.RewardRefID, + Metadata: entity.Metadata, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, + } +} + +func ToCampaignEntity(request *contract.CreateCampaignRequest) *entities.Campaign { + if request == nil { + return nil + } + + var rules []entities.CampaignRule + if request.Rules != nil { + for _, rule := range request.Rules { + // For create request, we'll use a temporary UUID that will be set by the repository + rules = append(rules, *ToCampaignRuleEntity(&rule, uuid.Nil)) + } + } + + return &entities.Campaign{ + Name: request.Name, + Description: request.Description, + Type: entities.CampaignType(request.Type), + StartDate: request.StartDate, + EndDate: request.EndDate, + IsActive: request.IsActive, + ShowOnApp: request.ShowOnApp, + Position: request.Position, + Metadata: request.Metadata, + Rules: rules, + } +} + +func ToCampaignEntityFromUpdate(request *contract.UpdateCampaignRequest) *entities.Campaign { + if request == nil { + return nil + } + + var rules []entities.CampaignRule + if request.Rules != nil { + for _, rule := range request.Rules { + rules = append(rules, *ToCampaignRuleEntityFromUpdate(&rule, request.ID)) + } + } + + return &entities.Campaign{ + ID: request.ID, + Name: request.Name, + Description: request.Description, + Type: entities.CampaignType(request.Type), + StartDate: request.StartDate, + EndDate: request.EndDate, + IsActive: request.IsActive, + ShowOnApp: request.ShowOnApp, + Position: request.Position, + Metadata: request.Metadata, + Rules: rules, + } +} + +func ToCampaignRuleEntity(request *contract.CampaignRuleStruct, campaignID uuid.UUID) *entities.CampaignRule { + if request == nil { + return nil + } + + var rewardSubtype *entities.RewardSubtype + if request.RewardSubtype != nil { + subtype := entities.RewardSubtype(*request.RewardSubtype) + rewardSubtype = &subtype + } + + return &entities.CampaignRule{ + CampaignID: campaignID, + RuleType: entities.RuleType(request.RuleType), + ConditionValue: request.ConditionValue, + RewardType: entities.CampaignRewardType(request.RewardType), + RewardValue: request.RewardValue, + RewardSubtype: rewardSubtype, + RewardRefID: request.RewardRefID, + Metadata: request.Metadata, + } +} + +func ToCampaignRuleEntityFromUpdate(request *contract.CampaignRuleStruct, campaignID uuid.UUID) *entities.CampaignRule { + if request == nil { + return nil + } + + var rewardSubtype *entities.RewardSubtype + if request.RewardSubtype != nil { + subtype := entities.RewardSubtype(*request.RewardSubtype) + rewardSubtype = &subtype + } + + return &entities.CampaignRule{ + CampaignID: campaignID, + RuleType: entities.RuleType(request.RuleType), + ConditionValue: request.ConditionValue, + RewardType: entities.CampaignRewardType(request.RewardType), + RewardValue: request.RewardValue, + RewardSubtype: rewardSubtype, + RewardRefID: request.RewardRefID, + Metadata: request.Metadata, + } +} + +func ToCampaignResponseSlice(entities []entities.Campaign) []models.CampaignResponse { + if entities == nil { + return nil + } + + responses := make([]models.CampaignResponse, len(entities)) + for i, entity := range entities { + responses[i] = *ToCampaignResponse(&entity) + } + + return responses +} + +func ToCampaignRuleResponseSlice(entities []entities.CampaignRule) []models.CampaignRuleResponse { + if entities == nil { + return nil + } + + responses := make([]models.CampaignRuleResponse, len(entities)) + for i, entity := range entities { + responses[i] = *ToCampaignRuleResponse(&entity) + } + + return responses +} diff --git a/internal/mappers/reward_mapper.go b/internal/mappers/reward_mapper.go new file mode 100644 index 0000000..72c01f0 --- /dev/null +++ b/internal/mappers/reward_mapper.go @@ -0,0 +1,135 @@ +package mappers + +import ( + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/models" +) + +func ToRewardResponse(entity *entities.Reward) *models.RewardResponse { + if entity == nil { + return nil + } + + var tnc *models.TermsAndConditionsStruct + if entity.Tnc != nil { + var sections []models.TncSectionStruct + for _, section := range entity.Tnc.Sections { + sections = append(sections, models.TncSectionStruct{ + Title: section.Title, + Rules: section.Rules, + }) + } + tnc = &models.TermsAndConditionsStruct{ + Sections: sections, + ExpiryDays: entity.Tnc.ExpiryDays, + } + } + + var images *[]string + if entity.Images != nil { + images = entity.Images + } + + return &models.RewardResponse{ + ID: entity.ID, + Name: entity.Name, + RewardType: string(entity.RewardType), + CostPoints: entity.CostPoints, + Stock: entity.Stock, + MaxPerCustomer: entity.MaxPerCustomer, + Tnc: tnc, + Metadata: entity.Metadata, + Images: images, + CreatedAt: entity.CreatedAt, + UpdatedAt: entity.UpdatedAt, + } +} + +func ToRewardEntity(request *contract.CreateRewardRequest) *entities.Reward { + if request == nil { + return nil + } + + var tnc *entities.TermsAndConditions + if request.Tnc != nil { + var sections []entities.TncSection + for _, section := range request.Tnc.Sections { + sections = append(sections, entities.TncSection{ + Title: section.Title, + Rules: section.Rules, + }) + } + tnc = &entities.TermsAndConditions{ + Sections: sections, + ExpiryDays: request.Tnc.ExpiryDays, + } + } + + var images *[]string + if request.Images != nil { + images = request.Images + } + + return &entities.Reward{ + Name: request.Name, + RewardType: entities.RewardType(request.RewardType), + CostPoints: request.CostPoints, + Stock: request.Stock, + MaxPerCustomer: request.MaxPerCustomer, + Tnc: tnc, + Metadata: request.Metadata, + Images: images, + } +} + +func ToRewardEntityFromUpdate(request *contract.UpdateRewardRequest) *entities.Reward { + if request == nil { + return nil + } + + var tnc *entities.TermsAndConditions + if request.Tnc != nil { + var sections []entities.TncSection + for _, section := range request.Tnc.Sections { + sections = append(sections, entities.TncSection{ + Title: section.Title, + Rules: section.Rules, + }) + } + tnc = &entities.TermsAndConditions{ + Sections: sections, + ExpiryDays: request.Tnc.ExpiryDays, + } + } + + var images *[]string + if request.Images != nil { + images = request.Images + } + + return &entities.Reward{ + ID: request.ID, + Name: request.Name, + RewardType: entities.RewardType(request.RewardType), + CostPoints: request.CostPoints, + Stock: request.Stock, + MaxPerCustomer: request.MaxPerCustomer, + Tnc: tnc, + Metadata: request.Metadata, + Images: images, + } +} + +func ToRewardResponseSlice(entities []entities.Reward) []models.RewardResponse { + if entities == nil { + return nil + } + + responses := make([]models.RewardResponse, len(entities)) + for i, entity := range entities { + responses[i] = *ToRewardResponse(&entity) + } + + return responses +} diff --git a/internal/models/campaign.go b/internal/models/campaign.go new file mode 100644 index 0000000..da9a018 --- /dev/null +++ b/internal/models/campaign.go @@ -0,0 +1,132 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type CreateCampaignRequest struct { + Name string `json:"name" binding:"required,min=1,max=150"` + Description *string `json:"description,omitempty"` + Type string `json:"type" binding:"required,oneof=REWARD POINTS TOKENS MIXED"` + StartDate time.Time `json:"start_date" binding:"required"` + EndDate time.Time `json:"end_date" binding:"required"` + IsActive bool `json:"is_active"` + ShowOnApp bool `json:"show_on_app"` + Position int `json:"position" binding:"min=0"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` + Rules []CampaignRuleStruct `json:"rules" binding:"required,min=1"` +} + +type UpdateCampaignRequest struct { + ID uuid.UUID `json:"id" binding:"required"` + Name string `json:"name" binding:"required,min=1,max=150"` + Description *string `json:"description,omitempty"` + Type string `json:"type" binding:"required,oneof=REWARD POINTS TOKENS MIXED"` + StartDate time.Time `json:"start_date" binding:"required"` + EndDate time.Time `json:"end_date" binding:"required"` + IsActive bool `json:"is_active"` + ShowOnApp bool `json:"show_on_app"` + Position int `json:"position" binding:"min=0"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` + Rules []CampaignRuleStruct `json:"rules" binding:"required,min=1"` +} + +type ListCampaignsRequest struct { + Page int `form:"page" binding:"min=1"` + Limit int `form:"limit" binding:"min=1,max=100"` + Search string `form:"search"` + Type string `form:"type"` + IsActive *bool `form:"is_active"` + ShowOnApp *bool `form:"show_on_app"` + StartDate *time.Time `form:"start_date"` + EndDate *time.Time `form:"end_date"` +} + +type CampaignResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Type string `json:"type"` + StartDate time.Time `json:"start_date"` + EndDate time.Time `json:"end_date"` + IsActive bool `json:"is_active"` + ShowOnApp bool `json:"show_on_app"` + Position int `json:"position"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` + Rules []CampaignRuleResponse `json:"rules,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type CampaignRuleResponse struct { + ID uuid.UUID `json:"id"` + CampaignID uuid.UUID `json:"campaign_id"` + RuleType string `json:"rule_type"` + ConditionValue *string `json:"condition_value,omitempty"` + RewardType string `json:"reward_type"` + RewardValue *int64 `json:"reward_value,omitempty"` + RewardSubtype *string `json:"reward_subtype,omitempty"` + RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ListCampaignsResponse struct { + Campaigns []CampaignResponse `json:"campaigns"` + Total int64 `json:"total"` + Page int `json:"page"` + Limit int `json:"limit"` +} + +// Campaign Rule Models +type CreateCampaignRuleRequest struct { + CampaignID uuid.UUID `json:"campaign_id" binding:"required"` + RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"` + ConditionValue *string `json:"condition_value,omitempty"` + RewardType string `json:"reward_type" binding:"required,oneof=POINTS TOKENS REWARD"` + RewardValue *int64 `json:"reward_value,omitempty"` + RewardSubtype *string `json:"reward_subtype,omitempty"` + RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` +} + +type UpdateCampaignRuleRequest struct { + ID uuid.UUID `json:"id" binding:"required"` + CampaignID uuid.UUID `json:"campaign_id" binding:"required"` + RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"` + ConditionValue *string `json:"condition_value,omitempty"` + RewardType string `json:"reward_type" binding:"required,oneof=POINTS TOKENS REWARD"` + RewardValue *int64 `json:"reward_value,omitempty"` + RewardSubtype *string `json:"reward_subtype,omitempty"` + RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` +} + +type ListCampaignRulesRequest struct { + Page int `form:"page" binding:"min=1"` + Limit int `form:"limit" binding:"min=1,max=100"` + CampaignID string `form:"campaign_id"` + RuleType string `form:"rule_type"` + RewardType string `form:"reward_type"` +} + +type ListCampaignRulesResponse struct { + Rules []CampaignRuleResponse `json:"rules"` + Total int64 `json:"total"` + Page int `json:"page"` + Limit int `json:"limit"` +} + +// Helper structs +type CampaignRuleStruct struct { + RuleType string `json:"rule_type" binding:"required,oneof=TIER SPEND PRODUCT CATEGORY DAY LOCATION"` + ConditionValue *string `json:"condition_value,omitempty"` + RewardType string `json:"reward_type" binding:"required,oneof=POINTS TOKENS REWARD"` + RewardValue *int64 `json:"reward_value,omitempty"` + RewardSubtype *string `json:"reward_subtype,omitempty"` + RewardRefID *uuid.UUID `json:"reward_ref_id,omitempty"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` +} diff --git a/internal/models/reward.go b/internal/models/reward.go new file mode 100644 index 0000000..e2b29a5 --- /dev/null +++ b/internal/models/reward.go @@ -0,0 +1,71 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type CreateRewardRequest struct { + Name string `json:"name" binding:"required,min=1,max=150"` + RewardType string `json:"reward_type" binding:"required,oneof=VOUCHER PHYSICAL DIGITAL BALANCE"` + CostPoints int64 `json:"cost_points" binding:"required,min=1"` + Stock *int `json:"stock,omitempty"` + MaxPerCustomer int `json:"max_per_customer" binding:"min=1"` + Tnc *TermsAndConditionsStruct `json:"tnc,omitempty"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` + Images *[]string `json:"images,omitempty"` +} + +type UpdateRewardRequest struct { + ID uuid.UUID `json:"id" binding:"required"` + Name string `json:"name" binding:"required,min=1,max=150"` + RewardType string `json:"reward_type" binding:"required,oneof=VOUCHER PHYSICAL DIGITAL BALANCE"` + CostPoints int64 `json:"cost_points" binding:"required,min=1"` + Stock *int `json:"stock,omitempty"` + MaxPerCustomer int `json:"max_per_customer" binding:"min=1"` + Tnc *TermsAndConditionsStruct `json:"tnc,omitempty"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` + Images *[]string `json:"images,omitempty"` +} + +type ListRewardsRequest struct { + Page int `form:"page" binding:"min=1"` + Limit int `form:"limit" binding:"min=1,max=100"` + Search string `form:"search"` + RewardType string `form:"reward_type"` + MinPoints *int64 `form:"min_points"` + MaxPoints *int64 `form:"max_points"` + HasStock *bool `form:"has_stock"` +} + +type RewardResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + RewardType string `json:"reward_type"` + CostPoints int64 `json:"cost_points"` + Stock *int `json:"stock,omitempty"` + MaxPerCustomer int `json:"max_per_customer"` + Tnc *TermsAndConditionsStruct `json:"tnc,omitempty"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` + Images *[]string `json:"images,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type ListRewardsResponse struct { + Rewards []RewardResponse `json:"rewards"` + Total int64 `json:"total"` + Page int `json:"page"` + Limit int `json:"limit"` +} + +type TermsAndConditionsStruct struct { + Sections []TncSectionStruct `json:"sections"` + ExpiryDays int `json:"expiry_days"` +} + +type TncSectionStruct struct { + Title string `json:"title"` + Rules []string `json:"rules"` +} diff --git a/internal/processor/campaign_processor.go b/internal/processor/campaign_processor.go new file mode 100644 index 0000000..2823a26 --- /dev/null +++ b/internal/processor/campaign_processor.go @@ -0,0 +1,292 @@ +package processor + +import ( + "context" + "fmt" + + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/mappers" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +type CampaignProcessor interface { + CreateCampaign(ctx context.Context, req *contract.CreateCampaignRequest) (*models.CampaignResponse, error) + GetCampaign(ctx context.Context, id string) (*models.CampaignResponse, error) + ListCampaigns(ctx context.Context, req *contract.ListCampaignsRequest) (*models.ListCampaignsResponse, error) + UpdateCampaign(ctx context.Context, req *contract.UpdateCampaignRequest) (*models.CampaignResponse, error) + DeleteCampaign(ctx context.Context, id string) error + GetActiveCampaigns(ctx context.Context) ([]models.CampaignResponse, error) + GetCampaignsForApp(ctx context.Context) ([]models.CampaignResponse, error) +} + +type campaignProcessor struct { + campaignRepo repository.CampaignRepository +} + +func NewCampaignProcessor(campaignRepo repository.CampaignRepository) CampaignProcessor { + return &campaignProcessor{ + campaignRepo: campaignRepo, + } +} + +func (p *campaignProcessor) CreateCampaign(ctx context.Context, req *contract.CreateCampaignRequest) (*models.CampaignResponse, error) { + // Convert request to entity + entity := mappers.ToCampaignEntity(req) + if entity == nil { + return nil, fmt.Errorf("invalid request data") + } + + // Create in repository + if err := p.campaignRepo.Create(ctx, entity); err != nil { + return nil, fmt.Errorf("failed to create campaign: %w", err) + } + + // Convert entity to response + response := mappers.ToCampaignResponse(entity) + return response, nil +} + +func (p *campaignProcessor) GetCampaign(ctx context.Context, id string) (*models.CampaignResponse, error) { + // Get from repository + entity, err := p.campaignRepo.GetByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to get campaign: %w", err) + } + + if entity == nil { + return nil, fmt.Errorf("campaign not found") + } + + // Convert entity to response + response := mappers.ToCampaignResponse(entity) + return response, nil +} + +func (p *campaignProcessor) ListCampaigns(ctx context.Context, req *contract.ListCampaignsRequest) (*models.ListCampaignsResponse, error) { + // Convert request to entity request + entityReq := &entities.ListCampaignsRequest{ + Page: req.Page, + Limit: req.Limit, + Search: req.Search, + Type: req.Type, + IsActive: req.IsActive, + ShowOnApp: req.ShowOnApp, + StartDate: req.StartDate, + EndDate: req.EndDate, + } + + // Get from repository + entities, total, err := p.campaignRepo.List(ctx, entityReq) + if err != nil { + return nil, fmt.Errorf("failed to list campaigns: %w", err) + } + + // Convert entities to response + campaigns := mappers.ToCampaignResponseSlice(entities) + + response := &models.ListCampaignsResponse{ + Campaigns: campaigns, + Total: total, + Page: req.Page, + Limit: req.Limit, + } + + return response, nil +} + +func (p *campaignProcessor) UpdateCampaign(ctx context.Context, req *contract.UpdateCampaignRequest) (*models.CampaignResponse, error) { + // Convert request to entity + entity := mappers.ToCampaignEntityFromUpdate(req) + if entity == nil { + return nil, fmt.Errorf("invalid request data") + } + + // Update in repository + if err := p.campaignRepo.Update(ctx, entity); err != nil { + return nil, fmt.Errorf("failed to update campaign: %w", err) + } + + // Get updated entity + updatedEntity, err := p.campaignRepo.GetByID(ctx, req.ID.String()) + if err != nil { + return nil, fmt.Errorf("failed to get updated campaign: %w", err) + } + + // Convert entity to response + response := mappers.ToCampaignResponse(updatedEntity) + return response, nil +} + +func (p *campaignProcessor) DeleteCampaign(ctx context.Context, id string) error { + // Delete from repository + if err := p.campaignRepo.Delete(ctx, id); err != nil { + return fmt.Errorf("failed to delete campaign: %w", err) + } + + return nil +} + +func (p *campaignProcessor) GetActiveCampaigns(ctx context.Context) ([]models.CampaignResponse, error) { + // Get from repository + entities, err := p.campaignRepo.GetActiveCampaigns(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get active campaigns: %w", err) + } + + // Convert entities to response + campaigns := mappers.ToCampaignResponseSlice(entities) + return campaigns, nil +} + +func (p *campaignProcessor) GetCampaignsForApp(ctx context.Context) ([]models.CampaignResponse, error) { + // Get from repository + entities, err := p.campaignRepo.GetCampaignsForApp(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get campaigns for app: %w", err) + } + + // Convert entities to response + campaigns := mappers.ToCampaignResponseSlice(entities) + return campaigns, nil +} + +// Campaign Rule Processor +type CampaignRuleProcessor interface { + CreateCampaignRule(ctx context.Context, req *contract.CreateCampaignRuleRequest) (*models.CampaignRuleResponse, error) + GetCampaignRule(ctx context.Context, id string) (*models.CampaignRuleResponse, error) + ListCampaignRules(ctx context.Context, req *contract.ListCampaignRulesRequest) (*models.ListCampaignRulesResponse, error) + UpdateCampaignRule(ctx context.Context, req *contract.UpdateCampaignRuleRequest) (*models.CampaignRuleResponse, error) + DeleteCampaignRule(ctx context.Context, id string) error + GetCampaignRulesByCampaignID(ctx context.Context, campaignID string) ([]models.CampaignRuleResponse, error) +} + +type campaignRuleProcessor struct { + campaignRuleRepo repository.CampaignRuleRepository +} + +func NewCampaignRuleProcessor(campaignRuleRepo repository.CampaignRuleRepository) CampaignRuleProcessor { + return &campaignRuleProcessor{ + campaignRuleRepo: campaignRuleRepo, + } +} + +func (p *campaignRuleProcessor) CreateCampaignRule(ctx context.Context, req *contract.CreateCampaignRuleRequest) (*models.CampaignRuleResponse, error) { + // Convert request to entity + entity := &entities.CampaignRule{ + CampaignID: req.CampaignID, + RuleType: entities.RuleType(req.RuleType), + ConditionValue: req.ConditionValue, + RewardType: entities.CampaignRewardType(req.RewardType), + RewardValue: req.RewardValue, + RewardSubtype: (*entities.RewardSubtype)(req.RewardSubtype), + RewardRefID: req.RewardRefID, + Metadata: req.Metadata, + } + + // Create in repository + if err := p.campaignRuleRepo.Create(ctx, entity); err != nil { + return nil, fmt.Errorf("failed to create campaign rule: %w", err) + } + + // Convert entity to response + response := mappers.ToCampaignRuleResponse(entity) + return response, nil +} + +func (p *campaignRuleProcessor) GetCampaignRule(ctx context.Context, id string) (*models.CampaignRuleResponse, error) { + // Get from repository + entity, err := p.campaignRuleRepo.GetByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to get campaign rule: %w", err) + } + + if entity == nil { + return nil, fmt.Errorf("campaign rule not found") + } + + // Convert entity to response + response := mappers.ToCampaignRuleResponse(entity) + return response, nil +} + +func (p *campaignRuleProcessor) ListCampaignRules(ctx context.Context, req *contract.ListCampaignRulesRequest) (*models.ListCampaignRulesResponse, error) { + // Convert request to entity request + entityReq := &entities.ListCampaignRulesRequest{ + Page: req.Page, + Limit: req.Limit, + CampaignID: req.CampaignID, + RuleType: req.RuleType, + RewardType: req.RewardType, + } + + // Get from repository + entities, total, err := p.campaignRuleRepo.List(ctx, entityReq) + if err != nil { + return nil, fmt.Errorf("failed to list campaign rules: %w", err) + } + + // Convert entities to response + rules := mappers.ToCampaignRuleResponseSlice(entities) + + response := &models.ListCampaignRulesResponse{ + Rules: rules, + Total: total, + Page: req.Page, + Limit: req.Limit, + } + + return response, nil +} + +func (p *campaignRuleProcessor) UpdateCampaignRule(ctx context.Context, req *contract.UpdateCampaignRuleRequest) (*models.CampaignRuleResponse, error) { + // Convert request to entity + entity := &entities.CampaignRule{ + ID: req.ID, + CampaignID: req.CampaignID, + RuleType: entities.RuleType(req.RuleType), + ConditionValue: req.ConditionValue, + RewardType: entities.CampaignRewardType(req.RewardType), + RewardValue: req.RewardValue, + RewardSubtype: (*entities.RewardSubtype)(req.RewardSubtype), + RewardRefID: req.RewardRefID, + Metadata: req.Metadata, + } + + // Update in repository + if err := p.campaignRuleRepo.Update(ctx, entity); err != nil { + return nil, fmt.Errorf("failed to update campaign rule: %w", err) + } + + // Get updated entity + updatedEntity, err := p.campaignRuleRepo.GetByID(ctx, req.ID.String()) + if err != nil { + return nil, fmt.Errorf("failed to get updated campaign rule: %w", err) + } + + // Convert entity to response + response := mappers.ToCampaignRuleResponse(updatedEntity) + return response, nil +} + +func (p *campaignRuleProcessor) DeleteCampaignRule(ctx context.Context, id string) error { + // Delete from repository + if err := p.campaignRuleRepo.Delete(ctx, id); err != nil { + return fmt.Errorf("failed to delete campaign rule: %w", err) + } + + return nil +} + +func (p *campaignRuleProcessor) GetCampaignRulesByCampaignID(ctx context.Context, campaignID string) ([]models.CampaignRuleResponse, error) { + // Get from repository + entities, err := p.campaignRuleRepo.GetByCampaignID(ctx, campaignID) + if err != nil { + return nil, fmt.Errorf("failed to get campaign rules by campaign ID: %w", err) + } + + // Convert entities to response + rules := mappers.ToCampaignRuleResponseSlice(entities) + return rules, nil +} diff --git a/internal/processor/reward_processor.go b/internal/processor/reward_processor.go new file mode 100644 index 0000000..2f880f1 --- /dev/null +++ b/internal/processor/reward_processor.go @@ -0,0 +1,149 @@ +package processor + +import ( + "context" + "fmt" + + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/entities" + "apskel-pos-be/internal/mappers" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/repository" +) + +type RewardProcessor interface { + CreateReward(ctx context.Context, req *contract.CreateRewardRequest) (*models.RewardResponse, error) + GetReward(ctx context.Context, id string) (*models.RewardResponse, error) + ListRewards(ctx context.Context, req *contract.ListRewardsRequest) (*models.ListRewardsResponse, error) + UpdateReward(ctx context.Context, req *contract.UpdateRewardRequest) (*models.RewardResponse, error) + DeleteReward(ctx context.Context, id string) error + UpdateRewardStock(ctx context.Context, id string, newStock int) error + GetRewardsByType(ctx context.Context, rewardType string) ([]models.RewardResponse, error) +} + +type rewardProcessor struct { + rewardRepo repository.RewardRepository +} + +func NewRewardProcessor(rewardRepo repository.RewardRepository) RewardProcessor { + return &rewardProcessor{ + rewardRepo: rewardRepo, + } +} + +func (p *rewardProcessor) CreateReward(ctx context.Context, req *contract.CreateRewardRequest) (*models.RewardResponse, error) { + // Convert request to entity + entity := mappers.ToRewardEntity(req) + if entity == nil { + return nil, fmt.Errorf("invalid request data") + } + + // Create in repository + if err := p.rewardRepo.Create(ctx, entity); err != nil { + return nil, fmt.Errorf("failed to create reward: %w", err) + } + + // Convert entity to response + response := mappers.ToRewardResponse(entity) + return response, nil +} + +func (p *rewardProcessor) GetReward(ctx context.Context, id string) (*models.RewardResponse, error) { + // Get from repository + entity, err := p.rewardRepo.GetByID(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to get reward: %w", err) + } + + if entity == nil { + return nil, fmt.Errorf("reward not found") + } + + // Convert entity to response + response := mappers.ToRewardResponse(entity) + return response, nil +} + +func (p *rewardProcessor) ListRewards(ctx context.Context, req *contract.ListRewardsRequest) (*models.ListRewardsResponse, error) { + // Convert request to entity request + entityReq := &entities.ListRewardsRequest{ + Page: req.Page, + Limit: req.Limit, + Search: req.Search, + RewardType: req.RewardType, + MinPoints: req.MinPoints, + MaxPoints: req.MaxPoints, + HasStock: req.HasStock, + } + + // Get from repository + entities, total, err := p.rewardRepo.List(ctx, entityReq) + if err != nil { + return nil, fmt.Errorf("failed to list rewards: %w", err) + } + + // Convert entities to response + rewards := mappers.ToRewardResponseSlice(entities) + + response := &models.ListRewardsResponse{ + Rewards: rewards, + Total: total, + Page: req.Page, + Limit: req.Limit, + } + + return response, nil +} + +func (p *rewardProcessor) UpdateReward(ctx context.Context, req *contract.UpdateRewardRequest) (*models.RewardResponse, error) { + // Convert request to entity + entity := mappers.ToRewardEntityFromUpdate(req) + if entity == nil { + return nil, fmt.Errorf("invalid request data") + } + + // Update in repository + if err := p.rewardRepo.Update(ctx, entity); err != nil { + return nil, fmt.Errorf("failed to update reward: %w", err) + } + + // Get updated entity + updatedEntity, err := p.rewardRepo.GetByID(ctx, req.ID.String()) + if err != nil { + return nil, fmt.Errorf("failed to get updated reward: %w", err) + } + + // Convert entity to response + response := mappers.ToRewardResponse(updatedEntity) + return response, nil +} + +func (p *rewardProcessor) DeleteReward(ctx context.Context, id string) error { + // Delete from repository + if err := p.rewardRepo.Delete(ctx, id); err != nil { + return fmt.Errorf("failed to delete reward: %w", err) + } + + return nil +} + +func (p *rewardProcessor) UpdateRewardStock(ctx context.Context, id string, newStock int) error { + // Update stock in repository + if err := p.rewardRepo.UpdateStock(ctx, id, newStock); err != nil { + return fmt.Errorf("failed to update reward stock: %w", err) + } + + return nil +} + +func (p *rewardProcessor) GetRewardsByType(ctx context.Context, rewardType string) ([]models.RewardResponse, error) { + // Get from repository + entities, err := p.rewardRepo.GetByRewardType(ctx, entities.RewardType(rewardType)) + if err != nil { + return nil, fmt.Errorf("failed to get rewards by type: %w", err) + } + + // Convert entities to response + rewards := mappers.ToRewardResponseSlice(entities) + return rewards, nil +} diff --git a/internal/repository/campaign_repository.go b/internal/repository/campaign_repository.go new file mode 100644 index 0000000..71a8b1f --- /dev/null +++ b/internal/repository/campaign_repository.go @@ -0,0 +1,268 @@ +package repository + +import ( + "context" + "fmt" + + "apskel-pos-be/internal/entities" + + "gorm.io/gorm" +) + +type CampaignRepository interface { + Create(ctx context.Context, campaign *entities.Campaign) error + GetByID(ctx context.Context, id string) (*entities.Campaign, error) + List(ctx context.Context, req *entities.ListCampaignsRequest) ([]entities.Campaign, int64, error) + Update(ctx context.Context, campaign *entities.Campaign) error + Delete(ctx context.Context, id string) error + GetActiveCampaigns(ctx context.Context) ([]entities.Campaign, error) + GetCampaignsForApp(ctx context.Context) ([]entities.Campaign, error) +} + +type campaignRepository struct { + db *gorm.DB +} + +func NewCampaignRepository(db *gorm.DB) CampaignRepository { + return &campaignRepository{ + db: db, + } +} + +func (r *campaignRepository) Create(ctx context.Context, campaign *entities.Campaign) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // Create campaign first + if err := tx.Create(campaign).Error; err != nil { + return fmt.Errorf("failed to create campaign: %w", err) + } + + // Create campaign rules + if len(campaign.Rules) > 0 { + for i := range campaign.Rules { + campaign.Rules[i].CampaignID = campaign.ID + } + if err := tx.Create(&campaign.Rules).Error; err != nil { + return fmt.Errorf("failed to create campaign rules: %w", err) + } + } + + return nil + }) +} + +func (r *campaignRepository) GetByID(ctx context.Context, id string) (*entities.Campaign, error) { + var campaign entities.Campaign + if err := r.db.WithContext(ctx).Preload("Rules").Where("id = ?", id).First(&campaign).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("campaign not found") + } + return nil, fmt.Errorf("failed to get campaign: %w", err) + } + return &campaign, nil +} + +func (r *campaignRepository) List(ctx context.Context, req *entities.ListCampaignsRequest) ([]entities.Campaign, int64, error) { + var campaigns []entities.Campaign + var total int64 + + query := r.db.WithContext(ctx).Model(&entities.Campaign{}) + + // Apply filters + if req.Search != "" { + query = query.Where("name ILIKE ? OR description ILIKE ?", "%"+req.Search+"%", "%"+req.Search+"%") + } + + if req.Type != "" { + query = query.Where("type = ?", req.Type) + } + + if req.IsActive != nil { + query = query.Where("is_active = ?", *req.IsActive) + } + + if req.ShowOnApp != nil { + query = query.Where("show_on_app = ?", *req.ShowOnApp) + } + + if req.StartDate != nil { + query = query.Where("start_date >= ?", *req.StartDate) + } + + if req.EndDate != nil { + query = query.Where("end_date <= ?", *req.EndDate) + } + + // Count total records + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count campaigns: %w", err) + } + + // Apply pagination + if req.Page > 0 && req.Limit > 0 { + offset := (req.Page - 1) * req.Limit + query = query.Offset(offset).Limit(req.Limit) + } + + // Apply sorting + query = query.Order("position ASC, created_at DESC") + + if err := query.Preload("Rules").Find(&campaigns).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list campaigns: %w", err) + } + + return campaigns, total, nil +} + +func (r *campaignRepository) Update(ctx context.Context, campaign *entities.Campaign) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // Update campaign + if err := tx.Save(campaign).Error; err != nil { + return fmt.Errorf("failed to update campaign: %w", err) + } + + // Delete existing rules + if err := tx.Where("campaign_id = ?", campaign.ID).Delete(&entities.CampaignRule{}).Error; err != nil { + return fmt.Errorf("failed to delete existing campaign rules: %w", err) + } + + // Create new rules + if len(campaign.Rules) > 0 { + for i := range campaign.Rules { + campaign.Rules[i].CampaignID = campaign.ID + } + if err := tx.Create(&campaign.Rules).Error; err != nil { + return fmt.Errorf("failed to create campaign rules: %w", err) + } + } + + return nil + }) +} + +func (r *campaignRepository) Delete(ctx context.Context, id string) error { + return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // Delete campaign (rules will be deleted by CASCADE) + if err := tx.Where("id = ?", id).Delete(&entities.Campaign{}).Error; err != nil { + return fmt.Errorf("failed to delete campaign: %w", err) + } + return nil + }) +} + +func (r *campaignRepository) GetActiveCampaigns(ctx context.Context) ([]entities.Campaign, error) { + var campaigns []entities.Campaign + now := "now()" + if err := r.db.WithContext(ctx).Where("is_active = ? AND start_date <= ? AND end_date >= ?", true, now, now).Preload("Rules").Find(&campaigns).Error; err != nil { + return nil, fmt.Errorf("failed to get active campaigns: %w", err) + } + return campaigns, nil +} + +func (r *campaignRepository) GetCampaignsForApp(ctx context.Context) ([]entities.Campaign, error) { + var campaigns []entities.Campaign + now := "now()" + if err := r.db.WithContext(ctx).Where("is_active = ? AND show_on_app = ? AND start_date <= ? AND end_date >= ?", true, true, now, now).Preload("Rules").Order("position ASC").Find(&campaigns).Error; err != nil { + return nil, fmt.Errorf("failed to get campaigns for app: %w", err) + } + return campaigns, nil +} + +// Campaign Rule Repository +type CampaignRuleRepository interface { + Create(ctx context.Context, rule *entities.CampaignRule) error + GetByID(ctx context.Context, id string) (*entities.CampaignRule, error) + List(ctx context.Context, req *entities.ListCampaignRulesRequest) ([]entities.CampaignRule, int64, error) + Update(ctx context.Context, rule *entities.CampaignRule) error + Delete(ctx context.Context, id string) error + GetByCampaignID(ctx context.Context, campaignID string) ([]entities.CampaignRule, error) +} + +type campaignRuleRepository struct { + db *gorm.DB +} + +func NewCampaignRuleRepository(db *gorm.DB) CampaignRuleRepository { + return &campaignRuleRepository{ + db: db, + } +} + +func (r *campaignRuleRepository) Create(ctx context.Context, rule *entities.CampaignRule) error { + if err := r.db.WithContext(ctx).Create(rule).Error; err != nil { + return fmt.Errorf("failed to create campaign rule: %w", err) + } + return nil +} + +func (r *campaignRuleRepository) GetByID(ctx context.Context, id string) (*entities.CampaignRule, error) { + var rule entities.CampaignRule + if err := r.db.WithContext(ctx).Where("id = ?", id).First(&rule).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("campaign rule not found") + } + return nil, fmt.Errorf("failed to get campaign rule: %w", err) + } + return &rule, nil +} + +func (r *campaignRuleRepository) List(ctx context.Context, req *entities.ListCampaignRulesRequest) ([]entities.CampaignRule, int64, error) { + var rules []entities.CampaignRule + var total int64 + + query := r.db.WithContext(ctx).Model(&entities.CampaignRule{}) + + // Apply filters + if req.CampaignID != "" { + query = query.Where("campaign_id = ?", req.CampaignID) + } + + if req.RuleType != "" { + query = query.Where("rule_type = ?", req.RuleType) + } + + if req.RewardType != "" { + query = query.Where("reward_type = ?", req.RewardType) + } + + // Count total records + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count campaign rules: %w", err) + } + + // Apply pagination + if req.Page > 0 && req.Limit > 0 { + offset := (req.Page - 1) * req.Limit + query = query.Offset(offset).Limit(req.Limit) + } + + // Apply sorting + query = query.Order("created_at DESC") + + if err := query.Find(&rules).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list campaign rules: %w", err) + } + + return rules, total, nil +} + +func (r *campaignRuleRepository) Update(ctx context.Context, rule *entities.CampaignRule) error { + if err := r.db.WithContext(ctx).Save(rule).Error; err != nil { + return fmt.Errorf("failed to update campaign rule: %w", err) + } + return nil +} + +func (r *campaignRuleRepository) Delete(ctx context.Context, id string) error { + if err := r.db.WithContext(ctx).Where("id = ?", id).Delete(&entities.CampaignRule{}).Error; err != nil { + return fmt.Errorf("failed to delete campaign rule: %w", err) + } + return nil +} + +func (r *campaignRuleRepository) GetByCampaignID(ctx context.Context, campaignID string) ([]entities.CampaignRule, error) { + var rules []entities.CampaignRule + if err := r.db.WithContext(ctx).Where("campaign_id = ?", campaignID).Find(&rules).Error; err != nil { + return nil, fmt.Errorf("failed to get campaign rules by campaign ID: %w", err) + } + return rules, nil +} diff --git a/internal/repository/reward_repository.go b/internal/repository/reward_repository.go new file mode 100644 index 0000000..a143c58 --- /dev/null +++ b/internal/repository/reward_repository.go @@ -0,0 +1,129 @@ +package repository + +import ( + "context" + "fmt" + + "apskel-pos-be/internal/entities" + + "gorm.io/gorm" +) + +type RewardRepository interface { + Create(ctx context.Context, reward *entities.Reward) error + GetByID(ctx context.Context, id string) (*entities.Reward, error) + List(ctx context.Context, req *entities.ListRewardsRequest) ([]entities.Reward, int64, error) + Update(ctx context.Context, reward *entities.Reward) error + Delete(ctx context.Context, id string) error + UpdateStock(ctx context.Context, id string, newStock int) error + GetByRewardType(ctx context.Context, rewardType entities.RewardType) ([]entities.Reward, error) +} + +type rewardRepository struct { + db *gorm.DB +} + +func NewRewardRepository(db *gorm.DB) RewardRepository { + return &rewardRepository{ + db: db, + } +} + +func (r *rewardRepository) Create(ctx context.Context, reward *entities.Reward) error { + if err := r.db.WithContext(ctx).Create(reward).Error; err != nil { + return fmt.Errorf("failed to create reward: %w", err) + } + return nil +} + +func (r *rewardRepository) GetByID(ctx context.Context, id string) (*entities.Reward, error) { + var reward entities.Reward + if err := r.db.WithContext(ctx).Where("id = ?", id).First(&reward).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, fmt.Errorf("reward not found") + } + return nil, fmt.Errorf("failed to get reward: %w", err) + } + return &reward, nil +} + +func (r *rewardRepository) List(ctx context.Context, req *entities.ListRewardsRequest) ([]entities.Reward, int64, error) { + var rewards []entities.Reward + var total int64 + + query := r.db.WithContext(ctx).Model(&entities.Reward{}) + + // Apply filters + if req.Search != "" { + query = query.Where("name ILIKE ?", "%"+req.Search+"%") + } + + if req.RewardType != "" { + query = query.Where("reward_type = ?", req.RewardType) + } + + if req.MinPoints != nil { + query = query.Where("cost_points >= ?", *req.MinPoints) + } + + if req.MaxPoints != nil { + query = query.Where("cost_points <= ?", *req.MaxPoints) + } + + if req.HasStock != nil { + if *req.HasStock { + query = query.Where("stock > 0 OR stock IS NULL") + } else { + query = query.Where("stock <= 0") + } + } + + // Count total records + if err := query.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("failed to count rewards: %w", err) + } + + // Apply pagination + if req.Page > 0 && req.Limit > 0 { + offset := (req.Page - 1) * req.Limit + query = query.Offset(offset).Limit(req.Limit) + } + + // Apply sorting + query = query.Order("created_at DESC") + + if err := query.Find(&rewards).Error; err != nil { + return nil, 0, fmt.Errorf("failed to list rewards: %w", err) + } + + return rewards, total, nil +} + +func (r *rewardRepository) Update(ctx context.Context, reward *entities.Reward) error { + if err := r.db.WithContext(ctx).Save(reward).Error; err != nil { + return fmt.Errorf("failed to update reward: %w", err) + } + return nil +} + +func (r *rewardRepository) Delete(ctx context.Context, id string) error { + if err := r.db.WithContext(ctx).Where("id = ?", id).Delete(&entities.Reward{}).Error; err != nil { + return fmt.Errorf("failed to delete reward: %w", err) + } + return nil +} + +func (r *rewardRepository) UpdateStock(ctx context.Context, id string, newStock int) error { + if err := r.db.WithContext(ctx).Model(&entities.Reward{}).Where("id = ?", id).Update("stock", newStock).Error; err != nil { + return fmt.Errorf("failed to update reward stock: %w", err) + } + return nil +} + +func (r *rewardRepository) GetByRewardType(ctx context.Context, rewardType entities.RewardType) ([]entities.Reward, error) { + var rewards []entities.Reward + if err := r.db.WithContext(ctx).Where("reward_type = ?", rewardType).Find(&rewards).Error; err != nil { + return nil, fmt.Errorf("failed to get rewards by type: %w", err) + } + return rewards, nil +} diff --git a/internal/router/router.go b/internal/router/router.go index cd0c757..e30a5b9 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -41,6 +41,8 @@ type Router struct { accountHandler *handler.AccountHandler orderIngredientTransactionHandler *handler.OrderIngredientTransactionHandler gamificationHandler *handler.GamificationHandler + rewardHandler *handler.RewardHandler + campaignHandler *handler.CampaignHandler authMiddleware *middleware.AuthMiddleware } @@ -93,7 +95,11 @@ func NewRouter(cfg *config.Config, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, - gamificationValidator validator.GamificationValidator) *Router { + gamificationValidator validator.GamificationValidator, + rewardService service.RewardService, + rewardValidator validator.RewardValidator, + campaignService service.CampaignService, + campaignValidator validator.CampaignValidator) *Router { return &Router{ config: cfg, @@ -124,6 +130,8 @@ func NewRouter(cfg *config.Config, accountHandler: handler.NewAccountHandler(accountService, accountValidator), orderIngredientTransactionHandler: handler.NewOrderIngredientTransactionHandler(&orderIngredientTransactionService, orderIngredientTransactionValidator), gamificationHandler: handler.NewGamificationHandler(gamificationService, gamificationValidator), + rewardHandler: handler.NewRewardHandler(rewardService, rewardValidator), + campaignHandler: handler.NewCampaignHandler(campaignService, campaignValidator), authMiddleware: authMiddleware, } } @@ -510,6 +518,30 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { // omsetTracker.PUT("/:id", r.gamificationHandler.UpdateOmsetTracker) // omsetTracker.DELETE("/:id", r.gamificationHandler.DeleteOmsetTracker) //} + + // Rewards + rewards := gamification.Group("/rewards") + { + rewards.POST("", r.rewardHandler.CreateReward) + rewards.GET("", r.rewardHandler.ListRewards) + rewards.GET("/:id", r.rewardHandler.GetReward) + rewards.PUT("/:id", r.rewardHandler.UpdateReward) + rewards.DELETE("/:id", r.rewardHandler.DeleteReward) + rewards.PUT("/:id/stock/:stock", r.rewardHandler.UpdateRewardStock) + rewards.GET("/type/:type", r.rewardHandler.GetRewardsByType) + } + + // Campaigns + campaigns := gamification.Group("/campaigns") + { + campaigns.POST("", r.campaignHandler.CreateCampaign) + campaigns.GET("", r.campaignHandler.ListCampaigns) + campaigns.GET("/active", r.campaignHandler.GetActiveCampaigns) + campaigns.GET("/app", r.campaignHandler.GetCampaignsForApp) + campaigns.GET("/:id", r.campaignHandler.GetCampaign) + campaigns.PUT("/:id", r.campaignHandler.UpdateCampaign) + campaigns.DELETE("/:id", r.campaignHandler.DeleteCampaign) + } } outlets := protected.Group("/outlets") diff --git a/internal/service/campaign_service.go b/internal/service/campaign_service.go new file mode 100644 index 0000000..6fb4bcf --- /dev/null +++ b/internal/service/campaign_service.go @@ -0,0 +1,245 @@ +package service + +import ( + "context" + "fmt" + "time" + + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/processor" +) + +type CampaignService interface { + CreateCampaign(ctx context.Context, req *contract.CreateCampaignRequest) (*models.CampaignResponse, error) + GetCampaign(ctx context.Context, id string) (*models.CampaignResponse, error) + ListCampaigns(ctx context.Context, req *contract.ListCampaignsRequest) (*models.ListCampaignsResponse, error) + UpdateCampaign(ctx context.Context, req *contract.UpdateCampaignRequest) (*models.CampaignResponse, error) + DeleteCampaign(ctx context.Context, id string) error + GetActiveCampaigns(ctx context.Context) ([]models.CampaignResponse, error) + GetCampaignsForApp(ctx context.Context) ([]models.CampaignResponse, error) +} + +type campaignService struct { + campaignProcessor processor.CampaignProcessor +} + +func NewCampaignService(campaignProcessor processor.CampaignProcessor) CampaignService { + return &campaignService{ + campaignProcessor: campaignProcessor, + } +} + +func (s *campaignService) CreateCampaign(ctx context.Context, req *contract.CreateCampaignRequest) (*models.CampaignResponse, error) { + // Validate campaign type + if err := s.validateCampaignType(req.Type); err != nil { + return nil, fmt.Errorf("validation failed: %w", err) + } + + // Validate date range + if err := s.validateDateRange(req.StartDate, req.EndDate); err != nil { + return nil, fmt.Errorf("validation failed: %w", err) + } + + // Validate rules + if err := s.validateCampaignRules(req.Rules); err != nil { + return nil, fmt.Errorf("validation failed: %w", err) + } + + // Validate position + if req.Position < 0 { + return nil, fmt.Errorf("position cannot be negative") + } + + // Create campaign + response, err := s.campaignProcessor.CreateCampaign(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to create campaign: %w", err) + } + + return response, nil +} + +func (s *campaignService) GetCampaign(ctx context.Context, id string) (*models.CampaignResponse, error) { + if id == "" { + return nil, fmt.Errorf("campaign ID is required") + } + + response, err := s.campaignProcessor.GetCampaign(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to get campaign: %w", err) + } + + return response, nil +} + +func (s *campaignService) ListCampaigns(ctx context.Context, req *contract.ListCampaignsRequest) (*models.ListCampaignsResponse, error) { + // Set default pagination + if req.Page <= 0 { + req.Page = 1 + } + if req.Limit <= 0 { + req.Limit = 10 + } + if req.Limit > 100 { + req.Limit = 100 + } + + // Validate campaign type filter if provided + if req.Type != "" { + if err := s.validateCampaignType(req.Type); err != nil { + return nil, fmt.Errorf("validation failed: %w", err) + } + } + + response, err := s.campaignProcessor.ListCampaigns(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to list campaigns: %w", err) + } + + return response, nil +} + +func (s *campaignService) UpdateCampaign(ctx context.Context, req *contract.UpdateCampaignRequest) (*models.CampaignResponse, error) { + if req.ID.String() == "" { + return nil, fmt.Errorf("campaign ID is required") + } + + // Validate campaign type + if err := s.validateCampaignType(req.Type); err != nil { + return nil, fmt.Errorf("validation failed: %w", err) + } + + // Validate date range + if err := s.validateDateRange(req.StartDate, req.EndDate); err != nil { + return nil, fmt.Errorf("validation failed: %w", err) + } + + // Validate rules + if err := s.validateCampaignRules(req.Rules); err != nil { + return nil, fmt.Errorf("validation failed: %w", err) + } + + // Validate position + if req.Position < 0 { + return nil, fmt.Errorf("position cannot be negative") + } + + response, err := s.campaignProcessor.UpdateCampaign(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to update campaign: %w", err) + } + + return response, nil +} + +func (s *campaignService) DeleteCampaign(ctx context.Context, id string) error { + if id == "" { + return fmt.Errorf("campaign ID is required") + } + + err := s.campaignProcessor.DeleteCampaign(ctx, id) + if err != nil { + return fmt.Errorf("failed to delete campaign: %w", err) + } + + return nil +} + +func (s *campaignService) GetActiveCampaigns(ctx context.Context) ([]models.CampaignResponse, error) { + campaigns, err := s.campaignProcessor.GetActiveCampaigns(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get active campaigns: %w", err) + } + + return campaigns, nil +} + +func (s *campaignService) GetCampaignsForApp(ctx context.Context) ([]models.CampaignResponse, error) { + campaigns, err := s.campaignProcessor.GetCampaignsForApp(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get campaigns for app: %w", err) + } + + return campaigns, nil +} + +func (s *campaignService) validateCampaignType(campaignType string) error { + validTypes := []string{"REWARD", "POINTS", "TOKENS", "MIXED"} + for _, validType := range validTypes { + if campaignType == validType { + return nil + } + } + return fmt.Errorf("invalid campaign type: %s. Valid types are: REWARD, POINTS, TOKENS, MIXED", campaignType) +} + +func (s *campaignService) validateDateRange(startDate, endDate time.Time) error { + if startDate.IsZero() { + return fmt.Errorf("start date is required") + } + if endDate.IsZero() { + return fmt.Errorf("end date is required") + } + if startDate.After(endDate) { + return fmt.Errorf("start date cannot be after end date") + } + if startDate.Before(time.Now()) { + return fmt.Errorf("start date cannot be in the past") + } + return nil +} + +func (s *campaignService) validateCampaignRules(rules []contract.CampaignRuleStruct) error { + if len(rules) == 0 { + return fmt.Errorf("at least one rule is required") + } + + for i, rule := range rules { + if err := s.validateRuleType(rule.RuleType); err != nil { + return fmt.Errorf("invalid rule type in rule %d: %w", i+1, err) + } + + if err := s.validateRewardType(rule.RewardType); err != nil { + return fmt.Errorf("invalid reward type in rule %d: %w", i+1, err) + } + + // Validate reward value based on reward type + if rule.RewardType == "POINTS" || rule.RewardType == "TOKENS" { + if rule.RewardValue == nil || *rule.RewardValue <= 0 { + return fmt.Errorf("reward value must be positive for %s type in rule %d", rule.RewardType, i+1) + } + } + + // Validate reward reference ID for REWARD type + if rule.RewardType == "REWARD" { + if rule.RewardRefID == nil { + return fmt.Errorf("reward reference ID is required for REWARD type in rule %d", i+1) + } + } + + _ = i // Avoid unused variable warning + } + + return nil +} + +func (s *campaignService) validateRuleType(ruleType string) error { + validTypes := []string{"TIER", "SPEND", "PRODUCT", "CATEGORY", "DAY", "LOCATION"} + for _, validType := range validTypes { + if ruleType == validType { + return nil + } + } + return fmt.Errorf("invalid rule type: %s. Valid types are: TIER, SPEND, PRODUCT, CATEGORY, DAY, LOCATION", ruleType) +} + +func (s *campaignService) validateRewardType(rewardType string) error { + validTypes := []string{"POINTS", "TOKENS", "REWARD"} + for _, validType := range validTypes { + if rewardType == validType { + return nil + } + } + return fmt.Errorf("invalid reward type: %s. Valid types are: POINTS, TOKENS, REWARD", rewardType) +} diff --git a/internal/service/reward_service.go b/internal/service/reward_service.go new file mode 100644 index 0000000..e1960b6 --- /dev/null +++ b/internal/service/reward_service.go @@ -0,0 +1,198 @@ +package service + +import ( + "context" + "fmt" + + "apskel-pos-be/internal/contract" + "apskel-pos-be/internal/models" + "apskel-pos-be/internal/processor" +) + +type RewardService interface { + CreateReward(ctx context.Context, req *contract.CreateRewardRequest) (*models.RewardResponse, error) + GetReward(ctx context.Context, id string) (*models.RewardResponse, error) + ListRewards(ctx context.Context, req *contract.ListRewardsRequest) (*models.ListRewardsResponse, error) + UpdateReward(ctx context.Context, req *contract.UpdateRewardRequest) (*models.RewardResponse, error) + DeleteReward(ctx context.Context, id string) error + UpdateRewardStock(ctx context.Context, id string, newStock int) error + GetRewardsByType(ctx context.Context, rewardType string) ([]models.RewardResponse, error) +} + +type rewardService struct { + rewardProcessor processor.RewardProcessor +} + +func NewRewardService(rewardProcessor processor.RewardProcessor) RewardService { + return &rewardService{ + rewardProcessor: rewardProcessor, + } +} + +func (s *rewardService) CreateReward(ctx context.Context, req *contract.CreateRewardRequest) (*models.RewardResponse, error) { + // Validate reward type + if err := s.validateRewardType(req.RewardType); err != nil { + return nil, fmt.Errorf("validation failed: %w", err) + } + + // Validate cost points + if req.CostPoints <= 0 { + return nil, fmt.Errorf("cost points must be greater than 0") + } + + // Validate stock for physical rewards + if req.RewardType == "PHYSICAL" && req.Stock != nil && *req.Stock <= 0 { + return nil, fmt.Errorf("physical rewards must have positive stock or unlimited stock") + } + + // Validate max per customer + if req.MaxPerCustomer <= 0 { + return nil, fmt.Errorf("max per customer must be greater than 0") + } + + // Create reward + response, err := s.rewardProcessor.CreateReward(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to create reward: %w", err) + } + + return response, nil +} + +func (s *rewardService) GetReward(ctx context.Context, id string) (*models.RewardResponse, error) { + if id == "" { + return nil, fmt.Errorf("reward ID is required") + } + + response, err := s.rewardProcessor.GetReward(ctx, id) + if err != nil { + return nil, fmt.Errorf("failed to get reward: %w", err) + } + + return response, nil +} + +func (s *rewardService) ListRewards(ctx context.Context, req *contract.ListRewardsRequest) (*models.ListRewardsResponse, error) { + // Set default pagination + if req.Page <= 0 { + req.Page = 1 + } + if req.Limit <= 0 { + req.Limit = 10 + } + if req.Limit > 100 { + req.Limit = 100 + } + + // Validate reward type filter if provided + if req.RewardType != "" { + if err := s.validateRewardType(req.RewardType); err != nil { + return nil, fmt.Errorf("validation failed: %w", err) + } + } + + // Validate points range if provided + if req.MinPoints != nil && req.MaxPoints != nil { + if *req.MinPoints > *req.MaxPoints { + return nil, fmt.Errorf("min points cannot be greater than max points") + } + } + + response, err := s.rewardProcessor.ListRewards(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to list rewards: %w", err) + } + + return response, nil +} + +func (s *rewardService) UpdateReward(ctx context.Context, req *contract.UpdateRewardRequest) (*models.RewardResponse, error) { + if req.ID.String() == "" { + return nil, fmt.Errorf("reward ID is required") + } + + // Validate reward type + if err := s.validateRewardType(req.RewardType); err != nil { + return nil, fmt.Errorf("validation failed: %w", err) + } + + // Validate cost points + if req.CostPoints <= 0 { + return nil, fmt.Errorf("cost points must be greater than 0") + } + + // Validate stock for physical rewards + if req.RewardType == "PHYSICAL" && req.Stock != nil && *req.Stock <= 0 { + return nil, fmt.Errorf("physical rewards must have positive stock or unlimited stock") + } + + // Validate max per customer + if req.MaxPerCustomer <= 0 { + return nil, fmt.Errorf("max per customer must be greater than 0") + } + + response, err := s.rewardProcessor.UpdateReward(ctx, req) + if err != nil { + return nil, fmt.Errorf("failed to update reward: %w", err) + } + + return response, nil +} + +func (s *rewardService) DeleteReward(ctx context.Context, id string) error { + if id == "" { + return fmt.Errorf("reward ID is required") + } + + err := s.rewardProcessor.DeleteReward(ctx, id) + if err != nil { + return fmt.Errorf("failed to delete reward: %w", err) + } + + return nil +} + +func (s *rewardService) UpdateRewardStock(ctx context.Context, id string, newStock int) error { + if id == "" { + return fmt.Errorf("reward ID is required") + } + + if newStock < 0 { + return fmt.Errorf("stock cannot be negative") + } + + err := s.rewardProcessor.UpdateRewardStock(ctx, id, newStock) + if err != nil { + return fmt.Errorf("failed to update reward stock: %w", err) + } + + return nil +} + +func (s *rewardService) GetRewardsByType(ctx context.Context, rewardType string) ([]models.RewardResponse, error) { + if rewardType == "" { + return nil, fmt.Errorf("reward type is required") + } + + // Validate reward type + if err := s.validateRewardType(rewardType); err != nil { + return nil, fmt.Errorf("validation failed: %w", err) + } + + rewards, err := s.rewardProcessor.GetRewardsByType(ctx, rewardType) + if err != nil { + return nil, fmt.Errorf("failed to get rewards by type: %w", err) + } + + return rewards, nil +} + +func (s *rewardService) validateRewardType(rewardType string) error { + validTypes := []string{"VOUCHER", "PHYSICAL", "DIGITAL", "BALANCE"} + for _, validType := range validTypes { + if rewardType == validType { + return nil + } + } + return fmt.Errorf("invalid reward type: %s. Valid types are: VOUCHER, PHYSICAL, DIGITAL, BALANCE", rewardType) +} diff --git a/internal/validator/campaign_validator.go b/internal/validator/campaign_validator.go new file mode 100644 index 0000000..1658e8f --- /dev/null +++ b/internal/validator/campaign_validator.go @@ -0,0 +1,336 @@ +package validator + +import ( + "errors" + "strings" + "time" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" +) + +type CampaignValidator interface { + ValidateCreateCampaignRequest(req *contract.CreateCampaignRequest) (error, string) + ValidateUpdateCampaignRequest(req *contract.UpdateCampaignRequest) (error, string) + ValidateListCampaignsRequest(req *contract.ListCampaignsRequest) (error, string) + ValidateGetCampaignRequest(req *contract.GetCampaignRequest) (error, string) + ValidateDeleteCampaignRequest(req *contract.DeleteCampaignRequest) (error, string) + ValidateCreateCampaignRuleRequest(req *contract.CreateCampaignRuleRequest) (error, string) + ValidateUpdateCampaignRuleRequest(req *contract.UpdateCampaignRuleRequest) (error, string) + ValidateListCampaignRulesRequest(req *contract.ListCampaignRulesRequest) (error, string) + ValidateGetCampaignRuleRequest(req *contract.GetCampaignRuleRequest) (error, string) + ValidateDeleteCampaignRuleRequest(req *contract.DeleteCampaignRuleRequest) (error, string) +} + +type CampaignValidatorImpl struct{} + +func NewCampaignValidator() CampaignValidator { + return &CampaignValidatorImpl{} +} + +func (v *CampaignValidatorImpl) ValidateCreateCampaignRequest(req *contract.CreateCampaignRequest) (error, string) { + if req == nil { + return errors.New("request is required"), constants.ValidationErrorCode + } + + // Validate name + if strings.TrimSpace(req.Name) == "" { + return errors.New("campaign name is required"), constants.ValidationErrorCode + } + if len(req.Name) > 150 { + return errors.New("campaign name cannot exceed 150 characters"), constants.ValidationErrorCode + } + + // Validate campaign type + if !v.isValidCampaignType(req.Type) { + return errors.New("invalid campaign type. Valid types are: REWARD, POINTS, TOKENS, MIXED"), constants.ValidationErrorCode + } + + // Validate date range + if err := v.validateDateRange(req.StartDate, req.EndDate); err != nil { + return err, constants.ValidationErrorCode + } + + // Validate position + if req.Position < 0 { + return errors.New("position cannot be negative"), constants.ValidationErrorCode + } + + // Validate rules + if len(req.Rules) == 0 { + return errors.New("at least one rule is required"), constants.ValidationErrorCode + } + + for i, rule := range req.Rules { + if err := v.validateCampaignRule(&rule, i+1); err != nil { + return err, constants.ValidationErrorCode + } + } + + return nil, "" +} + +func (v *CampaignValidatorImpl) ValidateUpdateCampaignRequest(req *contract.UpdateCampaignRequest) (error, string) { + if req == nil { + return errors.New("request is required"), constants.ValidationErrorCode + } + + // Validate ID + if req.ID.String() == "" { + return errors.New("campaign ID is required"), constants.ValidationErrorCode + } + + // Validate name + if strings.TrimSpace(req.Name) == "" { + return errors.New("campaign name is required"), constants.ValidationErrorCode + } + if len(req.Name) > 150 { + return errors.New("campaign name cannot exceed 150 characters"), constants.ValidationErrorCode + } + + // Validate campaign type + if !v.isValidCampaignType(req.Type) { + return errors.New("invalid campaign type. Valid types are: REWARD, POINTS, TOKENS, MIXED"), constants.ValidationErrorCode + } + + // Validate date range + if err := v.validateDateRange(req.StartDate, req.EndDate); err != nil { + return err, constants.ValidationErrorCode + } + + // Validate position + if req.Position < 0 { + return errors.New("position cannot be negative"), constants.ValidationErrorCode + } + + // Validate rules + if len(req.Rules) == 0 { + return errors.New("at least one rule is required"), constants.ValidationErrorCode + } + + for i, rule := range req.Rules { + if err := v.validateCampaignRule(&rule, i+1); err != nil { + return err, constants.ValidationErrorCode + } + } + + return nil, "" +} + +func (v *CampaignValidatorImpl) ValidateListCampaignsRequest(req *contract.ListCampaignsRequest) (error, string) { + if req == nil { + return errors.New("request is required"), constants.ValidationErrorCode + } + + // Validate pagination + if req.Page < 1 { + return errors.New("page must be greater than 0"), constants.ValidationErrorCode + } + if req.Limit < 1 { + return errors.New("limit must be greater than 0"), constants.ValidationErrorCode + } + if req.Limit > 100 { + return errors.New("limit cannot exceed 100"), constants.ValidationErrorCode + } + + // Validate campaign type filter if provided + if req.Type != "" && !v.isValidCampaignType(req.Type) { + return errors.New("invalid campaign type filter. Valid types are: REWARD, POINTS, TOKENS, MIXED"), constants.ValidationErrorCode + } + + return nil, "" +} + +func (v *CampaignValidatorImpl) ValidateGetCampaignRequest(req *contract.GetCampaignRequest) (error, string) { + if req == nil { + return errors.New("request is required"), constants.ValidationErrorCode + } + + if req.ID.String() == "" { + return errors.New("campaign ID is required"), constants.ValidationErrorCode + } + + return nil, "" +} + +func (v *CampaignValidatorImpl) ValidateDeleteCampaignRequest(req *contract.DeleteCampaignRequest) (error, string) { + if req == nil { + return errors.New("request is required"), constants.ValidationErrorCode + } + + if req.ID.String() == "" { + return errors.New("campaign ID is required"), constants.ValidationErrorCode + } + + return nil, "" +} + +func (v *CampaignValidatorImpl) ValidateCreateCampaignRuleRequest(req *contract.CreateCampaignRuleRequest) (error, string) { + if req == nil { + return errors.New("request is required"), constants.ValidationErrorCode + } + + // Validate campaign ID + if req.CampaignID.String() == "" { + return errors.New("campaign ID is required"), constants.ValidationErrorCode + } + + // Validate rule + if err := v.validateCampaignRule(&contract.CampaignRuleStruct{ + RuleType: req.RuleType, + ConditionValue: req.ConditionValue, + RewardType: req.RewardType, + RewardValue: req.RewardValue, + RewardSubtype: req.RewardSubtype, + RewardRefID: req.RewardRefID, + Metadata: req.Metadata, + }, 1); err != nil { + return err, constants.ValidationErrorCode + } + + return nil, "" +} + +func (v *CampaignValidatorImpl) ValidateUpdateCampaignRuleRequest(req *contract.UpdateCampaignRuleRequest) (error, string) { + if req == nil { + return errors.New("request is required"), constants.ValidationErrorCode + } + + // Validate ID + if req.ID.String() == "" { + return errors.New("campaign rule ID is required"), constants.ValidationErrorCode + } + + // Validate campaign ID + if req.CampaignID.String() == "" { + return errors.New("campaign ID is required"), constants.ValidationErrorCode + } + + // Validate rule + if err := v.validateCampaignRule(&contract.CampaignRuleStruct{ + RuleType: req.RuleType, + ConditionValue: req.ConditionValue, + RewardType: req.RewardType, + RewardValue: req.RewardValue, + RewardSubtype: req.RewardSubtype, + RewardRefID: req.RewardRefID, + Metadata: req.Metadata, + }, 1); err != nil { + return err, constants.ValidationErrorCode + } + + return nil, "" +} + +func (v *CampaignValidatorImpl) ValidateListCampaignRulesRequest(req *contract.ListCampaignRulesRequest) (error, string) { + if req == nil { + return errors.New("request is required"), constants.ValidationErrorCode + } + + // Validate pagination + if req.Page < 1 { + return errors.New("page must be greater than 0"), constants.ValidationErrorCode + } + if req.Limit < 1 { + return errors.New("limit must be greater than 0"), constants.ValidationErrorCode + } + if req.Limit > 100 { + return errors.New("limit cannot exceed 100"), constants.ValidationErrorCode + } + + // Validate rule type filter if provided + if req.RuleType != "" && !v.isValidRuleType(req.RuleType) { + return errors.New("invalid rule type filter. Valid types are: TIER, SPEND, PRODUCT, CATEGORY, DAY, LOCATION"), constants.ValidationErrorCode + } + + // Validate reward type filter if provided + if req.RewardType != "" && !v.isValidRewardType(req.RewardType) { + return errors.New("invalid reward type filter. Valid types are: POINTS, TOKENS, REWARD"), constants.ValidationErrorCode + } + + return nil, "" +} + +func (v *CampaignValidatorImpl) ValidateGetCampaignRuleRequest(req *contract.GetCampaignRuleRequest) (error, string) { + if req == nil { + return errors.New("request is required"), constants.ValidationErrorCode + } + + if req.ID.String() == "" { + return errors.New("campaign rule ID is required"), constants.ValidationErrorCode + } + + return nil, "" +} + +func (v *CampaignValidatorImpl) ValidateDeleteCampaignRuleRequest(req *contract.DeleteCampaignRuleRequest) (error, string) { + if req == nil { + return errors.New("request is required"), constants.ValidationErrorCode + } + + if req.ID.String() == "" { + return errors.New("campaign rule ID is required"), constants.ValidationErrorCode + } + + return nil, "" +} + +func (v *CampaignValidatorImpl) isValidCampaignType(campaignType string) bool { + validTypes := []string{"REWARD", "POINTS", "TOKENS", "MIXED"} + return contains(validTypes, campaignType) +} + +func (v *CampaignValidatorImpl) isValidRuleType(ruleType string) bool { + validTypes := []string{"TIER", "SPEND", "PRODUCT", "CATEGORY", "DAY", "LOCATION"} + return contains(validTypes, ruleType) +} + +func (v *CampaignValidatorImpl) isValidRewardType(rewardType string) bool { + validTypes := []string{"POINTS", "TOKENS", "REWARD"} + return contains(validTypes, rewardType) +} + +func (v *CampaignValidatorImpl) validateDateRange(startDate, endDate time.Time) error { + if startDate.IsZero() { + return errors.New("start date is required") + } + if endDate.IsZero() { + return errors.New("end date is required") + } + if startDate.After(endDate) { + return errors.New("start date cannot be after end date") + } + return nil +} + +func (v *CampaignValidatorImpl) validateCampaignRule(rule *contract.CampaignRuleStruct, ruleNumber int) error { + if rule == nil { + return errors.New("rule is required") + } + + // Validate rule type + if !v.isValidRuleType(rule.RuleType) { + return errors.New("invalid rule type in rule " + string(rune(ruleNumber)) + ". Valid types are: TIER, SPEND, PRODUCT, CATEGORY, DAY, LOCATION") + } + + // Validate reward type + if !v.isValidRewardType(rule.RewardType) { + return errors.New("invalid reward type in rule " + string(rune(ruleNumber)) + ". Valid types are: POINTS, TOKENS, REWARD") + } + + // Validate reward value based on reward type + if rule.RewardType == "POINTS" || rule.RewardType == "TOKENS" { + if rule.RewardValue == nil || *rule.RewardValue <= 0 { + return errors.New("reward value must be positive for " + rule.RewardType + " type in rule " + string(rune(ruleNumber))) + } + } + + // Validate reward reference ID for REWARD type + if rule.RewardType == "REWARD" { + if rule.RewardRefID == nil { + return errors.New("reward reference ID is required for REWARD type in rule " + string(rune(ruleNumber))) + } + } + + return nil +} diff --git a/internal/validator/reward_validator.go b/internal/validator/reward_validator.go new file mode 100644 index 0000000..cb23cdd --- /dev/null +++ b/internal/validator/reward_validator.go @@ -0,0 +1,263 @@ +package validator + +import ( + "errors" + "regexp" + "strings" + + "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/contract" +) + +type RewardValidator interface { + ValidateCreateRewardRequest(req *contract.CreateRewardRequest) (error, string) + ValidateUpdateRewardRequest(req *contract.UpdateRewardRequest) (error, string) + ValidateListRewardsRequest(req *contract.ListRewardsRequest) (error, string) + ValidateGetRewardRequest(req *contract.GetRewardRequest) (error, string) + ValidateDeleteRewardRequest(req *contract.DeleteRewardRequest) (error, string) +} + +type RewardValidatorImpl struct{} + +func NewRewardValidator() RewardValidator { + return &RewardValidatorImpl{} +} + +func (v *RewardValidatorImpl) ValidateCreateRewardRequest(req *contract.CreateRewardRequest) (error, string) { + if req == nil { + return errors.New("request is required"), constants.ValidationErrorCode + } + + // Validate name + if strings.TrimSpace(req.Name) == "" { + return errors.New("reward name is required"), constants.ValidationErrorCode + } + if len(req.Name) > 150 { + return errors.New("reward name cannot exceed 150 characters"), constants.ValidationErrorCode + } + + // Validate reward type + if !v.isValidRewardType(req.RewardType) { + return errors.New("invalid reward type. Valid types are: VOUCHER, PHYSICAL, DIGITAL, BALANCE"), constants.ValidationErrorCode + } + + // Validate cost points + if req.CostPoints <= 0 { + return errors.New("cost points must be greater than 0"), constants.ValidationErrorCode + } + + // Validate stock + if req.Stock != nil && *req.Stock < 0 { + return errors.New("stock cannot be negative"), constants.ValidationErrorCode + } + + // Validate max per customer + if req.MaxPerCustomer <= 0 { + return errors.New("max per customer must be greater than 0"), constants.ValidationErrorCode + } + + // Validate TNC if provided + if req.Tnc != nil { + if err := v.validateTermsAndConditions(req.Tnc); err != nil { + return err, constants.ValidationErrorCode + } + } + + // Validate images if provided + if req.Images != nil { + if err := v.validateImages(*req.Images); err != nil { + return err, constants.ValidationErrorCode + } + } + + return nil, "" +} + +func (v *RewardValidatorImpl) ValidateUpdateRewardRequest(req *contract.UpdateRewardRequest) (error, string) { + if req == nil { + return errors.New("request is required"), constants.ValidationErrorCode + } + + // Validate ID + if req.ID.String() == "" { + return errors.New("reward ID is required"), constants.ValidationErrorCode + } + + // Validate name + if strings.TrimSpace(req.Name) == "" { + return errors.New("reward name is required"), constants.ValidationErrorCode + } + if len(req.Name) > 150 { + return errors.New("reward name cannot exceed 150 characters"), constants.ValidationErrorCode + } + + // Validate reward type + if !v.isValidRewardType(req.RewardType) { + return errors.New("invalid reward type. Valid types are: VOUCHER, PHYSICAL, DIGITAL, BALANCE"), constants.ValidationErrorCode + } + + // Validate cost points + if req.CostPoints <= 0 { + return errors.New("cost points must be greater than 0"), constants.ValidationErrorCode + } + + // Validate stock + if req.Stock != nil && *req.Stock < 0 { + return errors.New("stock cannot be negative"), constants.ValidationErrorCode + } + + // Validate max per customer + if req.MaxPerCustomer <= 0 { + return errors.New("max per customer must be greater than 0"), constants.ValidationErrorCode + } + + // Validate TNC if provided + if req.Tnc != nil { + if err := v.validateTermsAndConditions(req.Tnc); err != nil { + return err, constants.ValidationErrorCode + } + } + + // Validate images if provided + if req.Images != nil { + if err := v.validateImages(*req.Images); err != nil { + return err, constants.ValidationErrorCode + } + } + + return nil, "" +} + +func (v *RewardValidatorImpl) ValidateListRewardsRequest(req *contract.ListRewardsRequest) (error, string) { + if req == nil { + return errors.New("request is required"), constants.ValidationErrorCode + } + + // Validate pagination + if req.Page < 1 { + return errors.New("page must be greater than 0"), constants.ValidationErrorCode + } + if req.Limit < 1 { + return errors.New("limit must be greater than 0"), constants.ValidationErrorCode + } + if req.Limit > 100 { + return errors.New("limit cannot exceed 100"), constants.ValidationErrorCode + } + + // Validate reward type filter if provided + if req.RewardType != "" && !v.isValidRewardType(req.RewardType) { + return errors.New("invalid reward type filter. Valid types are: VOUCHER, PHYSICAL, DIGITAL, BALANCE"), constants.ValidationErrorCode + } + + // Validate points range if provided + if req.MinPoints != nil && req.MaxPoints != nil { + if *req.MinPoints > *req.MaxPoints { + return errors.New("min points cannot be greater than max points"), constants.ValidationErrorCode + } + if *req.MinPoints < 0 { + return errors.New("min points cannot be negative"), constants.ValidationErrorCode + } + if *req.MaxPoints < 0 { + return errors.New("max points cannot be negative"), constants.ValidationErrorCode + } + } + + return nil, "" +} + +func (v *RewardValidatorImpl) ValidateGetRewardRequest(req *contract.GetRewardRequest) (error, string) { + if req == nil { + return errors.New("request is required"), constants.ValidationErrorCode + } + + if req.ID.String() == "" { + return errors.New("reward ID is required"), constants.ValidationErrorCode + } + + return nil, "" +} + +func (v *RewardValidatorImpl) ValidateDeleteRewardRequest(req *contract.DeleteRewardRequest) (error, string) { + if req == nil { + return errors.New("request is required"), constants.ValidationErrorCode + } + + if req.ID.String() == "" { + return errors.New("reward ID is required"), constants.ValidationErrorCode + } + + return nil, "" +} + +func (v *RewardValidatorImpl) isValidRewardType(rewardType string) bool { + validTypes := []string{"VOUCHER", "PHYSICAL", "DIGITAL", "BALANCE"} + return contains(validTypes, rewardType) +} + +func (v *RewardValidatorImpl) validateTermsAndConditions(tnc *contract.TermsAndConditionsStruct) error { + if tnc == nil { + return nil + } + + // Validate expiry days + if tnc.ExpiryDays < 0 { + return errors.New("expiry days cannot be negative") + } + + // Validate sections + if len(tnc.Sections) == 0 { + return errors.New("terms and conditions must have at least one section") + } + + for i, section := range tnc.Sections { + if strings.TrimSpace(section.Title) == "" { + return errors.New("section title is required") + } + if len(section.Rules) == 0 { + return errors.New("section must have at least one rule") + } + for j, rule := range section.Rules { + if strings.TrimSpace(rule) == "" { + return errors.New("rule cannot be empty") + } + if len(rule) > 500 { + return errors.New("rule cannot exceed 500 characters") + } + // Validate rule content (basic validation) + if len(rule) < 10 { + return errors.New("rule must be at least 10 characters long") + } + _ = j // Avoid unused variable warning + } + _ = i // Avoid unused variable warning + } + + return nil +} + +func (v *RewardValidatorImpl) validateImages(images []string) error { + if len(images) == 0 { + return errors.New("at least one image is required") + } + + for i, imgURL := range images { + if strings.TrimSpace(imgURL) == "" { + return errors.New("image URL is required") + } + + // Validate URL format (basic validation) + if !v.isValidURL(imgURL) { + return errors.New("invalid image URL format") + } + + _ = i // Avoid unused variable warning + } + + return nil +} + +func (v *RewardValidatorImpl) isValidURL(url string) bool { + // Basic URL validation regex + urlRegex := regexp.MustCompile(`^https?://[^\s/$.?#].[^\s]*$`) + return urlRegex.MatchString(url) +} diff --git a/migrations/000055_create_rewards_table.down.sql b/migrations/000055_create_rewards_table.down.sql new file mode 100644 index 0000000..fb755b5 --- /dev/null +++ b/migrations/000055_create_rewards_table.down.sql @@ -0,0 +1,6 @@ +-- Drop rewards table and its indexes +DROP INDEX IF EXISTS idx_rewards_stock; +DROP INDEX IF EXISTS idx_rewards_created_at; +DROP INDEX IF EXISTS idx_rewards_cost_points; +DROP INDEX IF EXISTS idx_rewards_reward_type; +DROP TABLE IF EXISTS rewards; diff --git a/migrations/000055_create_rewards_table.up.sql b/migrations/000055_create_rewards_table.up.sql new file mode 100644 index 0000000..ed780c2 --- /dev/null +++ b/migrations/000055_create_rewards_table.up.sql @@ -0,0 +1,30 @@ +-- Create rewards table +CREATE TABLE rewards ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(150) NOT NULL, + reward_type VARCHAR(50) NOT NULL, -- VOUCHER, PHYSICAL, DIGITAL, BALANCE + cost_points BIGINT NOT NULL, + stock INT, -- nullable if unlimited (digital rewards) + max_per_customer INT DEFAULT 1, + tnc JSONB, -- sections + rules + expiry_days + metadata JSONB, -- brand, model, etc. + images JSONB, -- array of images + created_at TIMESTAMP DEFAULT now(), + updated_at TIMESTAMP DEFAULT now() +); + +-- Create indexes for better performance +CREATE INDEX idx_rewards_reward_type ON rewards(reward_type); +CREATE INDEX idx_rewards_cost_points ON rewards(cost_points); +CREATE INDEX idx_rewards_created_at ON rewards(created_at); +CREATE INDEX idx_rewards_stock ON rewards(stock) WHERE stock IS NOT NULL; + +-- Add comments +COMMENT ON TABLE rewards IS 'Rewards catalog for gamification system'; +COMMENT ON COLUMN rewards.reward_type IS 'Type of reward: VOUCHER, PHYSICAL, DIGITAL, BALANCE'; +COMMENT ON COLUMN rewards.cost_points IS 'Points required to redeem this reward'; +COMMENT ON COLUMN rewards.stock IS 'Available quantity (NULL for unlimited digital rewards)'; +COMMENT ON COLUMN rewards.max_per_customer IS 'Maximum times a customer can redeem this reward'; +COMMENT ON COLUMN rewards.tnc IS 'Terms and conditions in JSON format'; +COMMENT ON COLUMN rewards.metadata IS 'Additional reward metadata (brand, model, etc.)'; +COMMENT ON COLUMN rewards.images IS 'Array of image URLs and types'; diff --git a/migrations/000056_create_campaigns_tables.down.sql b/migrations/000056_create_campaigns_tables.down.sql new file mode 100644 index 0000000..4539a32 --- /dev/null +++ b/migrations/000056_create_campaigns_tables.down.sql @@ -0,0 +1,15 @@ +-- Drop campaign_rules table and its indexes first (due to foreign key constraint) +DROP INDEX IF EXISTS idx_campaign_rules_reward_ref_id; +DROP INDEX IF EXISTS idx_campaign_rules_reward_type; +DROP INDEX IF EXISTS idx_campaign_rules_rule_type; +DROP INDEX IF EXISTS idx_campaign_rules_campaign_id; +DROP TABLE IF EXISTS campaign_rules; + +-- Drop campaigns table and its indexes +DROP INDEX IF EXISTS idx_campaigns_position; +DROP INDEX IF EXISTS idx_campaigns_show_on_app; +DROP INDEX IF EXISTS idx_campaigns_is_active; +DROP INDEX IF EXISTS idx_campaigns_end_date; +DROP INDEX IF EXISTS idx_campaigns_start_date; +DROP INDEX IF EXISTS idx_campaigns_type; +DROP TABLE IF EXISTS campaigns; diff --git a/migrations/000056_create_campaigns_tables.up.sql b/migrations/000056_create_campaigns_tables.up.sql new file mode 100644 index 0000000..c68c94e --- /dev/null +++ b/migrations/000056_create_campaigns_tables.up.sql @@ -0,0 +1,64 @@ +-- Create campaigns table +CREATE TABLE campaigns ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(150) NOT NULL, + description TEXT, + type VARCHAR(50) NOT NULL, -- REWARD, POINTS, TOKENS, MIXED + start_date TIMESTAMP NOT NULL, + end_date TIMESTAMP NOT NULL, + is_active BOOLEAN DEFAULT true, + show_on_app BOOLEAN DEFAULT true, -- apakah campaign muncul di aplikasi + position INT DEFAULT 0, -- urutan tampil di aplikasi + metadata JSONB, -- fleksibel: banner, theme, config lain + created_at TIMESTAMP DEFAULT now(), + updated_at TIMESTAMP DEFAULT now() +); + +-- Create campaign_rules table +CREATE TABLE campaign_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + campaign_id UUID NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE, + + -- Rule definition + rule_type VARCHAR(50) NOT NULL, -- TIER, SPEND, PRODUCT, CATEGORY, DAY, LOCATION + condition_value VARCHAR(255), -- e.g. SILVER, 50000, uuid-product, MONDAY + + -- Reward definition + reward_type VARCHAR(50) NOT NULL, -- POINTS, TOKENS, REWARD + reward_value BIGINT, -- e.g. 10 (points/tokens), 2 (multiplier) + reward_subtype VARCHAR(50), -- e.g. MULTIPLIER, SPIN, BONUS, PHYSICAL + reward_ref_id UUID, -- FK ke rewards.id kalau reward_type = REWARD + + metadata JSONB, -- fleksibel: extra configs + + created_at TIMESTAMP DEFAULT now(), + updated_at TIMESTAMP DEFAULT now() +); + +-- Create indexes for better performance +CREATE INDEX idx_campaigns_type ON campaigns(type); +CREATE INDEX idx_campaigns_start_date ON campaigns(start_date); +CREATE INDEX idx_campaigns_end_date ON campaigns(end_date); +CREATE INDEX idx_campaigns_is_active ON campaigns(is_active); +CREATE INDEX idx_campaigns_show_on_app ON campaigns(show_on_app); +CREATE INDEX idx_campaigns_position ON campaigns(position); + +CREATE INDEX idx_campaign_rules_campaign_id ON campaign_rules(campaign_id); +CREATE INDEX idx_campaign_rules_rule_type ON campaign_rules(rule_type); +CREATE INDEX idx_campaign_rules_reward_type ON campaign_rules(reward_type); +CREATE INDEX idx_campaign_rules_reward_ref_id ON campaign_rules(reward_ref_id); + +-- Add comments +COMMENT ON TABLE campaigns IS 'Campaign definitions for gamification system'; +COMMENT ON TABLE campaign_rules IS 'Rules and rewards for each campaign'; + +COMMENT ON COLUMN campaigns.type IS 'Type of campaign: REWARD, POINTS, TOKENS, MIXED'; +COMMENT ON COLUMN campaigns.show_on_app IS 'Whether campaign appears in mobile app'; +COMMENT ON COLUMN campaigns.position IS 'Display order in app (lower number = higher priority)'; + +COMMENT ON COLUMN campaign_rules.rule_type IS 'Rule condition type: TIER, SPEND, PRODUCT, CATEGORY, DAY, LOCATION'; +COMMENT ON COLUMN campaign_rules.condition_value IS 'Value for the rule condition (tier name, amount, product ID, etc.)'; +COMMENT ON COLUMN campaign_rules.reward_type IS 'Type of reward: POINTS, TOKENS, REWARD'; +COMMENT ON COLUMN campaign_rules.reward_value IS 'Reward amount or multiplier'; +COMMENT ON COLUMN campaign_rules.reward_subtype IS 'Subtype of reward: MULTIPLIER, SPIN, BONUS, PHYSICAL'; +COMMENT ON COLUMN campaign_rules.reward_ref_id IS 'Reference to rewards table when reward_type = REWARD';