package processor import ( "bytes" "context" "encoding/json" "errors" "fmt" "regexp" "strings" "github.com/google/uuid" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" ) // ErrEnakGameRejected wraps every reason an EnakGame request is refused: a field out // of bounds, a slug in use, an archived game, a retired configuration. The message // says which. var ErrEnakGameRejected = errors.New("enakgame request refused") const ( enakGameDefaultSessionTTL = 600 enakGameMaxSessionTTL = 24 * 60 * 60 enakGamePageLimit = 20 enakGameMaxPageLimit = 100 ) var enakGameSlugPattern = regexp.MustCompile(`^[a-z0-9]+(-[a-z0-9]+)*$`) func enakGameRejected(format string, args ...any) error { return fmt.Errorf("%w: %s", ErrEnakGameRejected, fmt.Sprintf(format, args...)) } // EnakGameAdminProcessor manages an organization's EnakGame games and their reward // configurations (docs/rfc-enakgame.md §5.1, §5.2, §11). Every change is written to // audit_logs in the same transaction (§13). type EnakGameAdminProcessor struct { games repository.EnakGameRepository audit *AuditLogger tx TxRunner } func NewEnakGameAdminProcessor(games repository.EnakGameRepository, audit *AuditLogger, tx TxRunner) *EnakGameAdminProcessor { return &EnakGameAdminProcessor{games: games, audit: audit, tx: tx} } func (p *EnakGameAdminProcessor) record(ctx context.Context, organizationID, actor uuid.UUID, entityType string, entityID uuid.UUID, action string, before, after any, reason *string) error { return p.audit.Record(ctx, AuditEntry{ OrganizationID: organizationID, ActorType: constants.AuditActorUser, ActorID: &actor, EntityType: entityType, EntityID: entityID, Action: action, Before: before, After: after, Reason: reason, Source: constants.AuditSourceAdminAPI, }) } // EnakGameInputFrom is a game's current values as an input, for a change that only // sends the fields it changes. func EnakGameInputFrom(game *models.EnakGame) models.EnakGameInput { return models.EnakGameInput{ Name: game.Name, Type: game.Type, Slug: game.Slug, Description: game.Description, ThumbnailURL: game.ThumbnailURL, GameURL: game.GameURL, Version: game.Version, Status: game.Status, EntryCost: game.EntryCost, SessionTTLSeconds: game.SessionTTLSeconds, ResultRules: game.ResultRules, } } func (p *EnakGameAdminProcessor) CreateGame(ctx context.Context, organizationID, actor uuid.UUID, in models.EnakGameInput) (*models.EnakGame, error) { if in.Type == "" { in.Type = string(entities.GameTypeMinigame) } if in.Status == "" { in.Status = constants.GameStatusDraft } if in.SessionTTLSeconds == 0 { in.SessionTTLSeconds = enakGameDefaultSessionTTL } if err := validateEnakGameInput(&in); err != nil { return nil, err } switch in.Status { case constants.GameStatusDraft, constants.GameStatusActive, constants.GameStatusInactive: default: return nil, enakGameRejected("a new game must be DRAFT, ACTIVE or INACTIVE") } game := &entities.Game{OrganizationID: &organizationID, Status: in.Status} applyEnakGameInput(game, in) err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { if err := p.games.CreateGame(ctx, game); err != nil { return err } return p.record(ctx, organizationID, actor, constants.AuditEntityGame, game.ID, "CREATED", nil, enakGameModel(game), nil) }) if err != nil { return nil, enakGameError(err) } return enakGameModel(game), nil } func (p *EnakGameAdminProcessor) GetGame(ctx context.Context, organizationID, id uuid.UUID) (*models.EnakGame, error) { game, err := p.games.GetGame(ctx, organizationID, id) if err != nil { return nil, err } return enakGameModel(game), nil } func (p *EnakGameAdminProcessor) ListGames(ctx context.Context, organizationID uuid.UUID, q models.EnakGameListQuery) (*models.PaginatedResponse[models.EnakGame], error) { page, limit := enakGamePage(q.Page, q.Limit) statuses := []string{constants.GameStatusDraft, constants.GameStatusActive, constants.GameStatusInactive} if q.Status != "" { status := strings.ToUpper(strings.TrimSpace(q.Status)) if !isEnakGameStatus(status) { return nil, enakGameRejected("unknown status %q", q.Status) } statuses = []string{status} } games, total, err := p.games.ListGames(ctx, repository.EnakGameFilter{ OrganizationID: organizationID, Statuses: statuses, Search: strings.TrimSpace(q.Search), Offset: (page - 1) * limit, Limit: limit, }) if err != nil { return nil, err } items := make([]models.EnakGame, 0, len(games)) for i := range games { items = append(items, *enakGameModel(&games[i])) } return &models.PaginatedResponse[models.EnakGame]{Data: items, Pagination: enakGamePagination(page, limit, total)}, nil } // UpdateGame changes everything but the status, which has its own endpoint. An // archived game never changes. func (p *EnakGameAdminProcessor) UpdateGame(ctx context.Context, organizationID, actor, id uuid.UUID, in models.EnakGameInput) (*models.EnakGame, error) { if err := validateEnakGameInput(&in); err != nil { return nil, err } var after *models.EnakGame err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { game, err := p.games.LockGame(ctx, organizationID, id) if err != nil { return err } if game.Status == constants.GameStatusArchived { return enakGameRejected("an archived game cannot change") } before := enakGameModel(game) applyEnakGameInput(game, in) if err := p.games.UpdateGame(ctx, game); err != nil { return err } after = enakGameModel(game) return p.record(ctx, organizationID, actor, constants.AuditEntityGame, id, "UPDATED", before, after, nil) }) if err != nil { return nil, enakGameError(err) } return p.GetGame(ctx, organizationID, after.ID) } // SetGameStatus moves a game between DRAFT, ACTIVE and INACTIVE, or archives it for // good. Sessions still open on a game leaving ACTIVE are refunded by the session job // (§7.3), not here. func (p *EnakGameAdminProcessor) SetGameStatus(ctx context.Context, organizationID, actor, id uuid.UUID, in models.EnakGameStatusInput) (*models.EnakGame, error) { status := strings.ToUpper(strings.TrimSpace(in.Status)) if !isEnakGameStatus(status) { return nil, enakGameRejected("status must be DRAFT, ACTIVE, INACTIVE or ARCHIVED") } if err := validateReason(in.Reason); err != nil { return nil, err } err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { game, err := p.games.LockGame(ctx, organizationID, id) if err != nil { return err } if game.Status == status { return nil } if game.Status == constants.GameStatusArchived { return enakGameRejected("an archived game cannot change") } if err := p.games.SetGameStatus(ctx, organizationID, id, status); err != nil { return err } return p.record(ctx, organizationID, actor, constants.AuditEntityGame, id, "STATUS_CHANGED", map[string]string{"status": game.Status}, map[string]string{"status": status}, in.Reason) }) if err != nil { return nil, enakGameError(err) } return p.GetGame(ctx, organizationID, id) } // CreateRewardConfig stores a new DRAFT version of a game's reward configuration. A // configuration is never edited (D7): a change is a new version, activated on its // own. func (p *EnakGameAdminProcessor) CreateRewardConfig(ctx context.Context, organizationID, actor, gameID uuid.UUID, in models.GameRewardConfigInput) (*models.GameRewardConfig, error) { rewardType := strings.ToUpper(strings.TrimSpace(in.RewardType)) calculator, err := RewardCalculatorFor(rewardType) if err != nil { return nil, enakGameRejected("%v", err) } if len(in.Rules) == 0 { return nil, enakGameRejected("rules are required") } if err := calculator.Validate(in.Rules); err != nil { return nil, enakGameRejected("%v", err) } if in.MaxReward < 0 { return nil, enakGameRejected("max_reward must be 0 or more") } if err := validateReason(in.Reason); err != nil { return nil, err } var rules bytes.Buffer if err := json.Compact(&rules, in.Rules); err != nil { return nil, enakGameRejected("rules: %v", err) } config := &entities.GameRewardConfig{ OrganizationID: organizationID, GameID: gameID, RewardType: rewardType, Rules: entities.JSONDocument(rules.Bytes()), MaxReward: in.MaxReward, Status: constants.GameRewardConfigStatusDraft, EffectiveAt: in.EffectiveAt, CreatedBy: actor, Reason: in.Reason, } err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { // The game lock also orders versions of the same game one after the other. game, err := p.games.LockGame(ctx, organizationID, gameID) if err != nil { return err } if game.Status == constants.GameStatusArchived { return enakGameRejected("an archived game cannot change") } if err := p.games.CreateRewardConfig(ctx, config); err != nil { return err } return p.record(ctx, organizationID, actor, constants.AuditEntityGameRewardConfig, config.ID, "CREATED", nil, rewardConfigModel(config), in.Reason) }) if err != nil { return nil, enakGameError(err) } return rewardConfigModel(config), nil } // ActivateRewardConfig makes a DRAFT version the game's active configuration and // retires the one active before, in one transaction. Activating the active version // again changes nothing. A retired version stays retired: going back means a new // version with the old rules. func (p *EnakGameAdminProcessor) ActivateRewardConfig(ctx context.Context, organizationID, actor, id uuid.UUID, in models.GameRewardConfigActivateInput) (*models.GameRewardConfig, error) { if err := validateReason(in.Reason); err != nil { return nil, err } var activated *entities.GameRewardConfig err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { config, err := p.games.GetRewardConfig(ctx, organizationID, id) if err != nil { return err } // Activations of the same game wait for each other here, so exactly one // version ends up active. game, err := p.games.LockGame(ctx, organizationID, config.GameID) if err != nil { return err } if game.Status == constants.GameStatusArchived { return enakGameRejected("an archived game cannot change") } if config, err = p.games.GetRewardConfig(ctx, organizationID, id); err != nil { return err } activated = config switch config.Status { case constants.GameRewardConfigStatusActive: return nil case constants.GameRewardConfigStatusRetired: return enakGameRejected("version %d is retired; create a new version instead", config.Version) } current, err := p.games.GetActiveRewardConfig(ctx, organizationID, config.GameID) if err != nil && !errors.Is(err, repository.ErrGameRewardConfigNotFound) { return err } if current != nil { if _, err := p.moveRewardConfig(ctx, organizationID, actor, current, constants.GameRewardConfigStatusRetired, "RETIRED", in.Reason); err != nil { return err } } activated, err = p.moveRewardConfig(ctx, organizationID, actor, config, constants.GameRewardConfigStatusActive, "ACTIVATED", in.Reason) return err }) if err != nil { return nil, enakGameError(err) } return rewardConfigModel(activated), nil } func (p *EnakGameAdminProcessor) moveRewardConfig(ctx context.Context, organizationID, actor uuid.UUID, config *entities.GameRewardConfig, to, action string, reason *string) (*entities.GameRewardConfig, error) { moved, err := p.games.SetRewardConfigStatus(ctx, organizationID, config.ID, config.Status, to) if err != nil { return nil, err } if !moved { return nil, enakGameRejected("version %d changed meanwhile; try again", config.Version) } before := config.Status next := *config next.Status = to err = p.record(ctx, organizationID, actor, constants.AuditEntityGameRewardConfig, config.ID, action, map[string]string{"status": before}, map[string]string{"status": to}, reason) return &next, err } // ListRewardConfigs returns every version of a game's configuration, newest first. func (p *EnakGameAdminProcessor) ListRewardConfigs(ctx context.Context, organizationID, gameID uuid.UUID) ([]models.GameRewardConfig, error) { if _, err := p.games.GetGame(ctx, organizationID, gameID); err != nil { return nil, err } configs, err := p.games.ListRewardConfigs(ctx, organizationID, gameID) if err != nil { return nil, err } out := make([]models.GameRewardConfig, 0, len(configs)) for i := range configs { out = append(out, *rewardConfigModel(&configs[i])) } return out, nil } // enakGameError turns a slug collision into a refusal; anything else passes as is. func enakGameError(err error) error { if errors.Is(err, repository.ErrEnakGameSlugTaken) { return enakGameRejected("another game already uses this slug") } return err } func isEnakGameStatus(s string) bool { switch s { case constants.GameStatusDraft, constants.GameStatusActive, constants.GameStatusInactive, constants.GameStatusArchived: return true } return false } func validateReason(reason *string) error { if reason != nil && len(*reason) > 255 { return enakGameRejected("reason must be at most 255 characters") } return nil } func validateEnakGameInput(in *models.EnakGameInput) error { in.Name = strings.TrimSpace(in.Name) in.Slug = strings.TrimSpace(in.Slug) in.Type = strings.ToUpper(strings.TrimSpace(in.Type)) in.Status = strings.ToUpper(strings.TrimSpace(in.Status)) switch { case in.Name == "" || len(in.Name) > 255: return enakGameRejected("name is required, at most 255 characters") case in.Type != string(entities.GameTypeSpin) && in.Type != string(entities.GameTypeRaffle) && in.Type != string(entities.GameTypeMinigame): return enakGameRejected("type must be SPIN, RAFFLE or MINIGAME") case len(in.Slug) > 100 || !enakGameSlugPattern.MatchString(in.Slug): return enakGameRejected("slug must be lowercase letters, digits and single dashes, at most 100 characters") case in.EntryCost < 1: // PRD §10.1: no game is free. return enakGameRejected("entry_cost must be at least 1") case in.SessionTTLSeconds < 1 || in.SessionTTLSeconds > enakGameMaxSessionTTL: return enakGameRejected("session_ttl_seconds must be between 1 and %d", enakGameMaxSessionTTL) } for field, value := range map[string]*string{"thumbnail_url": in.ThumbnailURL, "game_url": in.GameURL} { if value != nil && len(*value) > 500 { return enakGameRejected("%s must be at most 500 characters", field) } } if in.Version != nil && len(*in.Version) > 50 { return enakGameRejected("version must be at most 50 characters") } return validateResultRules(in.ResultRules) } func validateResultRules(r entities.GameResultRules) error { switch { case r.MaxScore != nil && *r.MaxScore < 0: return enakGameRejected("result_rules.max_score must be 0 or more") case r.MinDurationSeconds != nil && *r.MinDurationSeconds < 0: return enakGameRejected("result_rules.min_duration_seconds must be 0 or more") case r.MaxScorePerSecond != nil && !(*r.MaxScorePerSecond > 0): return enakGameRejected("result_rules.max_score_per_second must be more than 0") case r.DailyRewardLimit != nil && *r.DailyRewardLimit < 0: return enakGameRejected("result_rules.daily_reward_limit must be 0 or more") } seen := map[string]bool{} for _, outcome := range r.Outcomes { if strings.TrimSpace(outcome) == "" { return enakGameRejected("result_rules.outcomes cannot hold an empty outcome") } if seen[outcome] { return enakGameRejected("result_rules.outcomes lists %q twice", outcome) } seen[outcome] = true } return nil } func applyEnakGameInput(game *entities.Game, in models.EnakGameInput) { slug := in.Slug game.Name = in.Name game.Type = entities.GameType(in.Type) game.Slug = &slug game.Description = in.Description game.ThumbnailURL = in.ThumbnailURL game.GameURL = in.GameURL game.Version = in.Version game.EntryCost = in.EntryCost game.SessionTTLSeconds = in.SessionTTLSeconds game.ResultRules = in.ResultRules } func enakGameModel(g *entities.Game) *models.EnakGame { m := &models.EnakGame{ ID: g.ID, Name: g.Name, Type: string(g.Type), Description: g.Description, ThumbnailURL: g.ThumbnailURL, GameURL: g.GameURL, Version: g.Version, Status: g.Status, EntryCost: g.EntryCost, SessionTTLSeconds: g.SessionTTLSeconds, ResultRules: g.ResultRules, CreatedAt: g.CreatedAt, UpdatedAt: g.UpdatedAt, } if g.Slug != nil { m.Slug = *g.Slug } return m } func rewardConfigModel(c *entities.GameRewardConfig) *models.GameRewardConfig { return &models.GameRewardConfig{ ID: c.ID, GameID: c.GameID, Version: c.Version, RewardType: c.RewardType, Rules: json.RawMessage(c.Rules), MaxReward: c.MaxReward, Status: c.Status, EffectiveAt: c.EffectiveAt, CreatedBy: c.CreatedBy, Reason: c.Reason, BaseConfigID: c.BaseConfigID, Multiplier: c.Multiplier, BudgetID: c.BudgetID, CreatedAt: c.CreatedAt, } } func enakGamePage(page, limit int) (int, int) { if page < 1 { page = 1 } if limit < 1 || limit > enakGameMaxPageLimit { limit = enakGamePageLimit } return page, limit } func enakGamePagination(page, limit int, total int64) models.Pagination { return models.Pagination{Page: page, Limit: limit, Total: total, TotalPages: int((total + int64(limit) - 1) / int64(limit))} }