package processor import ( "context" "errors" "math" "strings" "time" "github.com/google/uuid" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" "apskel-pos-be/internal/models" "apskel-pos-be/internal/repository" ) // gameEventMaxMultiplier is the largest multiplier NUMERIC(5,2) holds. const gameEventMaxMultiplier = 999.99 // GameEventProcessor manages an organization's EnakGame events // (docs/rfc-enakgame.md §5.5, §11). Every change is audited in its transaction. type GameEventProcessor struct { events repository.GameEventRepository games repository.EnakGameRepository budgets repository.GameBudgetRepository audit *AuditLogger tx TxRunner } func NewGameEventProcessor(events repository.GameEventRepository, games repository.EnakGameRepository, budgets repository.GameBudgetRepository, audit *AuditLogger, tx TxRunner) *GameEventProcessor { return &GameEventProcessor{events: events, games: games, budgets: budgets, audit: audit, tx: tx} } func (p *GameEventProcessor) record(ctx context.Context, organizationID, actor, id uuid.UUID, action string, before, after any, reason *string) error { return p.audit.Record(ctx, AuditEntry{ OrganizationID: organizationID, ActorType: constants.AuditActorUser, ActorID: &actor, EntityType: constants.AuditEntityGameEvent, EntityID: id, Action: action, Before: before, After: after, Reason: reason, Source: constants.AuditSourceAdminAPI, }) } // GameEventInputFrom is an event's current values as an input, for a change that only // sends the fields it changes. func GameEventInputFrom(e *models.GameEvent) models.GameEventInput { return models.GameEventInput{ Name: e.Name, Slug: e.Slug, Description: e.Description, BannerURL: e.BannerURL, StartAt: e.StartAt, EndAt: e.EndAt, Timezone: e.Timezone, Priority: e.Priority, Multiplier: e.Multiplier, Bonus: e.Bonus, BudgetID: e.BudgetID, RewardLimit: e.RewardLimit, UserDailyLimit: e.UserDailyLimit, GameIDs: e.GameIDs, Status: e.Status, } } func (p *GameEventProcessor) CreateEvent(ctx context.Context, organizationID, actor uuid.UUID, in models.GameEventInput) (*models.GameEvent, error) { status := strings.ToUpper(strings.TrimSpace(in.Status)) if status == "" { status = constants.GameEventStatusDraft } if status != constants.GameEventStatusDraft && status != constants.GameEventStatusActive { return nil, enakGameRejected("a new event must be DRAFT or ACTIVE") } event := &entities.GameEvent{OrganizationID: organizationID, Status: status} gameIDs, err := applyGameEventInput(event, in) if err != nil { return nil, err } err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { if err := p.checkReferences(ctx, organizationID, event.BudgetID, gameIDs); err != nil { return err } if err := p.events.CreateEvent(ctx, event, gameIDs); err != nil { return err } return p.record(ctx, organizationID, actor, event.ID, "CREATED", nil, gameEventModel(event, gameIDs), nil) }) if err != nil { return nil, gameEventError(err) } return gameEventModel(event, gameIDs), nil } func (p *GameEventProcessor) GetEvent(ctx context.Context, organizationID, id uuid.UUID) (*models.GameEvent, error) { event, err := p.events.GetEvent(ctx, organizationID, id) if err != nil { return nil, err } games, err := p.events.EventGames(ctx, []uuid.UUID{id}) if err != nil { return nil, err } return gameEventModel(event, games[id]), nil } func (p *GameEventProcessor) ListEvents(ctx context.Context, organizationID uuid.UUID, q models.GameEventListQuery) (*models.PaginatedResponse[models.GameEvent], error) { page, limit := enakGamePage(q.Page, q.Limit) var statuses []string if q.Status != "" { status := strings.ToUpper(strings.TrimSpace(q.Status)) if !isGameEventStatus(status) { return nil, enakGameRejected("unknown status %q", q.Status) } statuses = []string{status} } events, total, err := p.events.ListEvents(ctx, repository.GameEventFilter{ OrganizationID: organizationID, Statuses: statuses, Offset: (page - 1) * limit, Limit: limit, }) if err != nil { return nil, err } ids := make([]uuid.UUID, 0, len(events)) for _, e := range events { ids = append(ids, e.ID) } games, err := p.events.EventGames(ctx, ids) if err != nil { return nil, err } items := make([]models.GameEvent, 0, len(events)) for i := range events { items = append(items, *gameEventModel(&events[i], games[events[i].ID])) } return &models.PaginatedResponse[models.GameEvent]{Data: items, Pagination: enakGamePagination(page, limit, total)}, nil } // UpdateEvent changes everything but the status. An event that ended or was cancelled // never changes. func (p *GameEventProcessor) UpdateEvent(ctx context.Context, organizationID, actor, id uuid.UUID, in models.GameEventInput) (*models.GameEvent, error) { var after *models.GameEvent err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { event, err := p.events.LockEvent(ctx, organizationID, id) if err != nil { return err } if event.Status == constants.GameEventStatusEnded || event.Status == constants.GameEventStatusCancelled { return enakGameRejected("an event that ended or was cancelled cannot change") } games, err := p.events.EventGames(ctx, []uuid.UUID{id}) if err != nil { return err } before := gameEventModel(event, games[id]) gameIDs, err := applyGameEventInput(event, in) if err != nil { return err } if err := p.checkReferences(ctx, organizationID, event.BudgetID, gameIDs); err != nil { return err } if err := p.events.UpdateEvent(ctx, event, gameIDs); err != nil { return err } saved, err := p.events.GetEvent(ctx, organizationID, id) if err != nil { return err } after = gameEventModel(saved, gameIDs) return p.record(ctx, organizationID, actor, id, "UPDATED", before, after, nil) }) if err != nil { return nil, gameEventError(err) } return after, nil } // SetEventStatus moves an event: DRAFT to ACTIVE or CANCELLED, ACTIVE to ENDED or // CANCELLED. ENDED and CANCELLED are final. func (p *GameEventProcessor) SetEventStatus(ctx context.Context, organizationID, actor, id uuid.UUID, in models.GameEventStatusInput) (*models.GameEvent, error) { status := strings.ToUpper(strings.TrimSpace(in.Status)) if !isGameEventStatus(status) { return nil, enakGameRejected("status must be DRAFT, ACTIVE, ENDED or CANCELLED") } if err := validateReason(in.Reason); err != nil { return nil, err } err := p.tx.WithTransaction(ctx, func(ctx context.Context) error { event, err := p.events.LockEvent(ctx, organizationID, id) if err != nil { return err } if event.Status == status { return nil } allowed := map[string][]string{ constants.GameEventStatusDraft: {constants.GameEventStatusActive, constants.GameEventStatusCancelled}, constants.GameEventStatusActive: {constants.GameEventStatusEnded, constants.GameEventStatusCancelled}, }[event.Status] if !containsString(allowed, status) { return enakGameRejected("an event cannot go from %s to %s", event.Status, status) } if err := p.events.SetEventStatus(ctx, organizationID, id, status); err != nil { return err } return p.record(ctx, organizationID, actor, id, "STATUS_CHANGED", map[string]string{"status": event.Status}, map[string]string{"status": status}, in.Reason) }) if err != nil { return nil, err } return p.GetEvent(ctx, organizationID, id) } // checkReferences refuses a budget that is not an EVENT budget of the organization, // and games that are not the organization's or are archived. func (p *GameEventProcessor) checkReferences(ctx context.Context, organizationID, budgetID uuid.UUID, gameIDs []uuid.UUID) error { budget, err := p.budgets.GetBudget(ctx, organizationID, budgetID) if errors.Is(err, repository.ErrGameBudgetNotFound) { return enakGameRejected("budget_id is not a budget of this organization") } if err != nil { return err } if budget.Scope != constants.GameBudgetScopeEvent { return enakGameRejected("an event is paid by an EVENT budget, not a %s one", budget.Scope) } for _, gameID := range gameIDs { game, err := p.games.GetGame(ctx, organizationID, gameID) if errors.Is(err, repository.ErrEnakGameNotFound) { return enakGameRejected("game %s is not a game of this organization", gameID) } if err != nil { return err } if game.Status == constants.GameStatusArchived { return enakGameRejected("game %s is archived", gameID) } } return nil } func gameEventError(err error) error { if errors.Is(err, repository.ErrGameEventSlugTaken) { return enakGameRejected("another event already uses this slug") } return err } func isGameEventStatus(s string) bool { switch s { case constants.GameEventStatusDraft, constants.GameEventStatusActive, constants.GameEventStatusEnded, constants.GameEventStatusCancelled: return true } return false } // applyGameEventInput checks an input and copies it onto the event, leaving its // organization and status alone. It returns the games, without repeats. func applyGameEventInput(e *entities.GameEvent, in models.GameEventInput) ([]uuid.UUID, error) { name, slug := strings.TrimSpace(in.Name), strings.TrimSpace(in.Slug) timezone := strings.TrimSpace(in.Timezone) if timezone == "" { timezone = "Asia/Jakarta" } switch { case name == "" || len(name) > 255: return nil, enakGameRejected("name is required, at most 255 characters") case len(slug) > 100 || !enakGameSlugPattern.MatchString(slug): return nil, enakGameRejected("slug must be lowercase letters, digits and single dashes, at most 100 characters") case in.StartAt.IsZero() || !in.EndAt.After(in.StartAt): return nil, enakGameRejected("start_at is required and end_at must be after it") case in.Multiplier != nil && (*in.Multiplier < 1 || *in.Multiplier > gameEventMaxMultiplier): return nil, enakGameRejected("multiplier must be between 1 and %g", gameEventMaxMultiplier) case in.Multiplier != nil && math.Abs(*in.Multiplier*100-math.Round(*in.Multiplier*100)) > 1e-9: return nil, enakGameRejected("multiplier must have at most two decimals") case in.Bonus != nil && *in.Bonus < 1: return nil, enakGameRejected("bonus must be at least 1") case (in.Multiplier == nil || *in.Multiplier == 1) && in.Bonus == nil: return nil, enakGameRejected("an event needs a multiplier above 1 or a bonus") case in.RewardLimit != nil && *in.RewardLimit < 1: return nil, enakGameRejected("reward_limit must be at least 1, or left out for no limit") case in.UserDailyLimit != nil && *in.UserDailyLimit < 1: return nil, enakGameRejected("user_daily_limit must be at least 1, or left out for no limit") case in.BudgetID == uuid.Nil: return nil, enakGameRejected("budget_id is required") case len(in.GameIDs) == 0: return nil, enakGameRejected("game_ids needs at least one game") case in.BannerURL != nil && len(*in.BannerURL) > 500: return nil, enakGameRejected("banner_url must be at most 500 characters") } if _, err := time.LoadLocation(timezone); err != nil || len(timezone) > 50 { return nil, enakGameRejected("timezone %q is not a known time zone", timezone) } seen := map[uuid.UUID]bool{} var gameIDs []uuid.UUID for _, id := range in.GameIDs { if !seen[id] { seen[id] = true gameIDs = append(gameIDs, id) } } e.Name, e.Slug, e.Description, e.BannerURL = name, slug, in.Description, in.BannerURL e.StartAt, e.EndAt, e.Timezone, e.Priority = in.StartAt, in.EndAt, timezone, in.Priority e.Multiplier, e.Bonus, e.BudgetID = in.Multiplier, in.Bonus, in.BudgetID e.RewardLimit, e.UserDailyLimit = in.RewardLimit, in.UserDailyLimit return gameIDs, nil } func gameEventModel(e *entities.GameEvent, gameIDs []uuid.UUID) *models.GameEvent { if gameIDs == nil { gameIDs = []uuid.UUID{} } return &models.GameEvent{ ID: e.ID, Name: e.Name, Slug: e.Slug, Description: e.Description, BannerURL: e.BannerURL, StartAt: e.StartAt, EndAt: e.EndAt, Timezone: e.Timezone, Status: e.Status, Priority: e.Priority, Multiplier: e.Multiplier, Bonus: e.Bonus, BudgetID: e.BudgetID, RewardLimit: e.RewardLimit, UserDailyLimit: e.UserDailyLimit, GameIDs: gameIDs, CreatedAt: e.CreatedAt, UpdatedAt: e.UpdatedAt, } }