Adds cmd/wallet-migrate (make wallet-migrate, args=-dry-run to only report), which moves customer_points and customer_tokens into the wallet (docs/prd-point-coin.md ยง10, PC-105). Each customer gets a MIGRATION ledger row and a non-expiring lot per currency, written through WalletProcessor in one transaction per customer. EnakCoin is the sum of every token type (Q6), with the legacy rows listed in the row's metadata. It credits the difference between the legacy balance and what earlier runs migrated, so running it again never doubles a balance and picks up only what the old code added since. A legacy balance that shrank after being migrated is reported and left alone, since only an admin adjustment may take balance away, and the command then exits non-zero. It ends with a legacy / migrated / wallet total per currency. Migration 000092 renames TOKENS to COINS in campaigns.type and campaign_rules.reward_type. The campaign API now validates COINS; it still accepts TOKENS, including as a list filter, and stores it as COINS so older dashboards keep working while they are updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
315 lines
10 KiB
Go
315 lines
10 KiB
Go
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, COINS, 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
|
|
}
|
|
|
|
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, COINS, 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
|
|
}
|
|
|
|
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, COINS, 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, COINS, 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", "COINS", "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", "COINS", "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, COINS, REWARD")
|
|
}
|
|
|
|
// Validate reward value based on reward type
|
|
if rule.RewardType == "POINTS" || rule.RewardType == "COINS" || 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
|
|
}
|