package repository import ( "context" "fmt" "time" "github.com/google/uuid" "gorm.io/gorm" ) // GameSessionStats is what an organization's sessions in a range add up to, for one // game, or for all of them when GameID is nil. type GameSessionStats struct { GameID *uuid.UUID GameName *string Plays int64 Completed int64 Refunded int64 Expired int64 Flagged int64 Players int64 AverageScore *float64 CoinIssued int64 EntryCost int64 CoinRefunded int64 } // WalletFlow is what one ledger type moved in one currency over a range. type WalletFlow struct { Currency string Type string Credit int64 Debit int64 Transactions int64 } // EnakGameAnalyticsRepository reads EnakGame analytics (docs/tasks-enakgame.md // EG-903, PRD ยง36) from the tables that hold the facts, on read. Ranges are [from, to). type EnakGameAnalyticsRepository interface { // SessionStats returns one row per game with a session started in the range, // most played first, then the row for all of them. gameID narrows it to one game. SessionStats(ctx context.Context, organizationID uuid.UUID, from, to time.Time, gameID *uuid.UUID) ([]GameSessionStats, error) // WalletFlows sums the organization's ledger in the range per currency and type. WalletFlows(ctx context.Context, organizationID uuid.UUID, from, to time.Time) ([]WalletFlow, error) // BalancesAt sums what the organization's customers held just before at. BalancesAt(ctx context.Context, organizationID uuid.UUID, at time.Time) (map[string]int64, error) } type enakGameAnalyticsRepository struct { db *gorm.DB } func NewEnakGameAnalyticsRepository(db *gorm.DB) EnakGameAnalyticsRepository { return &enakGameAnalyticsRepository{db: db} } func (r *enakGameAnalyticsRepository) SessionStats(ctx context.Context, organizationID uuid.UUID, from, to time.Time, gameID *uuid.UUID) ([]GameSessionStats, error) { filter := "" args := []any{organizationID, from, to} if gameID != nil { filter = "AND s.game_id = ?" args = append(args, *gameID) } // The empty grouping set adds the row for all games, also when there is no // session at all. var rows []GameSessionStats err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` SELECT s.game_id, g.name AS game_name, COUNT(*) AS plays, COUNT(*) FILTER (WHERE s.status = 'COMPLETED') AS completed, COUNT(*) FILTER (WHERE s.status = 'REFUNDED') AS refunded, COUNT(*) FILTER (WHERE s.status = 'EXPIRED') AS expired, COUNT(*) FILTER (WHERE s.flagged) AS flagged, COUNT(DISTINCT s.customer_id) AS players, AVG((s.result->>'score')::float8) FILTER (WHERE s.status = 'COMPLETED' AND jsonb_typeof(s.result->'score') = 'number') AS average_score, COALESCE(SUM(s.reward_total) FILTER (WHERE s.status = 'COMPLETED'), 0) AS coin_issued, COALESCE(SUM(s.entry_cost), 0) AS entry_cost, COALESCE(SUM(s.entry_cost) FILTER (WHERE s.status = 'REFUNDED'), 0) AS coin_refunded FROM game_sessions s JOIN games g ON g.id = s.game_id WHERE s.organization_id = ? AND s.started_at >= ? AND s.started_at < ? `+filter+` GROUP BY GROUPING SETS ((s.game_id, g.name), ()) ORDER BY GROUPING(s.game_id), plays DESC, g.name`, args...).Scan(&rows).Error if err != nil { return nil, fmt.Errorf("failed to read game session stats: %w", err) } return rows, nil } func (r *enakGameAnalyticsRepository) WalletFlows(ctx context.Context, organizationID uuid.UUID, from, to time.Time) ([]WalletFlow, error) { var rows []WalletFlow err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` SELECT currency, type, COALESCE(SUM(amount) FILTER (WHERE amount > 0), 0) AS credit, COALESCE(-SUM(amount) FILTER (WHERE amount < 0), 0) AS debit, COUNT(*) AS transactions FROM wallet_transactions WHERE organization_id = ? AND created_at >= ? AND created_at < ? GROUP BY currency, type ORDER BY currency, type`, organizationID, from, to).Scan(&rows).Error if err != nil { return nil, fmt.Errorf("failed to sum wallet flows: %w", err) } return rows, nil } func (r *enakGameAnalyticsRepository) BalancesAt(ctx context.Context, organizationID uuid.UUID, at time.Time) (map[string]int64, error) { // The ledger is signed and append-only, so what it adds up to before at is what // customers held then. var rows []struct { Currency string Total int64 } err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(` SELECT currency, COALESCE(SUM(amount), 0) AS total FROM wallet_transactions WHERE organization_id = ? AND created_at < ? GROUP BY currency`, organizationID, at).Scan(&rows).Error if err != nil { return nil, fmt.Errorf("failed to sum wallet balances: %w", err) } out := make(map[string]int64, len(rows)) for _, row := range rows { out[row.Currency] = row.Total } return out, nil }