diff --git a/docs/integration-enakgame.md b/docs/integration-enakgame.md index 3a64c40..61a876e 100644 --- a/docs/integration-enakgame.md +++ b/docs/integration-enakgame.md @@ -1,6 +1,6 @@ # Integrasi EnakGame: Game Client (Phaser) -**Untuk:** tim game EnakGame (client Phaser) · **Base URL:** `/api/v1` · **Per:** 9 Okt 2026 +**Untuk:** tim game EnakGame (client Phaser) · **Base URL:** `/api/v1` · **Per:** 12 Okt 2026 Kamu mengerjakan **game EnakGame**: game web (Phaser) yang dibuka aplikasi customer di dalam webview dari `game_url` sebuah game. Game inilah yang menjalankan satu kali main @@ -13,7 +13,7 @@ Pembagian tugas dengan aplikasi customer: | Aplikasi customer ([`integration-mobile-customer.md`](./integration-mobile-customer.md)) | Game EnakGame (dokumen ini) | |---|---| | Login customer, menyimpan token | Menerima token dari aplikasi lewat bridge (§4); bila tidak ada, meminta customer login (§5) | -| Daftar game, membuka `game_url` di webview | Start session, main, complete, tampilkan hadiah | +| Daftar game, membuka `game_url` di webview | Start session, main, complete, tampilkan hadiah, leaderboard mingguan (§10) | | Saldo, riwayat, voucher, PIN | Memberi tahu aplikasi saat saldo berubah atau game ditutup | Alasan di balik aturannya ada di [`rfc-enakgame.md`](./rfc-enakgame.md) dan @@ -106,6 +106,7 @@ memakai header `Authorization: Bearer `. Request ber-body memakai | 3 | `POST /customer/enakgame/sessions/:id/complete` | – | `{ "score"?, "outcome"?, "data"? }` | Permainan selesai | §6.3 | | 4 | `GET /customer/enakgame/sessions/:id` | – | – | Pemulihan setelah reload, bila `session_id` tersimpan | §6.4 | | 5 | `GET /customer/enakgame/sessions?game_id=&status=&page=&limit=` | – | – | Pemulihan setelah reload, bila `session_id` tidak tersimpan | §6.4 | +| 6 | `GET /customer/enakgame/leaderboard?date=&game_id=&limit=` | – | – | Layar leaderboard; muat ulang setelah complete | §10 | Game tidak memanggil endpoint lain. Registrasi, saldo, riwayat, voucher, dan PIN adalah tugas aplikasi customer. @@ -601,7 +602,49 @@ saja. --- -## 10. Checklist +## 10. Leaderboard mingguan — `GET /customer/enakgame/leaderboard` + +Peringkat customer satu organisasi menurut EnakCoin yang dimenangkan dari game dalam +satu minggu, Senin 00:00 sampai Minggu 23:59 WIB. Yang dihitung hanya session +`COMPLETED`, menurut minggu saat session dimulai; entry cost tidak mengurangi. Total +yang sama diurutkan menurut siapa yang mencapainya lebih dulu, jadi tidak ada peringkat +kembar. + +| Query | Default | Keterangan | +|---|---|---| +| `date` | Minggu ini | Hari mana pun dalam minggu yang diminta, `YYYY-MM-DD`. Untuk minggu lalu, kirim tanggal 7 hari sebelum `week_start` minggu ini | +| `game_id` | Semua game | Leaderboard satu game, mis. `game_id` dari `init` untuk "peringkat game ini" | +| `limit` | 10 | Jumlah peringkat teratas, maks 100 | + +```json +{ + "week_start": "2026-10-12", + "week_end": "2026-10-18", + "game_id": null, + "entries": [ + { "rank": 1, "name": "Budi S******", "coins": 120, "is_me": false }, + { "rank": 2, "name": "Siti N** A*****", "coins": 90, "is_me": true } + ], + "me": { "rank": 2, "name": "Siti N** A*****", "coins": 90, "is_me": true } +} +``` + +- Nama customer lain sudah disamarkan server: kata pertama utuh, kata berikutnya hanya + huruf pertama. Tampilkan apa adanya; untuk baris `is_me: true` tampilkan "Kamu". +- `me` adalah posisi customer yang login, juga bila di luar `limit`: tampilkan sebagai + baris tersendiri di bawah daftar. `null` bila belum menang EnakCoin minggu itu + ("Main untuk masuk leaderboard"). +- `entries` kosong (`[]`, tidak pernah `null`) bila belum ada yang menang minggu itu. +- Tampilkan rentang minggu dari `week_start`–`week_end`; jangan menghitung minggu + sendiri dari jam perangkat. +- Leaderboard tidak berubah sendiri. Muat ulang setelah complete berhasil, atau saat + layar leaderboard dibuka. +- `date` yang bukan `YYYY-MM-DD` atau `game_id` yang bukan UUID ditolak `304` (bug di + game; pesan umum). + +--- + +## 11. Checklist - [ ] Game baru sudah didaftarkan bersama tim backoffice (§2.3); `slug` diperiksa saat `init` (§6.1). - [ ] Body complete sesuai jenis hadiah game (§2.2). @@ -616,5 +659,6 @@ saja. - [ ] Complete hanya mengirim `score` / `outcome` / `data`, tidak pernah hadiah. - [ ] Hadiah di layar dari `reward_total`; `limited_by` dan `REFUNDED` ditangani. - [ ] Spin berhenti di `prize.entry`. +- [ ] Leaderboard (§10): rentang minggu dari `week_start`/`week_end`, baris `is_me` sebagai "Kamu", `me` di luar `limit` tampil di bawah daftar, dimuat ulang setelah complete. - [ ] Complete diulang dengan aman saat gagal; timeout `expires_at` ditangani. - [ ] `balance_changed` dikirim setelah start dan complete; `close` saat keluar. diff --git a/docs/rfc-enakgame.md b/docs/rfc-enakgame.md index b2f8c8b..af68df6 100644 --- a/docs/rfc-enakgame.md +++ b/docs/rfc-enakgame.md @@ -843,6 +843,7 @@ tambahkan snapshot harian, bukan cache yang di-invalidate. | `POST` | `/sessions/:id/complete` | §7.2. Idempotent tanpa header | | `GET` | `/sessions/:id` | Status dan hasil | | `GET` | `/sessions` | Riwayat main; filter opsional `game_id`, `status` (dipakai game untuk menemukan session `STARTED` setelah reload) | +| `GET` | `/leaderboard` | Peringkat mingguan (Senin–Minggu, Asia/Jakarta) org customer menurut EnakCoin yang dimenangkan dari game. Query opsional `date` (hari mana pun dalam minggu itu), `game_id`, `limit` (default 10, maks 100) | Prefix `/enakgame` dipakai karena `/customer/games` sudah dipakai alur spin lama. @@ -940,7 +941,8 @@ Ditemukan saat memetakan code. Tidak memblokir RFC ini, tapi sebagian berdampak ## 16. Di Luar Scope - **Mission** (PRD §3.1, §19). PRD belum mendefinisikan aturannya. -- **Leaderboard** (PRD §15). +- **Leaderboard event** (PRD §15). Leaderboard mingguan umum sudah ada (§11, ditambahkan + 2026-10-12); leaderboard per event dengan aturannya sendiri belum. - **Reward `TIERED`** (§8). - **Budget Controller automatic mode** (D9). - **Hosting dan build Phaser.** RFC ini hanya mendefinisikan API yang dipanggil game. diff --git a/internal/handler/enakgame_customer_handler.go b/internal/handler/enakgame_customer_handler.go index 9eafccb..b48f480 100644 --- a/internal/handler/enakgame_customer_handler.go +++ b/internal/handler/enakgame_customer_handler.go @@ -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 diff --git a/internal/models/enakgame.go b/internal/models/enakgame.go index aaa1207..7f441a5 100644 --- a/internal/models/enakgame.go +++ b/internal/models/enakgame.go @@ -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 { diff --git a/internal/processor/game_leaderboard.go b/internal/processor/game_leaderboard.go new file mode 100644 index 0000000..8e7acf8 --- /dev/null +++ b/internal/processor/game_leaderboard.go @@ -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, " ") +} diff --git a/internal/processor/game_leaderboard_db_test.go b/internal/processor/game_leaderboard_db_test.go new file mode 100644 index 0000000..6a11d93 --- /dev/null +++ b/internal/processor/game_leaderboard_db_test.go @@ -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) +} diff --git a/internal/processor/game_leaderboard_test.go b/internal/processor/game_leaderboard_test.go new file mode 100644 index 0000000..ca40b36 --- /dev/null +++ b/internal/processor/game_leaderboard_test.go @@ -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)))) +} diff --git a/internal/repository/game_session_repository.go b/internal/repository/game_session_repository.go index e5bb062..d604c50 100644 --- a/internal/repository/game_session_repository.go +++ b/internal/repository/game_session_repository.go @@ -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 +} diff --git a/internal/router/router.go b/internal/router/router.go index eaa345e..5f9fd0d 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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 diff --git a/internal/service/enakgame_service.go b/internal/service/enakgame_service.go index dbc2767..f6feb3c 100644 --- a/internal/service/enakgame_service.go +++ b/internal/service/enakgame_service.go @@ -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 {