package repository import ( "context" "errors" "fmt" "time" "github.com/google/uuid" "gorm.io/gorm" "apskel-pos-be/internal/constants" "apskel-pos-be/internal/entities" ) // ErrGameSessionNotFound means no such session, or not one of the customer's. var ErrGameSessionNotFound = errors.New("enakgame: session not found") // DueGameSession is a STARTED session the session job has to look at, with the status // of its game. type DueGameSession struct { ID uuid.UUID OrganizationID uuid.UUID CustomerID uuid.UUID GameStatus string } // GameSessionCompletion is what completing a session stores. type GameSessionCompletion struct { Result entities.JSONDocument RewardBreakdown entities.JSONDocument RewardTotal int64 Flagged bool EndedAt time.Time } // GameSessionRepository stores game sessions and the rewards they paid // (docs/rfc-enakgame.md §5.3, §5.4). // // A session leaves STARTED once: CompleteSession, RefundSession and ExpireSession are // each one UPDATE ... WHERE status = 'STARTED' and report whether they made the move, // so of two at once on the same session exactly one wins (D4). type GameSessionRepository interface { CreateSession(ctx context.Context, session *entities.GameSession) error GetSession(ctx context.Context, organizationID, id uuid.UUID) (*entities.GameSession, error) // GetCustomerSession returns ErrGameSessionNotFound for another customer's session. GetCustomerSession(ctx context.Context, customerID, id uuid.UUID) (*entities.GameSession, error) // GetSessionBySpendTransaction finds the session an entry cost debit paid for, to // replay a start sent twice. GetSessionBySpendTransaction(ctx context.Context, spendTransactionID uuid.UUID) (*entities.GameSession, error) // ListCustomerSessions returns a page of a customer's sessions, newest first, and // the total. ListCustomerSessions(ctx context.Context, filter CustomerSessionFilter) ([]entities.GameSession, int64, error) CompleteSession(ctx context.Context, id uuid.UUID, completion GameSessionCompletion) (bool, error) RefundSession(ctx context.Context, id, refundTransactionID uuid.UUID, reason string, endedAt time.Time) (bool, error) ExpireSession(ctx context.Context, id uuid.UUID, endedAt time.Time) (bool, error) // MarkCompletionFailed records that completing a STARTED session failed on a system // error (§7.3), and reports false when the session is no longer STARTED. MarkCompletionFailed(ctx context.Context, id uuid.UUID, at time.Time) (bool, error) // ListExpiredSessions returns STARTED sessions whose expiry is at or before now, // oldest expiry first. ListExpiredSessions(ctx context.Context, now time.Time, limit int) ([]entities.GameSession, error) // ListDueSessions returns STARTED sessions that have expired at now or whose game is // no longer ACTIVE (§7.3), oldest expiry first. ListDueSessions(ctx context.Context, now time.Time, limit int) ([]DueGameSession, error) // ListOpenSessionsByGame returns a game's STARTED sessions, oldest first. ListOpenSessionsByGame(ctx context.Context, organizationID, gameID uuid.UUID, limit int) ([]entities.GameSession, error) CreateSessionRewards(ctx context.Context, rewards []entities.GameSessionReward) error ListSessionRewards(ctx context.Context, sessionID uuid.UUID) ([]entities.GameSessionReward, error) } type gameSessionRepository struct { db *gorm.DB } func NewGameSessionRepository(db *gorm.DB) GameSessionRepository { return &gameSessionRepository{db: db} } func (r *gameSessionRepository) CreateSession(ctx context.Context, session *entities.GameSession) error { if session.Status == "" { session.Status = constants.GameSessionStatusStarted } if session.StartedAt.IsZero() { session.StartedAt = time.Now() } if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(session).Error; err != nil { return fmt.Errorf("failed to create game session: %w", err) } return nil } func (r *gameSessionRepository) first(q *gorm.DB) (*entities.GameSession, error) { var session entities.GameSession if err := q.First(&session).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return nil, ErrGameSessionNotFound } return nil, fmt.Errorf("failed to read game session: %w", err) } return &session, nil } func (r *gameSessionRepository) GetSession(ctx context.Context, organizationID, id uuid.UUID) (*entities.GameSession, error) { return r.first(DBFromContext(ctx, r.db).WithContext(ctx).Where("organization_id = ? AND id = ?", organizationID, id)) } func (r *gameSessionRepository) GetCustomerSession(ctx context.Context, customerID, id uuid.UUID) (*entities.GameSession, error) { return r.first(DBFromContext(ctx, r.db).WithContext(ctx).Where("customer_id = ? AND id = ?", customerID, id)) } func (r *gameSessionRepository) GetSessionBySpendTransaction(ctx context.Context, spendTransactionID uuid.UUID) (*entities.GameSession, error) { return r.first(DBFromContext(ctx, r.db).WithContext(ctx).Where("spend_transaction_id = ?", spendTransactionID)) } // CustomerSessionFilter selects a customer's sessions. type CustomerSessionFilter struct { CustomerID uuid.UUID // Nil for every game. GameID *uuid.UUID // Empty for every status. Status string Offset int Limit int } func (r *gameSessionRepository) ListCustomerSessions(ctx context.Context, filter CustomerSessionFilter) ([]entities.GameSession, int64, error) { q := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GameSession{}).Where("customer_id = ?", filter.CustomerID) if filter.GameID != nil { q = q.Where("game_id = ?", *filter.GameID) } if filter.Status != "" { q = q.Where("status = ?", filter.Status) } offset, limit := filter.Offset, filter.Limit var total int64 if err := q.Count(&total).Error; err != nil { return nil, 0, fmt.Errorf("failed to count game sessions: %w", err) } var sessions []entities.GameSession if err := q.Order("started_at DESC, id").Offset(offset).Limit(limit).Find(&sessions).Error; err != nil { return nil, 0, fmt.Errorf("failed to list game sessions: %w", err) } return sessions, total, nil } // leaveStarted runs one conditional move out of STARTED and reports whether it // happened. func (r *gameSessionRepository) leaveStarted(ctx context.Context, id uuid.UUID, set string, args ...any) (bool, error) { args = append(args, id, constants.GameSessionStatusStarted) result := DBFromContext(ctx, r.db).WithContext(ctx).Exec( `UPDATE game_sessions SET `+set+` WHERE id = ? AND status = ?`, args...) if result.Error != nil { return false, fmt.Errorf("failed to update game session: %w", result.Error) } return result.RowsAffected == 1, nil } func (r *gameSessionRepository) CompleteSession(ctx context.Context, id uuid.UUID, c GameSessionCompletion) (bool, error) { return r.leaveStarted(ctx, id, `status = ?, result = ?::jsonb, reward_breakdown = ?::jsonb, reward_total = ?, flagged = ?, ended_at = ?`, constants.GameSessionStatusCompleted, c.Result, c.RewardBreakdown, c.RewardTotal, c.Flagged, c.EndedAt) } func (r *gameSessionRepository) RefundSession(ctx context.Context, id, refundTransactionID uuid.UUID, reason string, endedAt time.Time) (bool, error) { return r.leaveStarted(ctx, id, `status = ?, refund_transaction_id = ?, refund_reason = ?, ended_at = ?`, constants.GameSessionStatusRefunded, refundTransactionID, reason, endedAt) } func (r *gameSessionRepository) ExpireSession(ctx context.Context, id uuid.UUID, endedAt time.Time) (bool, error) { return r.leaveStarted(ctx, id, `status = ?, ended_at = ?`, constants.GameSessionStatusExpired, endedAt) } func (r *gameSessionRepository) MarkCompletionFailed(ctx context.Context, id uuid.UUID, at time.Time) (bool, error) { result := DBFromContext(ctx, r.db).WithContext(ctx).Exec(` UPDATE game_sessions SET completion_failed_at = ? WHERE id = ? AND status = ?`, at, id, constants.GameSessionStatusStarted) if result.Error != nil { return false, fmt.Errorf("failed to mark game session: %w", result.Error) } return result.RowsAffected == 1, nil } func (r *gameSessionRepository) ListExpiredSessions(ctx context.Context, now time.Time, limit int) ([]entities.GameSession, error) { var sessions []entities.GameSession err := DBFromContext(ctx, r.db).WithContext(ctx). Where("status = ? AND expires_at <= ?", constants.GameSessionStatusStarted, now). Order("expires_at, id").Limit(limit).Find(&sessions).Error if err != nil { return nil, fmt.Errorf("failed to list expired game sessions: %w", err) } return sessions, nil } func (r *gameSessionRepository) ListOpenSessionsByGame(ctx context.Context, organizationID, gameID uuid.UUID, limit int) ([]entities.GameSession, error) { var sessions []entities.GameSession err := DBFromContext(ctx, r.db).WithContext(ctx). Where("organization_id = ? AND game_id = ? AND status = ?", organizationID, gameID, constants.GameSessionStatusStarted). Order("started_at, id").Limit(limit).Find(&sessions).Error if err != nil { return nil, fmt.Errorf("failed to list open game sessions: %w", err) } return sessions, nil } func (r *gameSessionRepository) CreateSessionRewards(ctx context.Context, rewards []entities.GameSessionReward) error { if len(rewards) == 0 { return nil } if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(&rewards).Error; err != nil { return fmt.Errorf("failed to record game session rewards: %w", err) } return nil } func (r *gameSessionRepository) ListSessionRewards(ctx context.Context, sessionID uuid.UUID) ([]entities.GameSessionReward, error) { var rewards []entities.GameSessionReward err := DBFromContext(ctx, r.db).WithContext(ctx).Where("session_id = ?", sessionID).Order("budget_id").Find(&rewards).Error if err != nil { return nil, fmt.Errorf("failed to list game session rewards: %w", err) } return rewards, nil } func (r *gameSessionRepository) ListDueSessions(ctx context.Context, now time.Time, limit int) ([]DueGameSession, error) { var rows []struct { ID string OrganizationID string CustomerID string GameStatus string } err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` SELECT s.id::text AS id, s.organization_id::text AS organization_id, s.customer_id::text AS customer_id, g.status AS game_status FROM game_sessions s JOIN games g ON g.id = s.game_id WHERE s.status = ? AND (s.expires_at <= ? OR g.status <> ?) ORDER BY s.expires_at, s.id LIMIT ?`, constants.GameSessionStatusStarted, now, constants.GameStatusActive, limit).Scan(&rows).Error if err != nil { return nil, fmt.Errorf("failed to list due game sessions: %w", err) } due := make([]DueGameSession, 0, len(rows)) for _, row := range rows { d := DueGameSession{GameStatus: row.GameStatus} d.ID, _ = uuid.Parse(row.ID) d.OrganizationID, _ = uuid.Parse(row.OrganizationID) d.CustomerID, _ = uuid.Parse(row.CustomerID) due = append(due, d) } return due, nil }