package repository import ( "context" "errors" "fmt" "time" "github.com/google/uuid" "gorm.io/gorm" "gorm.io/gorm/clause" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" ) var ( // ErrEnakGameNotFound means no game with that id in the organization, or, for a // write, none that may still change: an archived game never does. ErrEnakGameNotFound = errors.New("enakgame: game not found") // ErrGameRewardConfigNotFound means no reward configuration with that id in the // organization, or no active one for the game. ErrGameRewardConfigNotFound = errors.New("enakgame: reward config not found") // ErrEnakGameSlugTaken means another game of the organization has the slug. ErrEnakGameSlugTaken = errors.New("enakgame: slug already used") ) // EnakGameFilter selects an organization's games. type EnakGameFilter struct { OrganizationID uuid.UUID // Empty for every status. Statuses []string // Matched against the name, case-insensitively. Search string Offset int Limit int } // EnakGameRepository stores EnakGame games and their reward configurations // (docs/rfc-enakgame.md §5.1, §5.2). Every read and write is scoped to an // organization (D8), so the old games, which have none, never show up. type EnakGameRepository interface { CreateGame(ctx context.Context, game *entities.Game) error GetGame(ctx context.Context, organizationID, id uuid.UUID) (*entities.Game, error) // LockGame is GetGame with the row locked until the transaction ends. LockGame(ctx context.Context, organizationID, id uuid.UUID) (*entities.Game, error) // ListGames returns a page of games, newest first, and the total. ListGames(ctx context.Context, filter EnakGameFilter) ([]entities.Game, int64, error) // UpdateGame stores the game's editable columns, everything but its organization // and status. UpdateGame(ctx context.Context, game *entities.Game) error SetGameStatus(ctx context.Context, organizationID, id uuid.UUID, status string) error // CreateRewardConfig stores a configuration as the game's next version and sets // config.Version. CreateRewardConfig(ctx context.Context, config *entities.GameRewardConfig) error GetRewardConfig(ctx context.Context, organizationID, id uuid.UUID) (*entities.GameRewardConfig, error) GetActiveRewardConfig(ctx context.Context, organizationID, gameID uuid.UUID) (*entities.GameRewardConfig, error) // ListRewardConfigs returns every version of a game's configuration, newest first. ListRewardConfigs(ctx context.Context, organizationID, gameID uuid.UUID) ([]entities.GameRewardConfig, error) // SetRewardConfigStatus moves a configuration from one status to another, the only // change a configuration allows (D7). It reports false when the configuration was // not in from. SetRewardConfigStatus(ctx context.Context, organizationID, id uuid.UUID, from, to string) (bool, error) // ListActiveRewardConfigs returns the active configuration of every game of the // organization that is not archived, by game name. ListActiveRewardConfigs(ctx context.Context, organizationID uuid.UUID) ([]ActiveRewardConfig, error) // LastBudgetControllerChange is when the organization last accepted a Budget // Controller recommendation, or nil when it never did. LastBudgetControllerChange(ctx context.Context, organizationID uuid.UUID) (*time.Time, error) } // ActiveRewardConfig is a game's active configuration with the game's name. type ActiveRewardConfig struct { entities.GameRewardConfig `gorm:"embedded"` GameName string } type enakGameRepository struct { db *gorm.DB } func NewEnakGameRepository(db *gorm.DB) EnakGameRepository { return &enakGameRepository{db: db} } func (r *enakGameRepository) CreateGame(ctx context.Context, game *entities.Game) error { if err := DBFromContext(ctx, r.db).WithContext(ctx).Omit(clause.Associations).Create(game).Error; err != nil { if isUniqueViolation(err, "uq_games_org_slug") { return ErrEnakGameSlugTaken } return fmt.Errorf("failed to create game: %w", err) } return nil } func (r *enakGameRepository) getGame(ctx context.Context, organizationID, id uuid.UUID, lock bool) (*entities.Game, error) { q := DBFromContext(ctx, r.db).WithContext(ctx).Where("organization_id = ? AND id = ?", organizationID, id) if lock { q = q.Clauses(clause.Locking{Strength: "UPDATE"}) } var game entities.Game if err := q.First(&game).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrEnakGameNotFound } return nil, fmt.Errorf("failed to read game: %w", err) } return &game, nil } func (r *enakGameRepository) GetGame(ctx context.Context, organizationID, id uuid.UUID) (*entities.Game, error) { return r.getGame(ctx, organizationID, id, false) } func (r *enakGameRepository) LockGame(ctx context.Context, organizationID, id uuid.UUID) (*entities.Game, error) { return r.getGame(ctx, organizationID, id, true) } func (r *enakGameRepository) ListGames(ctx context.Context, filter EnakGameFilter) ([]entities.Game, int64, error) { q := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.Game{}).Where("organization_id = ?", filter.OrganizationID) if len(filter.Statuses) > 0 { q = q.Where("status IN ?", filter.Statuses) } if filter.Search != "" { q = q.Where("name ILIKE ?", "%"+filter.Search+"%") } var total int64 if err := q.Count(&total).Error; err != nil { return nil, 0, fmt.Errorf("failed to count games: %w", err) } var games []entities.Game if err := q.Order("created_at DESC, id").Offset(filter.Offset).Limit(filter.Limit).Find(&games).Error; err != nil { return nil, 0, fmt.Errorf("failed to list games: %w", err) } return games, total, nil } func (r *enakGameRepository) UpdateGame(ctx context.Context, game *entities.Game) error { if game.OrganizationID == nil { return ErrEnakGameNotFound } result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(` UPDATE games SET name = ?, type = ?, slug = ?, description = ?, thumbnail_url = ?, game_url = ?, version = ?, entry_cost = ?, session_ttl_seconds = ?, result_rules = ?, updated_at = NOW() WHERE organization_id = ? AND id = ? AND status <> ?`, game.Name, game.Type, game.Slug, game.Description, game.ThumbnailURL, game.GameURL, game.Version, game.EntryCost, game.SessionTTLSeconds, game.ResultRules, *game.OrganizationID, game.ID, constants.GameStatusArchived) if result.Error != nil { if isUniqueViolation(result.Error, "uq_games_org_slug") { return ErrEnakGameSlugTaken } return fmt.Errorf("failed to update game: %w", result.Error) } if result.RowsAffected == 0 { return ErrEnakGameNotFound } return nil } func (r *enakGameRepository) SetGameStatus(ctx context.Context, organizationID, id uuid.UUID, status string) error { result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(` UPDATE games SET status = ?, updated_at = NOW() WHERE organization_id = ? AND id = ? AND status <> ?`, status, organizationID, id, constants.GameStatusArchived) if result.Error != nil { return fmt.Errorf("failed to change game status: %w", result.Error) } if result.RowsAffected == 0 { return ErrEnakGameNotFound } return nil } func (r *enakGameRepository) CreateRewardConfig(ctx context.Context, config *entities.GameRewardConfig) error { if config.ID == uuid.Nil { config.ID = uuid.New() } if config.Status == "" { config.Status = constants.GameRewardConfigStatusDraft } // The version is computed in the insert. Two inserts at once may compute the same // one, and the unique (game_id, version) refuses the second. var rows []struct { Version int CreatedAt time.Time } err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` INSERT INTO game_reward_configs (id, organization_id, game_id, version, reward_type, rules, max_reward, status, effective_at, created_by, reason, base_config_id, multiplier, budget_id) SELECT ?, g.organization_id, g.id, COALESCE((SELECT MAX(version) FROM game_reward_configs WHERE game_id = g.id), 0) + 1, ?, ?::jsonb, ?, ?, ?, ?, ?, ?, ?, ? FROM games g WHERE g.organization_id = ? AND g.id = ? RETURNING version, created_at`, config.ID, config.RewardType, config.Rules, config.MaxReward, config.Status, config.EffectiveAt, config.CreatedBy, config.Reason, config.BaseConfigID, config.Multiplier, config.BudgetID, config.OrganizationID, config.GameID).Scan(&rows).Error if err != nil { return fmt.Errorf("failed to create reward config: %w", err) } if len(rows) == 0 { return ErrEnakGameNotFound } config.Version, config.CreatedAt = rows[0].Version, rows[0].CreatedAt return nil } func (r *enakGameRepository) firstRewardConfig(q *gorm.DB) (*entities.GameRewardConfig, error) { var config entities.GameRewardConfig if err := q.First(&config).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrGameRewardConfigNotFound } return nil, fmt.Errorf("failed to read reward config: %w", err) } return &config, nil } func (r *enakGameRepository) GetRewardConfig(ctx context.Context, organizationID, id uuid.UUID) (*entities.GameRewardConfig, error) { return r.firstRewardConfig(DBFromContext(ctx, r.db).WithContext(ctx). Where("organization_id = ? AND id = ?", organizationID, id)) } func (r *enakGameRepository) GetActiveRewardConfig(ctx context.Context, organizationID, gameID uuid.UUID) (*entities.GameRewardConfig, error) { return r.firstRewardConfig(DBFromContext(ctx, r.db).WithContext(ctx). Where("organization_id = ? AND game_id = ? AND status = ?", organizationID, gameID, constants.GameRewardConfigStatusActive)) } func (r *enakGameRepository) ListRewardConfigs(ctx context.Context, organizationID, gameID uuid.UUID) ([]entities.GameRewardConfig, error) { var configs []entities.GameRewardConfig err := DBFromContext(ctx, r.db).WithContext(ctx). Where("organization_id = ? AND game_id = ?", organizationID, gameID). Order("version DESC").Find(&configs).Error if err != nil { return nil, fmt.Errorf("failed to list reward configs: %w", err) } return configs, nil } func (r *enakGameRepository) SetRewardConfigStatus(ctx context.Context, organizationID, id uuid.UUID, from, to string) (bool, error) { result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(` UPDATE game_reward_configs SET status = ? WHERE organization_id = ? AND id = ? AND status = ?`, to, organizationID, id, from) if result.Error != nil { return false, fmt.Errorf("failed to change reward config status: %w", result.Error) } return result.RowsAffected == 1, nil } func (r *enakGameRepository) ListActiveRewardConfigs(ctx context.Context, organizationID uuid.UUID) ([]ActiveRewardConfig, error) { var configs []ActiveRewardConfig err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` SELECT c.*, g.name AS game_name FROM game_reward_configs c JOIN games g ON g.id = c.game_id WHERE c.organization_id = ? AND c.status = ? AND g.status <> ? ORDER BY g.name, g.id`, organizationID, constants.GameRewardConfigStatusActive, constants.GameStatusArchived).Scan(&configs).Error if err != nil { return nil, fmt.Errorf("failed to list active reward configs: %w", err) } return configs, nil } func (r *enakGameRepository) LastBudgetControllerChange(ctx context.Context, organizationID uuid.UUID) (*time.Time, error) { // effective_at, set by the Budget Controller from its clock, not created_at from // the database's. var last []time.Time err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` SELECT effective_at FROM game_reward_configs WHERE organization_id = ? AND budget_id IS NOT NULL ORDER BY effective_at DESC LIMIT 1`, organizationID).Scan(&last).Error if err != nil { return nil, fmt.Errorf("failed to read the last Budget Controller change: %w", err) } if len(last) == 0 { return nil, nil } return &last[0], nil }