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:
co-authored by
Claude Opus 5.5
parent
c43baa53e1
commit
3af16a1fbc
@@ -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))))
|
||||
}
|
||||
Reference in New Issue
Block a user