Add Campaign

This commit is contained in:
Aditya Siregar 2025-09-17 23:55:11 +07:00
parent 201e24041b
commit 155016dec8
26 changed files with 3399 additions and 1 deletions

View File

@ -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(),
}
}

View File

@ -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{

View File

@ -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"`
}

View File

@ -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"`
}

View File

@ -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"`
}

View File

@ -31,6 +31,9 @@ func GetAllEntities() []interface{} {
&GamePrize{},
&GamePlay{},
&OmsetTracker{},
&Reward{},
&Campaign{},
&CampaignRule{},
// Analytics entities are not database tables, they are query results
}
}

View File

@ -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"`
}

View File

@ -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")
}

View File

@ -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")
}

View File

@ -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
}

View File

@ -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
}

132
internal/models/campaign.go Normal file
View File

@ -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"`
}

71
internal/models/reward.go Normal file
View File

@ -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"`
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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")

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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;

View File

@ -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';

View File

@ -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;

View File

@ -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';