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
@@ -78,6 +78,20 @@ func (h *EnakGameCustomerHandler) GetSession(c *gin.Context) {
util.HandleResponse(c.Writer, c.Request, h.service.GetSession(c.Request.Context(), customerID, id), method)
}
// Leaderboard is GET /customer/enakgame/leaderboard?date=&game_id=&limit=.
func (h *EnakGameCustomerHandler) Leaderboard(c *gin.Context) {
const method = "EnakGameCustomerHandler::Leaderboard"
customerID, ok := customerIDFromGin(c, method)
if !ok {
return
}
var q models.EnakGameLeaderboardQuery
if !bindQuery(c, &q, method) {
return
}
util.HandleResponse(c.Writer, c.Request, h.service.Leaderboard(c.Request.Context(), customerID, q), method)
}
// CompleteSession is POST /customer/enakgame/sessions/:id/complete. The body is read
// leniently on purpose: a reward amount, or anything else the backend does not take
// from the client, is ignored rather than refused (P1). An empty body is a result
+28
View File
@@ -199,6 +199,34 @@ type GameSessionListQuery struct {
Limit int `form:"limit"`
}
// EnakGameLeaderboardQuery picks a week of the leaderboard, and optionally one game.
type EnakGameLeaderboardQuery struct {
// Any day of the week, YYYY-MM-DD in Asia/Jakarta; this week when empty.
Date string `form:"date"`
GameID string `form:"game_id"`
Limit int `form:"limit"`
}
// EnakGameLeaderboard ranks the customers of an organization by the EnakCoin they won
// from games in one week, Monday to Sunday in Asia/Jakarta.
type EnakGameLeaderboard struct {
WeekStart string `json:"week_start"`
WeekEnd string `json:"week_end"`
GameID *uuid.UUID `json:"game_id"`
Entries []EnakGameLeaderboardEntry `json:"entries"`
// The customer asking, wherever they rank; null when they won nothing that week.
Me *EnakGameLeaderboardEntry `json:"me"`
}
// EnakGameLeaderboardEntry is one customer on the leaderboard. Other customers are
// shown by a masked name only.
type EnakGameLeaderboardEntry struct {
Rank int `json:"rank"`
Name string `json:"name"`
Coins int64 `json:"coins"`
IsMe bool `json:"is_me"`
}
// GameSessionCompleteInput is what the client reports at the end of a play (§7.2): data
// only. Anything else it sends, a reward amount above all, is ignored (P1).
type GameSessionCompleteInput struct {
+98
View File
@@ -0,0 +1,98 @@
package processor
import (
"context"
"strings"
"time"
"github.com/google/uuid"
"apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository"
)
const (
// leaderboardLimit is how many ranks a leaderboard shows unless asked, and
// leaderboardMaxLimit the most it shows.
leaderboardLimit = 10
leaderboardMaxLimit = 100
)
// Leaderboard ranks the customers of the customer's organization by the EnakCoin they
// won from games in one week, Monday to Sunday in Asia/Jakarta: this week, or the one
// holding q.Date. A session counts in the week it started. Refunded, expired and
// flagged sessions count for nothing.
func (p *GameSessionProcessor) Leaderboard(ctx context.Context, customerID uuid.UUID, q models.EnakGameLeaderboardQuery) (*models.EnakGameLeaderboard, error) {
day := walletDay(p.now())
if s := strings.TrimSpace(q.Date); s != "" {
d, err := time.ParseInLocation("2006-01-02", s, walletDisplayLocation)
if err != nil {
return nil, gameSessionRejected("date must be YYYY-MM-DD")
}
day = d
}
weekStart := leaderboardWeekStart(day)
filter := repository.LeaderboardFilter{
CustomerID: customerID, From: weekStart, To: weekStart.AddDate(0, 0, 7), Limit: q.Limit,
}
if filter.Limit < 1 || filter.Limit > leaderboardMaxLimit {
filter.Limit = leaderboardLimit
}
if s := strings.TrimSpace(q.GameID); s != "" {
id, err := uuid.Parse(s)
if err != nil {
return nil, gameSessionRejected("game_id must be a UUID")
}
filter.GameID = &id
}
customer, err := p.customers.GetCustomer(ctx, customerID)
if err != nil {
return nil, err
}
filter.OrganizationID = customer.OrganizationID
rows, err := p.sessions.Leaderboard(ctx, filter)
if err != nil {
return nil, err
}
board := &models.EnakGameLeaderboard{
WeekStart: weekStart.Format("2006-01-02"), WeekEnd: weekStart.AddDate(0, 0, 6).Format("2006-01-02"),
GameID: filter.GameID, Entries: []models.EnakGameLeaderboardEntry{},
}
for _, row := range rows {
entry := models.EnakGameLeaderboardEntry{
Rank: row.Rank, Name: maskCustomerName(row.Name), Coins: row.Coins, IsMe: row.CustomerID == customerID,
}
if row.Rank <= filter.Limit {
board.Entries = append(board.Entries, entry)
}
if entry.IsMe {
me := entry
board.Me = &me
}
}
return board, nil
}
// leaderboardWeekStart is the Monday, at midnight in Asia/Jakarta, of the week of day.
func leaderboardWeekStart(day time.Time) time.Time {
day = walletDay(day)
return day.AddDate(0, 0, -((int(day.Weekday()) + 6) % 7))
}
// maskCustomerName keeps the first word of a name and the first letter of every other
// word: "Budi Santoso" is shown as "Budi S******". A word with a digit, as when a phone
// number was used for the name, keeps only its first character, the first word too.
func maskCustomerName(name string) string {
words := strings.Fields(name)
if len(words) == 0 {
return "Pemain"
}
for i, w := range words {
if i == 0 && !strings.ContainsAny(w, "0123456789") {
continue
}
r := []rune(w)
words[i] = string(r[0]) + strings.Repeat("*", len(r)-1)
}
return strings.Join(words, " ")
}
@@ -0,0 +1,95 @@
package processor
import (
"fmt"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"apskel-pos-be/internal/models"
)
// The weekly leaderboard against Postgres. Needs TEST_DATABASE_URL; see
// internal/repository/wallet_repository_test.go.
func TestGameSession_Leaderboard(t *testing.T) {
e := newEnakGameEnv(t)
ctx := e.ctx()
dave := uuid.New()
require.NoError(t, e.db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'Dave Lee')`, dave, e.orgA).Error)
runner := e.playableGame(e.orgA, "runner", 1)
other := e.playableGame(e.orgA, "other", 1)
theirs := e.playableGame(e.orgB, "theirs", 1)
for _, c := range []uuid.UUID{e.alice, e.bob, e.carol, dave} {
e.coins(c, 100, nil)
}
n := 0
// play completes a session of game for customer and returns its id; every
// playableGame pays 10.
play := func(customer, game uuid.UUID) uuid.UUID {
t.Helper()
n++
s, err := e.sessions.Start(ctx, customer, game, fmt.Sprintf("k%d", n))
require.NoError(t, err)
_, err = e.sessions.Complete(ctx, customer, s.SessionID, models.GameSessionCompleteInput{})
require.NoError(t, err)
return s.SessionID
}
play(e.alice, runner.ID)
play(e.alice, other.ID)
bobs := play(e.bob, runner.ID)
play(dave, runner.ID)
play(e.carol, theirs.ID)
// Bob reached 10 before Dave did.
require.NoError(t, e.db.Exec(`UPDATE game_sessions SET ended_at = ended_at - INTERVAL '1 minute' WHERE id = ?`, bobs).Error)
flagged := play(e.bob, runner.ID)
require.NoError(t, e.db.Exec(`UPDATE game_sessions SET flagged = TRUE WHERE id = ?`, flagged).Error)
lastWeek := play(dave, runner.ID)
require.NoError(t, e.db.Exec(`UPDATE game_sessions SET started_at = started_at - INTERVAL '7 days' WHERE id = ?`, lastWeek).Error)
_, err := e.sessions.Start(ctx, e.alice, runner.ID, "open")
require.NoError(t, err, "a STARTED session counts for nothing")
board, err := e.sessions.Leaderboard(ctx, e.bob, models.EnakGameLeaderboardQuery{})
require.NoError(t, err)
week := leaderboardWeekStart(e.now)
assert.Equal(t, week.Format("2006-01-02"), board.WeekStart)
assert.Equal(t, week.AddDate(0, 0, 6).Format("2006-01-02"), board.WeekEnd)
assert.Equal(t, []models.EnakGameLeaderboardEntry{
{Rank: 1, Name: "Alice", Coins: 20},
{Rank: 2, Name: "Bob", Coins: 10, IsMe: true},
{Rank: 3, Name: "Dave L**", Coins: 10},
}, board.Entries, "the flagged play, last week's play and another organization's do not count")
require.NotNil(t, board.Me)
assert.Equal(t, 2, board.Me.Rank)
board, err = e.sessions.Leaderboard(ctx, dave, models.EnakGameLeaderboardQuery{Limit: 1})
require.NoError(t, err)
require.Len(t, board.Entries, 1)
assert.Equal(t, "Alice", board.Entries[0].Name)
require.NotNil(t, board.Me, "the customer asking is returned below the limit")
assert.Equal(t, models.EnakGameLeaderboardEntry{Rank: 3, Name: "Dave L**", Coins: 10, IsMe: true}, *board.Me)
board, err = e.sessions.Leaderboard(ctx, e.alice, models.EnakGameLeaderboardQuery{GameID: other.ID.String()})
require.NoError(t, err)
assert.Equal(t, []models.EnakGameLeaderboardEntry{{Rank: 1, Name: "Alice", Coins: 10, IsMe: true}}, board.Entries)
assert.Equal(t, &other.ID, board.GameID)
board, err = e.sessions.Leaderboard(ctx, dave, models.EnakGameLeaderboardQuery{Date: week.AddDate(0, 0, -3).Format("2006-01-02")})
require.NoError(t, err)
assert.Equal(t, []models.EnakGameLeaderboardEntry{{Rank: 1, Name: "Dave L**", Coins: 10, IsMe: true}}, board.Entries, "last week")
board, err = e.sessions.Leaderboard(ctx, e.carol, models.EnakGameLeaderboardQuery{})
require.NoError(t, err)
assert.Equal(t, []models.EnakGameLeaderboardEntry{{Rank: 1, Name: "Carol", Coins: 10, IsMe: true}}, board.Entries, "only the customer's organization")
board, err = e.sessions.Leaderboard(ctx, e.bob, models.EnakGameLeaderboardQuery{GameID: uuid.NewString(), Date: "2020-01-01"})
require.NoError(t, err)
assert.Empty(t, board.Entries)
assert.Nil(t, board.Me)
_, err = e.sessions.Leaderboard(ctx, e.bob, models.EnakGameLeaderboardQuery{Date: "12-10-2026"})
assert.ErrorIs(t, err, ErrGameSessionRejected)
_, err = e.sessions.Leaderboard(ctx, e.bob, models.EnakGameLeaderboardQuery{GameID: "runner"})
assert.ErrorIs(t, err, ErrGameSessionRejected)
}
@@ -0,0 +1,38 @@
package processor
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestMaskCustomerName(t *testing.T) {
for name, want := range map[string]string{
"Budi Santoso": "Budi S******",
"budi": "budi",
" Siti Nur Aisyah": "Siti N** A*****",
"Ádám Ő": "Ádám Ő",
"081234567890": "0***********",
"Budi2 Santoso": "B**** S******",
"": "Pemain",
" ": "Pemain",
} {
assert.Equal(t, want, maskCustomerName(name), "%q", name)
}
}
func TestLeaderboardWeekStart(t *testing.T) {
monday := time.Date(2026, 10, 12, 0, 0, 0, 0, walletDisplayLocation)
for _, day := range []time.Time{
monday,
time.Date(2026, 10, 14, 12, 0, 0, 0, walletDisplayLocation),
time.Date(2026, 10, 18, 23, 59, 59, 0, walletDisplayLocation),
// Sunday 17:00 UTC is already Monday 00:00 in Jakarta.
time.Date(2026, 10, 11, 17, 0, 0, 0, time.UTC),
} {
assert.True(t, monday.Equal(leaderboardWeekStart(day)), "%s", day)
}
// Sunday 16:59 UTC is still Sunday in Jakarta, the week before.
assert.True(t, monday.AddDate(0, 0, -7).Equal(leaderboardWeekStart(time.Date(2026, 10, 11, 16, 59, 0, 0, time.UTC))))
}
@@ -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
}
+1
View File
@@ -197,6 +197,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
enakGame.GET("/sessions", r.enakGameCustomerHandler.ListSessions)
enakGame.GET("/sessions/:id", r.enakGameCustomerHandler.GetSession)
enakGame.POST("/sessions/:id/complete", r.enakGameCustomerHandler.CompleteSession)
enakGame.GET("/leaderboard", r.enakGameCustomerHandler.Leaderboard)
}
// Vouchers: what EnakPoint is spent on, whatever it came from (docs/rfc-enakgame.md
+6
View File
@@ -74,6 +74,7 @@ type EnakGameCustomerService interface {
CompleteSession(ctx context.Context, customerID, sessionID uuid.UUID, in models.GameSessionCompleteInput) *contract.Response
ListSessions(ctx context.Context, customerID uuid.UUID, q models.GameSessionListQuery) *contract.Response
GetSession(ctx context.Context, customerID, sessionID uuid.UUID) *contract.Response
Leaderboard(ctx context.Context, customerID uuid.UUID, q models.EnakGameLeaderboardQuery) *contract.Response
ListVouchers(ctx context.Context, customerID uuid.UUID) *contract.Response
RedeemVoucher(ctx context.Context, customerID, voucherID uuid.UUID, req *contract.RedeemVoucherRequest, idempotencyKey string, info models.CustomerPinRequestInfo) *contract.Response
@@ -381,6 +382,11 @@ func (s *EnakGameCustomerServiceImpl) GetSession(ctx context.Context, customerID
return respond(ctx, session, err)
}
func (s *EnakGameCustomerServiceImpl) Leaderboard(ctx context.Context, customerID uuid.UUID, q models.EnakGameLeaderboardQuery) *contract.Response {
board, err := s.sessions.Leaderboard(ctx, customerID, q)
return respond(ctx, board, err)
}
// decodeStrict reads a JSON body into v, refusing unknown fields and trailing data.
// It returns the error response, or nil when the body fits.
func decodeStrict(body []byte, v any) *contract.Response {