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
@@ -1,6 +1,6 @@
|
|||||||
# Integrasi EnakGame: Game Client (Phaser)
|
# 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
|
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
|
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) |
|
| 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) |
|
| 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 |
|
| 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
|
Alasan di balik aturannya ada di [`rfc-enakgame.md`](./rfc-enakgame.md) dan
|
||||||
@@ -106,6 +106,7 @@ memakai header `Authorization: Bearer <token>`. Request ber-body memakai
|
|||||||
| 3 | `POST /customer/enakgame/sessions/:id/complete` | – | `{ "score"?, "outcome"?, "data"? }` | Permainan selesai | §6.3 |
|
| 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 |
|
| 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 |
|
| 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
|
Game tidak memanggil endpoint lain. Registrasi, saldo, riwayat, voucher, dan PIN
|
||||||
adalah tugas aplikasi customer.
|
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).
|
- [ ] Game baru sudah didaftarkan bersama tim backoffice (§2.3); `slug` diperiksa saat `init` (§6.1).
|
||||||
- [ ] Body complete sesuai jenis hadiah game (§2.2).
|
- [ ] Body complete sesuai jenis hadiah game (§2.2).
|
||||||
@@ -616,5 +659,6 @@ saja.
|
|||||||
- [ ] Complete hanya mengirim `score` / `outcome` / `data`, tidak pernah hadiah.
|
- [ ] Complete hanya mengirim `score` / `outcome` / `data`, tidak pernah hadiah.
|
||||||
- [ ] Hadiah di layar dari `reward_total`; `limited_by` dan `REFUNDED` ditangani.
|
- [ ] Hadiah di layar dari `reward_total`; `limited_by` dan `REFUNDED` ditangani.
|
||||||
- [ ] Spin berhenti di `prize.entry`.
|
- [ ] 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.
|
- [ ] Complete diulang dengan aman saat gagal; timeout `expires_at` ditangani.
|
||||||
- [ ] `balance_changed` dikirim setelah start dan complete; `close` saat keluar.
|
- [ ] `balance_changed` dikirim setelah start dan complete; `close` saat keluar.
|
||||||
|
|||||||
@@ -843,6 +843,7 @@ tambahkan snapshot harian, bukan cache yang di-invalidate.
|
|||||||
| `POST` | `/sessions/:id/complete` | §7.2. Idempotent tanpa header |
|
| `POST` | `/sessions/:id/complete` | §7.2. Idempotent tanpa header |
|
||||||
| `GET` | `/sessions/:id` | Status dan hasil |
|
| `GET` | `/sessions/:id` | Status dan hasil |
|
||||||
| `GET` | `/sessions` | Riwayat main; filter opsional `game_id`, `status` (dipakai game untuk menemukan session `STARTED` setelah reload) |
|
| `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.
|
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
|
## 16. Di Luar Scope
|
||||||
|
|
||||||
- **Mission** (PRD §3.1, §19). PRD belum mendefinisikan aturannya.
|
- **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).
|
- **Reward `TIERED`** (§8).
|
||||||
- **Budget Controller automatic mode** (D9).
|
- **Budget Controller automatic mode** (D9).
|
||||||
- **Hosting dan build Phaser.** RFC ini hanya mendefinisikan API yang dipanggil game.
|
- **Hosting dan build Phaser.** RFC ini hanya mendefinisikan API yang dipanggil game.
|
||||||
|
|||||||
@@ -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)
|
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
|
// 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
|
// 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
|
// from the client, is ignored rather than refused (P1). An empty body is a result
|
||||||
|
|||||||
@@ -199,6 +199,34 @@ type GameSessionListQuery struct {
|
|||||||
Limit int `form:"limit"`
|
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
|
// 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).
|
// only. Anything else it sends, a reward amount above all, is ignored (P1).
|
||||||
type GameSessionCompleteInput struct {
|
type GameSessionCompleteInput struct {
|
||||||
|
|||||||
@@ -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
|
CreateSessionRewards(ctx context.Context, rewards []entities.GameSessionReward) error
|
||||||
ListSessionRewards(ctx context.Context, sessionID uuid.UUID) ([]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 {
|
type gameSessionRepository struct {
|
||||||
@@ -255,3 +281,45 @@ func (r *gameSessionRepository) ListDueSessions(ctx context.Context, now time.Ti
|
|||||||
}
|
}
|
||||||
return due, nil
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -197,6 +197,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
|||||||
enakGame.GET("/sessions", r.enakGameCustomerHandler.ListSessions)
|
enakGame.GET("/sessions", r.enakGameCustomerHandler.ListSessions)
|
||||||
enakGame.GET("/sessions/:id", r.enakGameCustomerHandler.GetSession)
|
enakGame.GET("/sessions/:id", r.enakGameCustomerHandler.GetSession)
|
||||||
enakGame.POST("/sessions/:id/complete", r.enakGameCustomerHandler.CompleteSession)
|
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
|
// Vouchers: what EnakPoint is spent on, whatever it came from (docs/rfc-enakgame.md
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ type EnakGameCustomerService interface {
|
|||||||
CompleteSession(ctx context.Context, customerID, sessionID uuid.UUID, in models.GameSessionCompleteInput) *contract.Response
|
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
|
ListSessions(ctx context.Context, customerID uuid.UUID, q models.GameSessionListQuery) *contract.Response
|
||||||
GetSession(ctx context.Context, customerID, sessionID uuid.UUID) *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
|
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
|
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)
|
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.
|
// decodeStrict reads a JSON body into v, refusing unknown fields and trailing data.
|
||||||
// It returns the error response, or nil when the body fits.
|
// It returns the error response, or nil when the body fits.
|
||||||
func decodeStrict(body []byte, v any) *contract.Response {
|
func decodeStrict(body []byte, v any) *contract.Response {
|
||||||
|
|||||||
Reference in New Issue
Block a user