Merge pull request 'feat(enakgame): filter play history by game and status' (#45) from feat/enakgame into staging

Reviewed-on: https://gits.altru.id/apksel-dev/apskel-pos-backend/pulls/45
This commit was merged in pull request #45.
This commit is contained in:
2026-10-09 15:24:14 +02:00
10 changed files with 129 additions and 24 deletions
+40 -6
View File
@@ -30,7 +30,8 @@ Alasan di balik aturannya ada di [`rfc-enakgame.md`](./rfc-enakgame.md) dan
selalu `reward_total` dari backend. selalu `reward_total` dari backend.
3. **Satu tap "Main" = satu `Idempotency-Key`.** Retry memakai key yang sama. 3. **Satu tap "Main" = satu `Idempotency-Key`.** Retry memakai key yang sama.
4. **Token customer adalah rahasia.** Hanya diterima lewat bridge, disimpan di memori, 4. **Token customer adalah rahasia.** Hanya diterima lewat bridge, disimpan di memori,
tidak pernah ditaruh di URL, `localStorage`, cookie, log, atau analytics. tidak pernah ditaruh di URL, `localStorage`, `sessionStorage`, cookie, log, atau
analytics. (`session_id` boleh disimpan di `sessionStorage`, §4.4.)
5. **Semua jumlah bilangan bulat.** Tidak ada pecahan EnakCoin. 5. **Semua jumlah bilangan bulat.** Tidak ada pecahan EnakCoin.
6. **Main game tidak butuh PIN.** 6. **Main game tidak butuh PIN.**
@@ -94,7 +95,8 @@ tunggu `token`, lalu ulangi request yang sama.
## 4. Alur satu kali main ## 4. Alur satu kali main
``` ```
init ─► GET /customer/enakgame/games ─► tampilkan biaya (dan roda, untuk spin) init ─► cek session yang masih berjalan (§4.4)
─► GET /customer/enakgame/games ─► tampilkan biaya (dan roda, untuk spin)
─► tap Main ─► POST /customer/enakgame/sessions (EnakCoin dipotong) ─► tap Main ─► POST /customer/enakgame/sessions (EnakCoin dipotong)
─► permainan berjalan (batas waktu: expires_at) ─► permainan berjalan (batas waktu: expires_at)
─► POST /customer/enakgame/sessions/:id/complete (server menghitung hadiah) ─► POST /customer/enakgame/sessions/:id/complete (server menghitung hadiah)
@@ -227,9 +229,13 @@ sepakati daftarnya dengan tim backoffice.
| `310` | `score` bukan bilangan bulat atau `outcome` bukan string | Bug di game | | `310` | `score` bukan bilangan bulat atau `outcome` bukan string | Bug di game |
| `404` | Session tidak ada / milik customer lain | Pesan umum | | `404` | Session tidak ada / milik customer lain | Pesan umum |
### 4.4 Cek status — `GET /customer/enakgame/sessions/:id` ### 4.4 Pemulihan setelah reload
Untuk memulihkan keadaan, mis. game dimuat ulang saat session masih berjalan: Webview bisa memuat ulang halaman game (aplikasi ke background, memori habis, crash)
saat customer sedang main. EnakCoin sudah terpotong, jadi game wajib menemukan lagi
session-nya. Dua endpoint dipakai:
**Satu session** — `GET /customer/enakgame/sessions/:id`
```json ```json
{ {
@@ -238,8 +244,35 @@ Untuk memulihkan keadaan, mis. game dimuat ulang saat session masih berjalan:
} }
``` ```
`status`: `STARTED`, `COMPLETED`, `REFUNDED`, atau `EXPIRED`. Riwayat main customer ada `status`: `STARTED`, `COMPLETED`, `REFUNDED`, atau `EXPIRED`. Response ini tidak memuat
di `GET /customer/enakgame/sessions?page=1&limit=20` (dipakai aplikasi, bukan game). `prize` atau rincian hadiah; untuk itu kirim ulang complete (lihat di bawah).
**Mencari session** — `GET /customer/enakgame/sessions?game_id=8a1f…&status=STARTED&limit=1`
Bentuk item sama dengan di atas, dibungkus `data` + `pagination`, terbaru di atas.
Semua query opsional: `game_id`, `status` (`STARTED`, `COMPLETED`, `REFUNDED`,
`EXPIRED`), `page`, `limit`. `status` atau `game_id` yang tidak valid ditolak `304`.
**Alurnya, setiap kali menerima `init`:**
1. Simpan `session_id` di **`sessionStorage`** setiap kali start berhasil, dan hapus
setelah hasilnya ditampilkan. `session_id` bukan rahasia; token tetap hanya di
memori (§1).
2. Bila ada `session_id` tersimpan, panggil `GET /sessions/:id`. Bila tidak ada (mis.
webview dibuka ulang dari awal), panggil
`GET /sessions?game_id=<game_id>&status=STARTED&limit=1`.
3. Tindak lanjuti sesuai status:
| Keadaan | Yang dilakukan game |
|---|---|
| `STARTED`, sekarang sebelum `expires_at` | **Lanjutkan** session itu: jangan start baru (EnakCoin akan terpotong lagi). Spin: langsung kirim complete `{}` dan tampilkan hasilnya. Game lain: progres main hilang, jadi mulai ulang permainan di session yang sama dengan timer sampai `expires_at`, lalu kirim complete |
| `STARTED`, `expires_at` sudah lewat | Anggap selesai. Server mengubahnya menjadi `EXPIRED` (atau merefund bila complete sebelumnya gagal karena error server) dalam ±1 menit. Tampilkan "Waktu bermain habis", lalu customer boleh start baru |
| `COMPLETED` | Hasil sudah dihitung tapi mungkin belum ditampilkan. Kirim ulang `POST /sessions/:id/complete` dengan body apa saja (`{}`): server mengembalikan jawaban yang sama persis, termasuk `prize`, tanpa hadiah dobel. Tampilkan hasilnya |
| `REFUNDED` | "EnakCoin kamu dikembalikan." |
| `EXPIRED` | "Waktu bermain habis." |
| Tidak ada session | Tampilkan layar awal seperti biasa |
Riwayat main lengkap (tanpa filter) dipakai aplikasi customer, bukan game.
--- ---
@@ -289,6 +322,7 @@ saja.
- [ ] Bridge sesuai kontrak §2 yang sudah disepakati dengan tim aplikasi. - [ ] Bridge sesuai kontrak §2 yang sudah disepakati dengan tim aplikasi.
- [ ] Token hanya di memori; tidak ada di URL, storage, log, atau analytics. - [ ] Token hanya di memori; tidak ada di URL, storage, log, atau analytics.
- [ ] Pemulihan setelah reload (§4.4): session `STARTED` dilanjutkan, bukan start baru; `COMPLETED` ditampilkan lewat complete ulang.
- [ ] Biaya main dan label event tampil sebelum main. - [ ] Biaya main dan label event tampil sebelum main.
- [ ] Satu `Idempotency-Key` per tap Main, dipakai ulang saat retry. - [ ] Satu `Idempotency-Key` per tap Main, dipakai ulang saat retry.
- [ ] Complete hanya mengirim `score` / `outcome` / `data`, tidak pernah hadiah. - [ ] Complete hanya mengirim `score` / `outcome` / `data`, tidak pernah hadiah.
+3
View File
@@ -616,6 +616,9 @@ kembali", lalu tutup. Tidak perlu mengirim pesan ke game.
### 8.4 Riwayat main — `GET /customer/enakgame/sessions?page=1&limit=20` ### 8.4 Riwayat main — `GET /customer/enakgame/sessions?page=1&limit=20`
Query opsional `game_id` (riwayat satu game) dan `status` (`STARTED`, `COMPLETED`,
`REFUNDED`, `EXPIRED`) untuk filter atau tab.
```json ```json
{ {
"data": [ "data": [
+1 -1
View File
@@ -842,7 +842,7 @@ tambahkan snapshot harian, bukan cache yang di-invalidate.
| `POST` | `/sessions` | §7.1. Wajib `Idempotency-Key` | | `POST` | `/sessions` | §7.1. Wajib `Idempotency-Key` |
| `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 | | `GET` | `/sessions` | Riwayat main; filter opsional `game_id`, `status` (dipakai game untuk menemukan session `STARTED` setelah reload) |
Prefix `/enakgame` dipakai karena `/customer/games` sudah dipakai alur spin lama. Prefix `/enakgame` dipakai karena `/customer/games` sudah dipakai alur spin lama.
@@ -50,16 +50,18 @@ func (h *EnakGameCustomerHandler) StartSession(c *gin.Context) {
util.HandleResponse(c.Writer, c.Request, h.service.StartSession(c.Request.Context(), customerID, &req, idempotencyKey(c)), method) util.HandleResponse(c.Writer, c.Request, h.service.StartSession(c.Request.Context(), customerID, &req, idempotencyKey(c)), method)
} }
// ListSessions is GET /customer/enakgame/sessions?page=&limit=. // ListSessions is GET /customer/enakgame/sessions?game_id=&status=&page=&limit=.
func (h *EnakGameCustomerHandler) ListSessions(c *gin.Context) { func (h *EnakGameCustomerHandler) ListSessions(c *gin.Context) {
const method = "EnakGameCustomerHandler::ListSessions" const method = "EnakGameCustomerHandler::ListSessions"
customerID, ok := customerIDFromGin(c, method) customerID, ok := customerIDFromGin(c, method)
if !ok { if !ok {
return return
} }
page, _ := strconv.Atoi(c.Query("page")) var q models.GameSessionListQuery
limit, _ := strconv.Atoi(c.Query("limit")) if !bindQuery(c, &q, method) {
util.HandleResponse(c.Writer, c.Request, h.service.ListSessions(c.Request.Context(), customerID, page, limit), method) return
}
util.HandleResponse(c.Writer, c.Request, h.service.ListSessions(c.Request.Context(), customerID, q), method)
} }
// GetSession is GET /customer/enakgame/sessions/:id. // GetSession is GET /customer/enakgame/sessions/:id.
+10
View File
@@ -189,6 +189,16 @@ type CustomerGameSession struct {
RefundReason *string `json:"refund_reason"` RefundReason *string `json:"refund_reason"`
} }
// GameSessionListQuery filters a customer's play history. The game client uses
// game_id with status=STARTED to find the play it was running before a reload.
type GameSessionListQuery struct {
GameID string `form:"game_id"`
// STARTED, COMPLETED, REFUNDED or EXPIRED; empty for all.
Status string `form:"status"`
Page int `form:"page"`
Limit int `form:"limit"`
}
// 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 {
+24 -2
View File
@@ -675,12 +675,34 @@ func TestGameSession_CustomerReads(t *testing.T) {
_, err = e.sessions.GetSession(ctx, e.bob, started.SessionID) _, err = e.sessions.GetSession(ctx, e.bob, started.SessionID)
assert.ErrorIs(t, err, repository.ErrGameSessionNotFound, "another customer's session") assert.ErrorIs(t, err, repository.ErrGameSessionNotFound, "another customer's session")
page, err := e.sessions.ListSessions(ctx, e.alice, 1, 10) page, err := e.sessions.ListSessions(ctx, e.alice, models.GameSessionListQuery{Page: 1, Limit: 10})
require.NoError(t, err) require.NoError(t, err)
assert.EqualValues(t, 1, page.Pagination.Total) assert.EqualValues(t, 1, page.Pagination.Total)
page, err = e.sessions.ListSessions(ctx, e.bob, 1, 10) page, err = e.sessions.ListSessions(ctx, e.bob, models.GameSessionListQuery{Page: 1, Limit: 10})
require.NoError(t, err) require.NoError(t, err)
assert.Empty(t, page.Data) assert.Empty(t, page.Data)
// After a reload, the game finds the play it was running by game and status.
other := e.playableGame(e.orgA, "other", 1)
otherStarted, err := e.sessions.Start(ctx, e.alice, other.ID, "k2")
require.NoError(t, err)
_, err = e.sessions.Complete(ctx, e.alice, otherStarted.SessionID, models.GameSessionCompleteInput{})
require.NoError(t, err)
running, err := e.sessions.ListSessions(ctx, e.alice, models.GameSessionListQuery{GameID: game.ID.String(), Status: "started"})
require.NoError(t, err)
require.Len(t, running.Data, 1)
assert.Equal(t, started.SessionID, running.Data[0].ID)
running, err = e.sessions.ListSessions(ctx, e.alice, models.GameSessionListQuery{GameID: other.ID.String(), Status: constants.GameSessionStatusStarted})
require.NoError(t, err)
assert.Empty(t, running.Data, "the other game's play is completed")
all, err := e.sessions.ListSessions(ctx, e.alice, models.GameSessionListQuery{})
require.NoError(t, err)
assert.EqualValues(t, 2, all.Pagination.Total)
_, err = e.sessions.ListSessions(ctx, e.alice, models.GameSessionListQuery{Status: "PLAYING"})
assert.ErrorIs(t, err, ErrGameSessionRejected)
_, err = e.sessions.ListSessions(ctx, e.alice, models.GameSessionListQuery{GameID: "runner"})
assert.ErrorIs(t, err, ErrGameSessionRejected)
} }
// gameWith makes an ACTIVE game of org A with the given result rules and an active // gameWith makes an ACTIVE game of org A with the given result rules and an active
+20 -4
View File
@@ -419,10 +419,26 @@ func (p *GameSessionProcessor) ListGames(ctx context.Context, customerID uuid.UU
return out, nil return out, nil
} }
// ListSessions returns a page of the customer's sessions, newest first. // ListSessions returns a page of the customer's sessions, newest first, of one game
func (p *GameSessionProcessor) ListSessions(ctx context.Context, customerID uuid.UUID, page, limit int) (*models.PaginatedResponse[models.CustomerGameSession], error) { // or status when asked.
page, limit = enakGamePage(page, limit) func (p *GameSessionProcessor) ListSessions(ctx context.Context, customerID uuid.UUID, q models.GameSessionListQuery) (*models.PaginatedResponse[models.CustomerGameSession], error) {
sessions, total, err := p.sessions.ListCustomerSessions(ctx, customerID, (page-1)*limit, limit) page, limit := enakGamePage(q.Page, q.Limit)
filter := repository.CustomerSessionFilter{CustomerID: customerID, Offset: (page - 1) * limit, Limit: limit}
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
}
switch status := strings.ToUpper(strings.TrimSpace(q.Status)); status {
case "", constants.GameSessionStatusStarted, constants.GameSessionStatusCompleted,
constants.GameSessionStatusRefunded, constants.GameSessionStatusExpired:
filter.Status = status
default:
return nil, gameSessionRejected("status must be STARTED, COMPLETED, REFUNDED or EXPIRED")
}
sessions, total, err := p.sessions.ListCustomerSessions(ctx, filter)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -287,7 +287,7 @@ func TestGameSessionRepository_ReadsAndMoves(t *testing.T) {
assert.True(t, got.Flagged) assert.True(t, got.Flagged)
assert.NotNil(t, got.CompletionFailedAt) assert.NotNil(t, got.CompletionFailedAt)
sessions, total, err := f.sessions.ListCustomerSessions(ctx, f.customer, 0, 1) sessions, total, err := f.sessions.ListCustomerSessions(ctx, CustomerSessionFilter{CustomerID: f.customer, Limit: 1})
require.NoError(t, err) require.NoError(t, err)
assert.EqualValues(t, 2, total) assert.EqualValues(t, 2, total)
assert.Len(t, sessions, 1) assert.Len(t, sessions, 1)
+21 -3
View File
@@ -50,7 +50,7 @@ type GameSessionRepository interface {
GetSessionBySpendTransaction(ctx context.Context, spendTransactionID uuid.UUID) (*entities.GameSession, error) GetSessionBySpendTransaction(ctx context.Context, spendTransactionID uuid.UUID) (*entities.GameSession, error)
// ListCustomerSessions returns a page of a customer's sessions, newest first, and // ListCustomerSessions returns a page of a customer's sessions, newest first, and
// the total. // the total.
ListCustomerSessions(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]entities.GameSession, int64, error) ListCustomerSessions(ctx context.Context, filter CustomerSessionFilter) ([]entities.GameSession, int64, error)
CompleteSession(ctx context.Context, id uuid.UUID, completion GameSessionCompletion) (bool, error) CompleteSession(ctx context.Context, id uuid.UUID, completion GameSessionCompletion) (bool, error)
RefundSession(ctx context.Context, id, refundTransactionID uuid.UUID, reason string, endedAt time.Time) (bool, error) RefundSession(ctx context.Context, id, refundTransactionID uuid.UUID, reason string, endedAt time.Time) (bool, error)
@@ -116,8 +116,26 @@ func (r *gameSessionRepository) GetSessionBySpendTransaction(ctx context.Context
return r.first(DBFromContext(ctx, r.db).WithContext(ctx).Where("spend_transaction_id = ?", spendTransactionID)) return r.first(DBFromContext(ctx, r.db).WithContext(ctx).Where("spend_transaction_id = ?", spendTransactionID))
} }
func (r *gameSessionRepository) ListCustomerSessions(ctx context.Context, customerID uuid.UUID, offset, limit int) ([]entities.GameSession, int64, error) { // CustomerSessionFilter selects a customer's sessions.
q := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GameSession{}).Where("customer_id = ?", customerID) type CustomerSessionFilter struct {
CustomerID uuid.UUID
// Nil for every game.
GameID *uuid.UUID
// Empty for every status.
Status string
Offset int
Limit int
}
func (r *gameSessionRepository) ListCustomerSessions(ctx context.Context, filter CustomerSessionFilter) ([]entities.GameSession, int64, error) {
q := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GameSession{}).Where("customer_id = ?", filter.CustomerID)
if filter.GameID != nil {
q = q.Where("game_id = ?", *filter.GameID)
}
if filter.Status != "" {
q = q.Where("status = ?", filter.Status)
}
offset, limit := filter.Offset, filter.Limit
var total int64 var total int64
if err := q.Count(&total).Error; err != nil { if err := q.Count(&total).Error; err != nil {
return nil, 0, fmt.Errorf("failed to count game sessions: %w", err) return nil, 0, fmt.Errorf("failed to count game sessions: %w", err)
+3 -3
View File
@@ -72,7 +72,7 @@ type EnakGameCustomerService interface {
ListGames(ctx context.Context, customerID uuid.UUID) *contract.Response ListGames(ctx context.Context, customerID uuid.UUID) *contract.Response
StartSession(ctx context.Context, customerID uuid.UUID, req *contract.StartGameSessionRequest, idempotencyKey string) *contract.Response StartSession(ctx context.Context, customerID uuid.UUID, req *contract.StartGameSessionRequest, idempotencyKey string) *contract.Response
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, page, limit int) *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
ListVouchers(ctx context.Context, customerID uuid.UUID) *contract.Response ListVouchers(ctx context.Context, customerID uuid.UUID) *contract.Response
@@ -371,8 +371,8 @@ func (s *EnakGameCustomerServiceImpl) CompleteSession(ctx context.Context, custo
return respond(ctx, completion, err) return respond(ctx, completion, err)
} }
func (s *EnakGameCustomerServiceImpl) ListSessions(ctx context.Context, customerID uuid.UUID, page, limit int) *contract.Response { func (s *EnakGameCustomerServiceImpl) ListSessions(ctx context.Context, customerID uuid.UUID, q models.GameSessionListQuery) *contract.Response {
sessions, err := s.sessions.ListSessions(ctx, customerID, page, limit) sessions, err := s.sessions.ListSessions(ctx, customerID, q)
return respond(ctx, sessions, err) return respond(ctx, sessions, err)
} }