feat(enakgame): weekly leaderboard

GET /customer/enakgame/leaderboard ranks the customers of an organization by
the EnakCoin they won from games in one week, Monday to Sunday in
Asia/Jakarta. Only completed, unflagged sessions count, by the week they
started; entry cost is not subtracted. Equal totals rank by who reached
theirs first, so no two customers share a rank.

Optional date (any day of the week, this week by default), game_id and limit
(10, at most 100). The response holds the top ranks and, under me, the
customer asking wherever they rank. Other customers are shown by a masked
name: the first word whole, the others by their first letter; a word with a
digit keeps only its first character.

The EnakGame web game shows it: integration-enakgame.md §10 (checklist now
§11) and RFC §11. The DB test needs TEST_DATABASE_URL.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-10-12 16:15:02 +07:00
co-authored by Claude Opus 5.5
parent c43baa53e1
commit 3af16a1fbc
10 changed files with 398 additions and 4 deletions
@@ -70,6 +70,32 @@ type GameSessionRepository interface {
CreateSessionRewards(ctx context.Context, rewards []entities.GameSessionReward) error
ListSessionRewards(ctx context.Context, sessionID uuid.UUID) ([]entities.GameSessionReward, error)
// Leaderboard ranks the active customers of an organization by the EnakCoin their
// completed, unflagged sessions started in [from, to) won, and returns the first
// limit ranks plus the row of filter.CustomerID wherever it ranks.
Leaderboard(ctx context.Context, filter LeaderboardFilter) ([]LeaderboardRow, error)
}
// LeaderboardFilter selects the sessions a leaderboard counts.
type LeaderboardFilter struct {
OrganizationID uuid.UUID
// Nil for every game.
GameID *uuid.UUID
From time.Time
To time.Time
Limit int
// The customer asking, returned even below Limit.
CustomerID uuid.UUID
}
// LeaderboardRow is one customer's total. Equal totals rank by who reached theirs
// first, so no two rows share a rank.
type LeaderboardRow struct {
Rank int
CustomerID uuid.UUID
Name string
Coins int64
}
type gameSessionRepository struct {
@@ -255,3 +281,45 @@ func (r *gameSessionRepository) ListDueSessions(ctx context.Context, now time.Ti
}
return due, nil
}
func (r *gameSessionRepository) Leaderboard(ctx context.Context, filter LeaderboardFilter) ([]LeaderboardRow, error) {
gameFilter, args := "", []any{filter.OrganizationID, filter.From, filter.To, constants.GameSessionStatusCompleted}
if filter.GameID != nil {
gameFilter = "AND s.game_id = ?"
args = append(args, *filter.GameID)
}
args = append(args, filter.Limit, filter.CustomerID)
var rows []struct {
Rank int
CustomerID string
Name string
Coins int64
}
err := DBFromContext(ctx, r.db).WithContext(ctx).Raw(`
WITH totals AS (
SELECT s.customer_id, SUM(s.reward_total) AS coins, MAX(s.ended_at) AS reached_at
FROM game_sessions s
WHERE s.organization_id = ? AND s.started_at >= ? AND s.started_at < ?
AND s.status = ? AND NOT s.flagged AND s.reward_total > 0 `+gameFilter+`
GROUP BY s.customer_id
), ranked AS (
SELECT t.customer_id, c.name, t.coins,
ROW_NUMBER() OVER (ORDER BY t.coins DESC, t.reached_at, t.customer_id) AS rank
FROM totals t
JOIN customers c ON c.id = t.customer_id AND c.is_active
)
SELECT rank, customer_id::text AS customer_id, name, coins
FROM ranked
WHERE rank <= ? OR customer_id = ?
ORDER BY rank`, args...).Scan(&rows).Error
if err != nil {
return nil, fmt.Errorf("failed to read game leaderboard: %w", err)
}
out := make([]LeaderboardRow, 0, len(rows))
for _, row := range rows {
l := LeaderboardRow{Rank: row.Rank, Name: row.Name, Coins: row.Coins}
l.CustomerID, _ = uuid.Parse(row.CustomerID)
out = append(out, l)
}
return out, nil
}