diff --git a/docs/api-enakpoint.md b/docs/api-enakpoint.md index 9b93c95..516ea96 100644 --- a/docs/api-enakpoint.md +++ b/docs/api-enakpoint.md @@ -151,7 +151,7 @@ PIN baru ditolak `304` bila bukan 6 digit, konfirmasinya beda, semua digit sama | POST | `/customer/wallet/exchange` | Ya | Wajib | | GET | `/customer/wallet/transfer/recipient?phone=` | – | – | | POST | `/customer/wallet/transfer` | Ya | Wajib | -| POST | `/customer/spin` | – | – | +| POST | `/customer/enakgame/sessions` | – | Wajib | ### GET /customer/wallet/exchange/preview?coins=30 @@ -210,19 +210,15 @@ Body `{ "currency": "POINT", "amount": 120, "recipient_phone": "081234561234", " `currency` = `POINT` atau `COIN`. Batas organisasi (transfer aktif, minimal, maksimal per transaksi, batas harian per currency yang reset tengah malam WIB) ditolak `304` sebelum PIN dicek. Transfer final. Saldo membawa tanggal kedaluwarsa aslinya ke penerima (`lots`), dan penerima mendapat push `WALLET_TRANSFER_IN`. -### POST /customer/spin +### Game (EnakGame) -Body `{ "spin_id": "" }`. Memotong EnakCoin sebesar `metadata.coin_cost` game itu (default 1). +`POST /customer/spin`, `GET /customer/games`, dan `GET /customer/ferris-wheel` sudah dihapus. Semua game, termasuk spin, dimainkan lewat `/customer/enakgame`: -```json -{ - "game_play": { "id": "…", "game_id": "…", "coins_used": 1, "created_at": "…" }, - "prize_won": { "id": "…", "name": "Voucher 10rb" }, - "coins_remaining": 7 -} -``` +- `GET /customer/enakgame/games`: game aktif dengan `entry_cost` (EnakCoin per main) dan, untuk spin, `prizes` (segmen roda). +- `POST /customer/enakgame/sessions` dengan `{ "game_id": "…" }` dan `Idempotency-Key`: memotong `entry_cost`. +- `POST /customer/enakgame/sessions/:id/complete`: server menghitung hadiah EnakCoin; untuk spin, response berisi `prize` (segmen yang keluar). -EnakCoin kurang, game nonaktif, atau hadiah baru saja habis → `304`, tidak ada EnakCoin yang terpotong. +Alur spin lengkap ada di [`enakgame-spin.md`](./enakgame-spin.md). ## POS: earning, void, dan refund diff --git a/docs/backoffice-enakpoint.md b/docs/backoffice-enakpoint.md index cc78597..d188868 100644 --- a/docs/backoffice-enakpoint.md +++ b/docs/backoffice-enakpoint.md @@ -18,7 +18,7 @@ Semua endpoint di bawah base URL `/api/v1`, butuh login user dengan role Admin a | Wallet customer | `GET /marketing/customers/:id/wallet`, `POST …/wallet/adjust` | Customer → detail customer → tab Wallet | | Telusuri mutasi | `GET /marketing/wallet-transactions/:id/trace` | Dibuka dari baris riwayat wallet | | PIN & keamanan customer | `DELETE /marketing/customers/:id/pin`, `GET …/security-events` | Customer → detail customer → tab Keamanan | -| Biaya main game | `PUT` game yang sudah ada, `metadata.coin_cost` | Marketing → Game → edit game | +| Biaya main game | `entry_cost` di `/marketing/enakgame/games` | Marketing → EnakGame → game | Penempatan menu di atas adalah usulan; sesuaikan dengan struktur backoffice yang ada. @@ -269,7 +269,7 @@ Admin tidak bisa membuat, mengganti, atau melihat PIN customer; satu-satunya aks ### Biaya main game -Semua game (spin, raffle, minigame) memakai EnakCoin yang sama. Biaya per main diisi di `metadata.coin_cost` saat membuat atau mengedit game (`/marketing/games`): bilangan bulat ≥ 1, default 1 bila kosong. Nilai pecahan, 0, atau teks membuat game tidak bisa dimainkan. Karena `metadata` dikirim utuh, pertahankan key metadata lain saat menyimpan. Hadiah game juga bernilai rupiah secara tidak langsung, karena EnakCoin bisa ditukar ke EnakPoint. +Semua game (spin, raffle, minigame) memakai EnakCoin yang sama dan dikelola di `/marketing/enakgame/games`. Menu lama `/marketing/games`, `/marketing/game-prizes`, dan `/marketing/rewards` sudah dihapus. Biaya per main adalah `entry_cost` game (bilangan bulat ≥ 1), hadiahnya diatur di reward config game itu. Langkah membuat spin ada di [`enakgame-spin.md`](./enakgame-spin.md). Hadiah game juga bernilai rupiah secara tidak langsung, karena EnakCoin bisa ditukar ke EnakPoint. ## Pesan error dan checklist @@ -293,7 +293,7 @@ Pesan `cause` saat ini berbahasa Inggris, mis. `invalid loyalty settings: loyalt - [ ] Adjustment mewajibkan alasan dan mengirim `idempotency_key`. - [ ] Tombol Telusuri ada di setiap baris riwayat. - [ ] Hapus PIN mewajibkan alasan; tab Keamanan menampilkan log. -- [ ] Form game punya input `coin_cost`. +- [ ] Form game EnakGame punya input `entry_cost`. - [ ] Semua nilai rupiah EnakPoint ditulis "setara potongan Rp …". Transfer belum boleh dirilis sebelum tinjauan legal (N3) selesai. Layar backoffice boleh disiapkan lebih dulu. diff --git a/docs/enakgame-spin.md b/docs/enakgame-spin.md new file mode 100644 index 0000000..75a9d01 --- /dev/null +++ b/docs/enakgame-spin.md @@ -0,0 +1,86 @@ +# Spin sebagai Game EnakGame + +**Sumber:** [RFC EnakGame](rfc-enakgame.md) §14, task EG-1001 +**Pembaca:** admin organisasi dan tim backoffice / aplikasi customer + +Spin lama (`POST /customer/spin`) diganti dengan game EnakGame biasa: tipe `SPIN`, +reward `PROBABILITY`, hadiah berupa EnakCoin. Spin dimainkan lewat endpoint session +yang sama dengan game lain, sehingga ikut mendapat idempotency, refund otomatis, budget, +event, dan Economy Guard. + +Tidak ada seeder: setiap organisasi membuat spin-nya sendiri lewat API admin di bawah. +Semua langkah memakai token admin organisasi; langkah 2–4 butuh loyalty manager. + +## Langkah Admin + +### 1. Buat game + +`POST /api/v1/marketing/enakgame/games` + +```json +{ + "name": "Spin Harian", + "slug": "spin", + "type": "SPIN", + "entry_cost": 5, + "status": "ACTIVE", + "thumbnail_url": "https://…/spin.png", + "game_url": "https://…/spin/index.html" +} +``` + +`entry_cost` adalah EnakCoin yang dipotong setiap kali spin (minimal 1). + +### 2. Buat reward config `PROBABILITY` + +`POST /api/v1/marketing/enakgame/games/:id/reward-configs` + +```json +{ + "reward_type": "PROBABILITY", + "max_reward": 50, + "rules": { + "table": [ + { "weight": 50, "amount": 0, "label": "Zonk" }, + { "weight": 30, "amount": 3, "label": "3 Coin" }, + { "weight": 15, "amount": 10, "label": "10 Coin" }, + { "weight": 5, "amount": 50, "label": "Jackpot" } + ] + }, + "reason": "Spin pertama" +} +``` + +- Satu baris `table` = satu segmen roda, urut searah gambar roda. +- `weight` bilangan bulat ≥ 1. Peluang segmen = `weight` ÷ total weight (di atas: 50%, + 30%, 15%, 5%). Weight tidak pernah dikirim ke customer. +- `amount` EnakCoin yang didapat (boleh 0). `label` opsional, maksimal 100 karakter, + ditampilkan di roda. +- `max_reward` minimal sebesar `amount` terbesar, kalau tidak hadiah besar terpotong. + +### 3. Aktifkan config + +`POST /api/v1/marketing/enakgame/reward-configs/:id/activate` + +Mengganti hadiah nanti berarti membuat versi config baru lalu mengaktifkannya; session +yang sedang berjalan tetap memakai versi saat dimulai. + +### 4. Pastikan ada budget global bulan berjalan + +`POST /api/v1/marketing/enakgame/budgets` dengan `scope: "GLOBAL"`, bila belum ada. Tanpa +budget global, customer tidak bisa memulai game apa pun. + +## Alur di Aplikasi Customer + +1. `GET /api/v1/customer/enakgame/games`: game spin punya `prizes`, yaitu segmen roda + (`entry`, `label`, `amount`) dalam urutan config. Gambar roda dari sini. +2. `POST /api/v1/customer/enakgame/sessions` dengan `{"game_id": "…"}` dan header + `Idempotency-Key`. Entry cost dipotong di sini. +3. `POST /api/v1/customer/enakgame/sessions/:id/complete` dengan body `{}`. Server yang + mengundi. Response berisi `prize` (`entry`, `label`, `amount`): putar roda sampai + berhenti di segmen `entry` itu. `reward_total` adalah Coin yang benar-benar masuk, + bisa lebih besar dari `prize.amount` karena event, atau lebih kecil karena limit + harian (`limited_by`). + +Aplikasi tidak boleh mengundi sendiri atau mengirim hadiah: apa pun yang dikirim selain +data hasil diabaikan. diff --git a/docs/integration-enakpoint.md b/docs/integration-enakpoint.md index 7e51933..80b21ca 100644 --- a/docs/integration-enakpoint.md +++ b/docs/integration-enakpoint.md @@ -367,23 +367,18 @@ Kurs per organisasi: `coin_amount` EnakCoin = `point_amount` EnakPoint (default ## 7. Game -`POST /api/v1/customer/spin` dengan `{ "spin_id": "" }`. Tanpa PIN. +Game lama (`POST /api/v1/customer/spin`, `GET /customer/games`, +`GET /customer/ferris-wheel`, dan admin `/marketing/games`, `/marketing/game-prizes`, +`/marketing/rewards`) sudah dihapus. Semua game, termasuk spin, sekarang game EnakGame: -Setiap game memotong EnakCoin sebesar `metadata.coin_cost` game itu (default 1). -Response: +- Customer: `GET /api/v1/customer/enakgame/games`, lalu + `POST /api/v1/customer/enakgame/sessions` (wajib `Idempotency-Key`, memotong + `entry_cost` EnakCoin), lalu `POST /api/v1/customer/enakgame/sessions/:id/complete` + (server menghitung hadiah EnakCoin). Tanpa PIN. +- Dashboard: game dan biaya per main (`entry_cost`, bilangan bulat ≥ 1) diatur di + `/marketing/enakgame/games`, hadiahnya di reward config. -```json -{ - "game_play": { "id": "…", "game_id": "…", "coins_used": 1, "created_at": "…" }, - "prize_won": { "id": "…", "name": "Voucher 10rb", … }, - "coins_remaining": 7 -} -``` - -EnakCoin kurang, game nonaktif, atau hadiah baru saja habis dijawab `304`; tidak ada -EnakCoin yang terpotong. - -Di dashboard, `metadata.coin_cost` diisi per game dengan bilangan bulat ≥ 1. +Langkah admin dan alur aplikasi untuk spin ada di [`enakgame-spin.md`](enakgame-spin.md). --- @@ -549,4 +544,5 @@ Riwayat perubahan: `GET /api/v1/marketing/loyalty-settings/history?page=1&limit= **Dashboard** - [ ] Tampilkan `point_cashback_percent`, `impact`, `expiry_preview`, dan `expiry_activations` sebelum owner menyimpan setting. -- [ ] Isi `metadata.coin_cost` untuk setiap game. +- [ ] Buat ulang game (termasuk spin) di `/marketing/enakgame/games` dengan `entry_cost` + dan reward config ([`enakgame-spin.md`](enakgame-spin.md)). diff --git a/docs/mobile-customer-enakpoint.md b/docs/mobile-customer-enakpoint.md index 5c01d74..3045441 100644 --- a/docs/mobile-customer-enakpoint.md +++ b/docs/mobile-customer-enakpoint.md @@ -116,7 +116,7 @@ Endpoint **tukar** dan **transfer** wajib header `Idempotency-Key` (string unik, | Tukar EnakCoin | `GET …/exchange/preview`, `POST /customer/wallet/exchange` | Ya | | Transfer | `GET …/transfer/recipient`, `POST /customer/wallet/transfer` | Ya | | PIN (buat, ganti, lupa) | `/customer/pin/*` | – | -| Game | `POST /customer/spin` | – | +| Game | `GET /customer/enakgame/games`, `POST /customer/enakgame/sessions`, `POST …/sessions/:id/complete` | – | | (latar belakang) registrasi push | `PUT` / `DELETE /customer/devices` | – | --- @@ -524,22 +524,20 @@ Penerima mendapat push `WALLET_TRANSFER_IN`. ## 8. Game (memakai EnakCoin) -`POST /api/v1/customer/spin` dengan `{ "spin_id": "" }`. Tanpa PIN. +`POST /api/v1/customer/spin`, `GET /customer/games`, dan `GET /customer/ferris-wheel` +sudah dihapus. Semua game, termasuk spin, dimainkan lewat EnakGame. Tanpa PIN. -```json -{ - "game_play": { "id": "…", "game_id": "…", "coins_used": 1, "created_at": "…" }, - "prize_won": { "id": "…", "name": "Voucher 10rb" }, - "coins_remaining": 7 -} -``` +1. `GET /api/v1/customer/enakgame/games`: daftar game dengan `entry_cost` (EnakCoin per + main). Tampilkan biaya sebelum main, dan nonaktifkan tombol bila `coin_balance` + kurang. Spin punya `prizes` untuk menggambar roda. +2. `POST /api/v1/customer/enakgame/sessions` dengan `{ "game_id": "…" }` dan header + `Idempotency-Key` (satu key per tap; retry memakai key yang sama). Response berisi + `session_id` dan `coin_balance` setelah dipotong. +3. `POST /api/v1/customer/enakgame/sessions/:id/complete` dengan hasil main (`score` + atau `outcome`; spin cukup `{}`). Server yang menentukan hadiah: perbarui saldo dari + `coin_balance`, tampilkan `reward_total`, dan untuk spin hentikan roda di `prize.entry`. -- Setiap game punya biaya sendiri: `metadata.coin_cost` pada data game dari - `GET /api/v1/customer/games` (atau `GET /customer/ferris-wheel`), default 1 bila kosong. - Tampilkan biaya sebelum main, dan nonaktifkan tombol bila `coin_balance` kurang. -- `304`: EnakCoin kurang, game nonaktif, atau hadiah baru saja habis. Tidak ada - EnakCoin yang terpotong; tampilkan pesan dan biarkan customer mencoba lagi. -- Setelah main, perbarui saldo EnakCoin dari `coins_remaining`. +Rincian spin ada di [`enakgame-spin.md`](./enakgame-spin.md). --- diff --git a/docs/prd-point-coin.md b/docs/prd-point-coin.md index d09ca03..c6e374f 100644 --- a/docs/prd-point-coin.md +++ b/docs/prd-point-coin.md @@ -385,6 +385,12 @@ beredar. Karena itu: ### F8 — Game Memakai EnakCoin +> **Diganti EnakGame (2026-10-07).** Alur game di bawah (`POST /customer/spin`, +> `metadata.coin_cost`, `game_plays`) sudah dihapus. Game sekarang dimainkan lewat +> `/customer/enakgame/sessions` dengan `games.entry_cost`; lihat +> [RFC EnakGame](rfc-enakgame.md) §14 dan [enakgame-spin.md](enakgame-spin.md). +> EnakCoin tetap mata uang untuk bermain game. + - **Semua jenis game** (`SPIN`, ferris wheel, `RAFFLE`, `MINIGAME`) memotong EnakCoin yang sama. `POST /customer/spin` memotong EnakCoin, bukan Token `SPIN`. - Biaya per main diatur per game di `games.metadata.coin_cost` (default 1), sehingga diff --git a/internal/app/app.go b/internal/app/app.go index a1b7a2f..8df41ce 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -149,14 +149,11 @@ func (a *App) Initialize(cfg *config.Config) error { validators.orderIngredientTransactionValidator, services.gamificationService, validators.gamificationValidator, - services.rewardService, - validators.rewardValidator, services.campaignService, validators.campaignValidator, services.customerAuthService, validators.customerAuthValidator, services.customerPointsService, - services.spinGameService, middleware.customerAuthMiddleware, services.userDeviceService, validators.userDeviceValidator, @@ -306,11 +303,7 @@ type repositories struct { accountRepo *repository.AccountRepositoryImpl orderIngredientTransactionRepo *repository.OrderIngredientTransactionRepositoryImpl tierRepo *repository.TierRepository - gameRepo *repository.GameRepository - gamePrizeRepo *repository.GamePrizeRepository - gamePlayRepo repository.GamePlayRepository omsetTrackerRepo *repository.OmsetTrackerRepository - rewardRepo repository.RewardRepository campaignRepo repository.CampaignRepository campaignRuleRepo repository.CampaignRuleRepository customerAuthRepo repository.CustomerAuthRepository @@ -362,11 +355,7 @@ func (a *App) initRepositories() *repositories { accountRepo: repository.NewAccountRepositoryImpl(a.db), orderIngredientTransactionRepo: repository.NewOrderIngredientTransactionRepositoryImpl(a.db).(*repository.OrderIngredientTransactionRepositoryImpl), tierRepo: repository.NewTierRepository(a.db), - gameRepo: repository.NewGameRepository(a.db), - gamePrizeRepo: repository.NewGamePrizeRepository(a.db), - gamePlayRepo: repository.NewGamePlayRepository(a.db), omsetTrackerRepo: repository.NewOmsetTrackerRepository(a.db), - rewardRepo: repository.NewRewardRepository(a.db), campaignRepo: repository.NewCampaignRepository(a.db), campaignRuleRepo: repository.NewCampaignRuleRepository(a.db), customerAuthRepo: repository.NewCustomerAuthRepository(a.db), @@ -413,11 +402,7 @@ type processors struct { accountProcessor *processor.AccountProcessorImpl orderIngredientTransactionProcessor *processor.OrderIngredientTransactionProcessorImpl tierProcessor *processor.TierProcessor - gameProcessor *processor.GameProcessor - gamePrizeProcessor *processor.GamePrizeProcessor - gamePlayProcessor *processor.GamePlayProcessor omsetTrackerProcessor *processor.OmsetTrackerProcessor - rewardProcessor processor.RewardProcessor campaignProcessor processor.CampaignProcessor campaignRuleProcessor processor.CampaignRuleProcessor customerAuthProcessor processor.CustomerAuthProcessor @@ -500,15 +485,11 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor accountProcessor: processor.NewAccountProcessorImpl(repos.accountRepo, repos.chartOfAccountRepo), orderIngredientTransactionProcessor: processor.NewOrderIngredientTransactionProcessorImpl(repos.orderIngredientTransactionRepo, repos.productRecipeRepo, repos.ingredientRepo, repos.unitRepo).(*processor.OrderIngredientTransactionProcessorImpl), tierProcessor: processor.NewTierProcessor(repos.tierRepo), - gameProcessor: processor.NewGameProcessor(repos.gameRepo), - gamePrizeProcessor: processor.NewGamePrizeProcessor(repos.gamePrizeRepo), - gamePlayProcessor: processor.NewGamePlayProcessor(repos.gamePlayRepo, repos.gameRepo, repos.gamePrizeRepo, processor.NewWalletProcessor(repos.walletRepo), repos.walletQueryRepo, repos.txManager), omsetTrackerProcessor: processor.NewOmsetTrackerProcessor(repos.omsetTrackerRepo), - rewardProcessor: processor.NewRewardProcessor(repos.rewardRepo), campaignProcessor: processor.NewCampaignProcessor(repos.campaignRepo), campaignRuleProcessor: processor.NewCampaignRuleProcessor(repos.campaignRuleRepo), customerAuthProcessor: processor.NewCustomerAuthProcessor(repos.customerAuthRepo, otpProcessor, repos.otpRepo, cfg.GetCustomerJWTSecret(), cfg.GetCustomerJWTExpiresTTL()), - customerPointsProcessor: processor.NewCustomerPointsProcessor(processor.NewWalletQueryProcessor(repos.walletQueryRepo, processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager)), repos.gameRepo), + customerPointsProcessor: processor.NewCustomerPointsProcessor(processor.NewWalletQueryProcessor(repos.walletQueryRepo, processor.NewLoyaltySettingsProcessor(repos.loyaltySettingsRepo, repos.txManager))), otpProcessor: otpProcessor, fileClient: fileClient, inventoryMovementService: inventoryMovementService, @@ -571,11 +552,9 @@ type services struct { accountService service.AccountService orderIngredientTransactionService *service.OrderIngredientTransactionService gamificationService service.GamificationService - rewardService service.RewardService campaignService service.CampaignService customerAuthService service.CustomerAuthService customerPointsService service.CustomerPointsService - spinGameService service.SpinGameService userDeviceService service.UserDeviceService notificationService service.NotificationService productOutletPriceService service.ProductOutletPriceService @@ -622,12 +601,10 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con chartOfAccountService := service.NewChartOfAccountService(processors.chartOfAccountProcessor) accountService := service.NewAccountService(processors.accountProcessor) orderIngredientTransactionService := service.NewOrderIngredientTransactionService(processors.orderIngredientTransactionProcessor, repos.txManager) - gamificationService := service.NewGamificationService(processors.tierProcessor, processors.gameProcessor, processors.gamePrizeProcessor, processors.gamePlayProcessor, processors.omsetTrackerProcessor) - rewardService := service.NewRewardService(processors.rewardProcessor) + gamificationService := service.NewGamificationService(processors.tierProcessor, processors.omsetTrackerProcessor) campaignService := service.NewCampaignService(processors.campaignProcessor, processors.campaignRuleProcessor) customerAuthService := service.NewCustomerAuthService(processors.customerAuthProcessor) customerPointsService := service.NewCustomerPointsService(processors.customerPointsProcessor) - spinGameService := service.NewSpinGameService(processors.gamePlayProcessor) userDeviceService := service.NewUserDeviceService(processors.userDeviceProcessor) notificationService := service.NewNotificationService(processors.notificationProcessor) @@ -663,11 +640,9 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con accountService: accountService, orderIngredientTransactionService: orderIngredientTransactionService, gamificationService: gamificationService, - rewardService: rewardService, campaignService: campaignService, customerAuthService: customerAuthService, customerPointsService: customerPointsService, - spinGameService: spinGameService, userDeviceService: userDeviceService, notificationService: notificationService, productOutletPriceService: service.NewProductOutletPriceService(processors.productOutletPriceProcessor), @@ -726,7 +701,6 @@ type validators struct { accountValidator *validator.AccountValidatorImpl orderIngredientTransactionValidator *validator.OrderIngredientTransactionValidatorImpl gamificationValidator *validator.GamificationValidatorImpl - rewardValidator validator.RewardValidator campaignValidator validator.CampaignValidator customerAuthValidator validator.CustomerAuthValidator userDeviceValidator *validator.UserDeviceValidatorImpl @@ -760,7 +734,6 @@ func (a *App) initValidators() *validators { accountValidator: validator.NewAccountValidator().(*validator.AccountValidatorImpl), orderIngredientTransactionValidator: validator.NewOrderIngredientTransactionValidator().(*validator.OrderIngredientTransactionValidatorImpl), gamificationValidator: validator.NewGamificationValidator(), - rewardValidator: validator.NewRewardValidator(), campaignValidator: validator.NewCampaignValidator(), customerAuthValidator: validator.NewCustomerAuthValidator(), userDeviceValidator: validator.NewUserDeviceValidator(), diff --git a/internal/constants/error.go b/internal/constants/error.go index c953e30..05193ea 100644 --- a/internal/constants/error.go +++ b/internal/constants/error.go @@ -53,15 +53,10 @@ const ( // Gamification entities CustomerPointsEntity = "customer_points" TierEntity = "tier" - GameEntity = "game" - GamePrizeEntity = "game_prize" - GamePlayEntity = "game_play" OmsetTrackerEntity = "omset_tracker" - RewardEntity = "reward" CampaignEntity = "campaign" CampaignRuleEntity = "campaign_rule" CustomerEntity = "customer" - SpinGameHandlerEntity = "spin_game_handler" UserDeviceServiceEntity = "user_device_service" NotificationServiceEntity = "notification_service" NotificationHandlerEntity = "notification_handler" diff --git a/internal/contract/customer_game_contract.go b/internal/contract/customer_game_contract.go deleted file mode 100644 index a074fb8..0000000 --- a/internal/contract/customer_game_contract.go +++ /dev/null @@ -1,60 +0,0 @@ -package contract - -import ( - "time" - - "github.com/google/uuid" -) - -// Request Contracts -type GetCustomerGamesRequest struct { - // No additional fields needed - customer ID comes from JWT token -} - -// Response Contracts -type GetCustomerGamesResponse struct { - Status string `json:"status"` - Message string `json:"message"` - Data *GetCustomerGamesResponseData `json:"data,omitempty"` -} - -type GetCustomerGamesResponseData struct { - Games []CustomerGameResponse `json:"games"` -} - -type CustomerGameResponse struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - IsActive bool `json:"is_active"` - Metadata *map[string]interface{} `json:"metadata,omitempty"` - Prizes []CustomerGamePrizeResponse `json:"prizes,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type CustomerGamePrizeResponse struct { - ID uuid.UUID `json:"id"` - GameID uuid.UUID `json:"game_id"` - Name string `json:"name"` - Image *string `json:"image,omitempty"` - Metadata *map[string]interface{} `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// Ferris Wheel Game Contracts -type GetFerrisWheelGameRequest struct { - // No additional fields needed - customer ID comes from JWT token -} - -type GetFerrisWheelGameResponse struct { - Status string `json:"status"` - Message string `json:"message"` - Data *GetFerrisWheelGameResponseData `json:"data,omitempty"` -} - -type GetFerrisWheelGameResponseData struct { - Game CustomerGameResponse `json:"game"` - Prizes []CustomerGamePrizeResponse `json:"prizes"` -} diff --git a/internal/contract/game_contract.go b/internal/contract/game_contract.go deleted file mode 100644 index 754acf8..0000000 --- a/internal/contract/game_contract.go +++ /dev/null @@ -1,49 +0,0 @@ -package contract - -import ( - "time" - - "github.com/google/uuid" -) - -type CreateGameRequest struct { - Name string `json:"name" validate:"required"` - Type string `json:"type" validate:"required,oneof=SPIN RAFFLE MINIGAME"` - IsActive bool `json:"is_active"` - Metadata map[string]interface{} `json:"metadata"` -} - -type UpdateGameRequest struct { - Name *string `json:"name,omitempty" validate:"omitempty,required"` - Type *string `json:"type,omitempty" validate:"omitempty,oneof=SPIN RAFFLE MINIGAME"` - IsActive *bool `json:"is_active,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` -} - -type GameResponse struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - IsActive bool `json:"is_active"` - Metadata map[string]interface{} `json:"metadata"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type ListGamesRequest struct { - Page int `json:"page" form:"page" validate:"min=1"` - Limit int `json:"limit" form:"limit" validate:"min=1,max=100"` - Search string `json:"search" form:"search"` - Type string `json:"type" form:"type" validate:"omitempty,oneof=SPIN RAFFLE MINIGAME"` - IsActive *bool `json:"is_active" form:"is_active"` - SortBy string `json:"sort_by" form:"sort_by" validate:"omitempty,oneof=name type created_at updated_at"` - SortOrder string `json:"sort_order" form:"sort_order" validate:"omitempty,oneof=asc desc"` -} - -type PaginatedGamesResponse struct { - Data []GameResponse `json:"data"` - TotalCount int `json:"total_count"` - Page int `json:"page"` - Limit int `json:"limit"` - TotalPages int `json:"total_pages"` -} diff --git a/internal/contract/game_play_contract.go b/internal/contract/game_play_contract.go deleted file mode 100644 index a7454e9..0000000 --- a/internal/contract/game_play_contract.go +++ /dev/null @@ -1,59 +0,0 @@ -package contract - -import ( - "time" - - "github.com/google/uuid" -) - -type CreateGamePlayRequest struct { - GameID uuid.UUID `json:"game_id" validate:"required"` - CustomerID uuid.UUID `json:"customer_id" validate:"required"` - CoinsUsed int `json:"coins_used" validate:"min=0"` - RandomSeed *string `json:"random_seed,omitempty"` -} - -type GamePlayResponse struct { - ID uuid.UUID `json:"id"` - GameID uuid.UUID `json:"game_id"` - CustomerID uuid.UUID `json:"customer_id"` - PrizeID *uuid.UUID `json:"prize_id,omitempty"` - CoinsUsed int `json:"coins_used"` - RandomSeed *string `json:"random_seed,omitempty"` - CreatedAt time.Time `json:"created_at"` - Game *GameResponse `json:"game,omitempty"` - Customer *CustomerResponse `json:"customer,omitempty"` - Prize *GamePrizeResponse `json:"prize,omitempty"` -} - -type ListGamePlaysRequest struct { - Page int `json:"page" validate:"min=1"` - Limit int `json:"limit" validate:"min=1,max=100"` - Search string `json:"search"` - GameID *uuid.UUID `json:"game_id"` - CustomerID *uuid.UUID `json:"customer_id"` - PrizeID *uuid.UUID `json:"prize_id"` - SortBy string `json:"sort_by" validate:"omitempty,oneof=created_at coins_used"` - SortOrder string `json:"sort_order" validate:"omitempty,oneof=asc desc"` -} - -type PaginatedGamePlaysResponse struct { - Data []GamePlayResponse `json:"data"` - TotalCount int `json:"total_count"` - Page int `json:"page"` - Limit int `json:"limit"` - TotalPages int `json:"total_pages"` -} - -// PlayGameRequest plays a game for a customer. What it costs is the game's -// metadata.coin_cost (docs/prd-point-coin.md F8), never the caller's choice. -type PlayGameRequest struct { - GameID uuid.UUID `json:"game_id" validate:"required"` - CustomerID uuid.UUID `json:"customer_id" validate:"required"` -} - -type PlayGameResponse struct { - GamePlay GamePlayResponse `json:"game_play"` - PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"` - CoinsRemaining int64 `json:"coins_remaining"` -} diff --git a/internal/contract/game_prize_contract.go b/internal/contract/game_prize_contract.go deleted file mode 100644 index e793b80..0000000 --- a/internal/contract/game_prize_contract.go +++ /dev/null @@ -1,64 +0,0 @@ -package contract - -import ( - "time" - - "github.com/google/uuid" -) - -type CreateGamePrizeRequest struct { - GameID uuid.UUID `json:"game_id" validate:"required"` - Name string `json:"name" validate:"required"` - Weight int `json:"weight" validate:"min=1"` - Stock int `json:"stock" validate:"min=0"` - MaxStock *int `json:"max_stock,omitempty"` - Threshold *int64 `json:"threshold,omitempty"` - FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"` - Image *string `json:"image,omitempty" validate:"omitempty,max=500"` - Metadata map[string]interface{} `json:"metadata"` -} - -type UpdateGamePrizeRequest struct { - Name *string `json:"name,omitempty" validate:"omitempty,required"` - Weight *int `json:"weight,omitempty" validate:"omitempty,min=1"` - Stock *int `json:"stock,omitempty" validate:"omitempty,min=0"` - MaxStock *int `json:"max_stock,omitempty"` - Threshold *int64 `json:"threshold,omitempty"` - FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"` - Image *string `json:"image,omitempty" validate:"omitempty,max=500"` - Metadata map[string]interface{} `json:"metadata,omitempty"` -} - -type GamePrizeResponse struct { - ID uuid.UUID `json:"id"` - GameID uuid.UUID `json:"game_id"` - Name string `json:"name"` - Weight int `json:"weight"` - Stock int `json:"stock"` - MaxStock *int `json:"max_stock,omitempty"` - Threshold *int64 `json:"threshold,omitempty"` - FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"` - Image *string `json:"image,omitempty"` - Metadata map[string]interface{} `json:"metadata"` - Game *GameResponse `json:"game,omitempty"` - FallbackPrize *GamePrizeResponse `json:"fallback_prize,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type ListGamePrizesRequest struct { - Page int `json:"page" form:"page" validate:"min=1"` - Limit int `json:"limit" form:"limit" validate:"min=1,max=100"` - Search string `json:"search" form:"search"` - GameID *uuid.UUID `json:"game_id" form:"game_id"` - SortBy string `json:"sort_by" form:"sort_by" validate:"omitempty,oneof=name weight stock created_at updated_at"` - SortOrder string `json:"sort_order" form:"sort_order" validate:"omitempty,oneof=asc desc"` -} - -type PaginatedGamePrizesResponse struct { - Data []GamePrizeResponse `json:"data"` - TotalCount int `json:"total_count"` - Page int `json:"page"` - Limit int `json:"limit"` - TotalPages int `json:"total_pages"` -} diff --git a/internal/contract/omset_tracker_contract.go b/internal/contract/omset_tracker_contract.go index 0b14243..a4052eb 100644 --- a/internal/contract/omset_tracker_contract.go +++ b/internal/contract/omset_tracker_contract.go @@ -27,15 +27,14 @@ type AddOmsetRequest struct { } type OmsetTrackerResponse struct { - ID uuid.UUID `json:"id"` - PeriodType string `json:"period_type"` - PeriodStart time.Time `json:"period_start"` - PeriodEnd time.Time `json:"period_end"` - Total int64 `json:"total"` - GameID *uuid.UUID `json:"game_id,omitempty"` - Game *GameResponse `json:"game,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + PeriodType string `json:"period_type"` + PeriodStart time.Time `json:"period_start"` + PeriodEnd time.Time `json:"period_end"` + Total int64 `json:"total"` + GameID *uuid.UUID `json:"game_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type ListOmsetTrackerRequest struct { diff --git a/internal/contract/reward_contract.go b/internal/contract/reward_contract.go deleted file mode 100644 index 968d26b..0000000 --- a/internal/contract/reward_contract.go +++ /dev/null @@ -1,82 +0,0 @@ -package contract - -import ( - "time" - - "github.com/google/uuid" -) - -// Request Contracts -type CreateRewardRequest struct { - Name string `json:"name" binding:"required,min=1,max=150"` - RewardType string `json:"reward_type" binding:"required,oneof=VOUCHER PHYSICAL DIGITAL"` - CostPoints int64 `json:"cost_points" binding:"required,min=1"` - Stock *int `json:"stock,omitempty"` - MaxPerCustomer int `json:"max_per_customer" binding:"min=1"` - Tnc *TermsAndConditionsStruct `json:"tnc,omitempty"` - Metadata *map[string]interface{} `json:"metadata,omitempty"` - Images *[]string `json:"images,omitempty"` -} - -type UpdateRewardRequest struct { - ID uuid.UUID `json:"id" binding:"required"` - Name string `json:"name" binding:"required,min=1,max=150"` - RewardType string `json:"reward_type" binding:"required,oneof=VOUCHER PHYSICAL DIGITAL BALANCE"` - CostPoints int64 `json:"cost_points" binding:"required,min=1"` - Stock *int `json:"stock,omitempty"` - MaxPerCustomer int `json:"max_per_customer" binding:"min=1"` - Tnc *TermsAndConditionsStruct `json:"tnc,omitempty"` - Metadata *map[string]interface{} `json:"metadata,omitempty"` - Images *[]string `json:"images,omitempty"` -} - -type ListRewardsRequest struct { - Page int `form:"page" binding:"min=1"` - Limit int `form:"limit" binding:"min=1,max=100"` - Search string `form:"search"` - RewardType string `form:"reward_type"` - MinPoints *int64 `form:"min_points"` - MaxPoints *int64 `form:"max_points"` - HasStock *bool `form:"has_stock"` -} - -type GetRewardRequest struct { - ID uuid.UUID `uri:"id" binding:"required"` -} - -type DeleteRewardRequest struct { - ID uuid.UUID `uri:"id" binding:"required"` -} - -// Response Contracts -type RewardResponse struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - RewardType string `json:"reward_type"` - CostPoints int64 `json:"cost_points"` - Stock *int `json:"stock,omitempty"` - MaxPerCustomer int `json:"max_per_customer"` - Tnc *TermsAndConditionsStruct `json:"tnc,omitempty"` - Metadata *map[string]interface{} `json:"metadata,omitempty"` - Images *[]string `json:"images,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type ListRewardsResponse struct { - Rewards []RewardResponse `json:"rewards"` - Total int64 `json:"total"` - Page int `json:"page"` - Limit int `json:"limit"` -} - -// Helper structs -type TermsAndConditionsStruct struct { - Sections []TncSectionStruct `json:"sections"` - ExpiryDays int `json:"expiry_days"` -} - -type TncSectionStruct struct { - Title string `json:"title"` - Rules []string `json:"rules"` -} diff --git a/internal/contract/spin_game_contract.go b/internal/contract/spin_game_contract.go deleted file mode 100644 index b234f2b..0000000 --- a/internal/contract/spin_game_contract.go +++ /dev/null @@ -1,20 +0,0 @@ -package contract - -// SpinGameRequest represents the request to play a spin game -type SpinGameRequest struct { - SpinID string `json:"spin_id" validate:"required,uuid"` -} - -// SpinGameResponse represents the response from playing a spin game -type SpinGameResponse struct { - Status string `json:"status"` - Message string `json:"message"` - Data *SpinGameResponseData `json:"data,omitempty"` -} - -// SpinGameResponseData contains the game play result -type SpinGameResponseData struct { - GamePlay GamePlayResponse `json:"game_play"` - PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"` - CoinsRemaining int64 `json:"coins_remaining"` -} diff --git a/internal/entities/entities.go b/internal/entities/entities.go index 8ee2e10..1aa2a64 100644 --- a/internal/entities/entities.go +++ b/internal/entities/entities.go @@ -28,10 +28,7 @@ func GetAllEntities() []interface{} { &CustomerTokens{}, &Tier{}, &Game{}, - &GamePrize{}, - &GamePlay{}, &OmsetTracker{}, - &Reward{}, &Campaign{}, &CampaignRule{}, &OtpSession{}, diff --git a/internal/entities/game.go b/internal/entities/game.go index 7effc74..ea568f8 100644 --- a/internal/entities/game.go +++ b/internal/entities/game.go @@ -16,11 +16,12 @@ const ( ) type Game struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - Name string `gorm:"type:varchar(255);not null" json:"name" validate:"required"` - Type GameType `gorm:"type:varchar(50);not null" json:"type" validate:"required,oneof=SPIN RAFFLE MINIGAME"` - IsActive bool `gorm:"default:true" json:"is_active"` - Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + Name string `gorm:"type:varchar(255);not null" json:"name" validate:"required"` + Type GameType `gorm:"type:varchar(50);not null" json:"type" validate:"required,oneof=SPIN RAFFLE MINIGAME"` + // The old games' settings. Nothing reads it any more; their coin_cost became + // entry_cost (migration 000103). + Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` // EnakGame (docs/rfc-enakgame.md §5.1). Only an ARCHIVED game, which every old game // is, may lack an organization and a slug. @@ -39,9 +40,6 @@ type Game struct { CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - - Prizes []GamePrize `gorm:"foreignKey:GameID" json:"prizes,omitempty"` - Plays []GamePlay `gorm:"foreignKey:GameID" json:"plays,omitempty"` } func (g *Game) BeforeCreate(tx *gorm.DB) error { diff --git a/internal/entities/game_play.go b/internal/entities/game_play.go deleted file mode 100644 index 026ca95..0000000 --- a/internal/entities/game_play.go +++ /dev/null @@ -1,33 +0,0 @@ -package entities - -import ( - "time" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -type GamePlay struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - GameID uuid.UUID `gorm:"type:uuid;not null;index" json:"game_id" validate:"required"` - CustomerID uuid.UUID `gorm:"type:uuid;not null;index" json:"customer_id" validate:"required"` - PrizeID *uuid.UUID `gorm:"type:uuid" json:"prize_id,omitempty"` - CoinsUsed int `gorm:"column:coins_used;default:0" json:"coins_used" validate:"min=0"` - RandomSeed *string `gorm:"type:varchar(255)" json:"random_seed,omitempty"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - - Game Game `gorm:"foreignKey:GameID" json:"game,omitempty"` - Customer Customer `gorm:"foreignKey:CustomerID" json:"customer,omitempty"` - Prize *GamePrize `gorm:"foreignKey:PrizeID" json:"prize,omitempty"` -} - -func (gp *GamePlay) BeforeCreate(tx *gorm.DB) error { - if gp.ID == uuid.Nil { - gp.ID = uuid.New() - } - return nil -} - -func (GamePlay) TableName() string { - return "game_plays" -} diff --git a/internal/entities/game_prize.go b/internal/entities/game_prize.go deleted file mode 100644 index 8345f38..0000000 --- a/internal/entities/game_prize.go +++ /dev/null @@ -1,38 +0,0 @@ -package entities - -import ( - "time" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -type GamePrize struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - GameID uuid.UUID `gorm:"type:uuid;not null;index" json:"game_id" validate:"required"` - Name string `gorm:"type:varchar(255);not null" json:"name" validate:"required"` - Weight int `gorm:"not null" json:"weight" validate:"min=1"` - Stock int `gorm:"default:0" json:"stock" validate:"min=0"` - MaxStock *int `gorm:"" json:"max_stock,omitempty"` - Threshold *int64 `gorm:"" json:"threshold,omitempty"` - FallbackPrizeID *uuid.UUID `gorm:"type:uuid" json:"fallback_prize_id,omitempty"` - Image *string `gorm:"type:varchar(500)" json:"image,omitempty"` - Metadata Metadata `gorm:"type:jsonb;default:'{}'" json:"metadata"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` - - Game Game `gorm:"foreignKey:GameID" json:"game,omitempty"` - FallbackPrize *GamePrize `gorm:"foreignKey:FallbackPrizeID" json:"fallback_prize,omitempty"` - Plays []GamePlay `gorm:"foreignKey:PrizeID" json:"plays,omitempty"` -} - -func (gp *GamePrize) BeforeCreate(tx *gorm.DB) error { - if gp.ID == uuid.Nil { - gp.ID = uuid.New() - } - return nil -} - -func (GamePrize) TableName() string { - return "game_prizes" -} diff --git a/internal/entities/reward.go b/internal/entities/reward.go deleted file mode 100644 index f05e417..0000000 --- a/internal/entities/reward.go +++ /dev/null @@ -1,126 +0,0 @@ -package entities - -import ( - "database/sql/driver" - "encoding/json" - "fmt" - "time" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -type RewardType string - -const ( - RewardTypeVoucher RewardType = "VOUCHER" - RewardTypePhysical RewardType = "PHYSICAL" - RewardTypeDigital RewardType = "DIGITAL" - RewardTypeBalance RewardType = "BALANCE" -) - -// StringSlice is a custom type for []string that implements sql.Scanner and driver.Valuer -type StringSlice []string - -// Scan implements the sql.Scanner interface for StringSlice -func (s *StringSlice) Scan(value interface{}) error { - if value == nil { - *s = StringSlice{} - return nil - } - - var bytes []byte - switch v := value.(type) { - case []byte: - bytes = v - case string: - bytes = []byte(v) - default: - return fmt.Errorf("cannot scan %T into StringSlice", value) - } - - return json.Unmarshal(bytes, s) -} - -// Value implements the driver.Valuer interface for StringSlice -func (s StringSlice) Value() (driver.Value, error) { - if s == nil { - return nil, nil - } - return json.Marshal(s) -} - -type TermsAndConditions struct { - Sections []TncSection `json:"sections"` - ExpiryDays int `json:"expiry_days"` -} - -// Scan implements the sql.Scanner interface for TermsAndConditions -func (t *TermsAndConditions) Scan(value interface{}) error { - if value == nil { - *t = TermsAndConditions{} - return nil - } - - var bytes []byte - switch v := value.(type) { - case []byte: - bytes = v - case string: - bytes = []byte(v) - default: - return fmt.Errorf("cannot scan %T into TermsAndConditions", value) - } - - return json.Unmarshal(bytes, t) -} - -// Value implements the driver.Valuer interface for TermsAndConditions -func (t TermsAndConditions) Value() (driver.Value, error) { - return json.Marshal(t) -} - -type TncSection struct { - Title string `json:"title"` - Rules []string `json:"rules"` -} - -type Reward struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - Name string `gorm:"type:varchar(150);not null" json:"name"` - RewardType RewardType `gorm:"type:varchar(50);not null" json:"reward_type"` - CostPoints int64 `gorm:"type:bigint;not null" json:"cost_points"` - Stock *int `gorm:"type:int" json:"stock,omitempty"` - MaxPerCustomer int `gorm:"type:int;default:1" json:"max_per_customer"` - Tnc *TermsAndConditions `gorm:"type:jsonb" json:"tnc,omitempty"` - Metadata *map[string]interface{} `gorm:"type:jsonb" json:"metadata,omitempty"` - Images *StringSlice `gorm:"type:jsonb" json:"images,omitempty"` - CreatedAt time.Time `gorm:"type:timestamp;default:now()" json:"created_at"` - UpdatedAt time.Time `gorm:"type:timestamp;default:now()" json:"updated_at"` -} - -func (Reward) TableName() string { - return "rewards" -} - -func (r *Reward) BeforeCreate(tx *gorm.DB) error { - if r.ID == uuid.Nil { - r.ID = uuid.New() - } - return nil -} - -func (r *Reward) BeforeUpdate(tx *gorm.DB) error { - r.UpdatedAt = time.Now() - return nil -} - -type ListRewardsRequest struct { - Page int `form:"page" binding:"min=1"` - Limit int `form:"limit" binding:"min=1,max=100"` - Search string `form:"search"` - RewardType string `form:"reward_type"` - MinPoints *int64 `form:"min_points"` - MaxPoints *int64 `form:"max_points"` - HasStock *bool `form:"has_stock"` -} diff --git a/internal/entities/string_slice.go b/internal/entities/string_slice.go new file mode 100644 index 0000000..775fbcf --- /dev/null +++ b/internal/entities/string_slice.go @@ -0,0 +1,38 @@ +package entities + +import ( + "database/sql/driver" + "encoding/json" + "fmt" +) + +// StringSlice is a custom type for []string that implements sql.Scanner and driver.Valuer +type StringSlice []string + +// Scan implements the sql.Scanner interface for StringSlice +func (s *StringSlice) Scan(value interface{}) error { + if value == nil { + *s = StringSlice{} + return nil + } + + var bytes []byte + switch v := value.(type) { + case []byte: + bytes = v + case string: + bytes = []byte(v) + default: + return fmt.Errorf("cannot scan %T into StringSlice", value) + } + + return json.Unmarshal(bytes, s) +} + +// Value implements the driver.Valuer interface for StringSlice +func (s StringSlice) Value() (driver.Value, error) { + if s == nil { + return nil, nil + } + return json.Marshal(s) +} diff --git a/internal/handler/customer_points_handler.go b/internal/handler/customer_points_handler.go index 8875d79..a5c49e6 100644 --- a/internal/handler/customer_points_handler.go +++ b/internal/handler/customer_points_handler.go @@ -93,36 +93,6 @@ func (h *CustomerPointsHandler) GetCustomerWallet(c *gin.Context) { util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerWallet") } -func (h *CustomerPointsHandler) GetCustomerGames(c *gin.Context) { - ctx := c.Request.Context() - - response, err := h.customerPointsService.GetCustomerGames(ctx) - if err != nil { - logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetCustomerGames -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()), - }), "CustomerPointsHandler::GetCustomerGames") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetCustomerGames") -} - -func (h *CustomerPointsHandler) GetFerrisWheelGame(c *gin.Context) { - ctx := c.Request.Context() - - response, err := h.customerPointsService.GetFerrisWheelGame(ctx) - if err != nil { - logger.FromContext(ctx).WithError(err).Error("CustomerPointsHandler::GetFerrisWheelGame -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.InternalServerErrorCode, constants.RequestEntity, err.Error()), - }), "CustomerPointsHandler::GetFerrisWheelGame") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "CustomerPointsHandler::GetFerrisWheelGame") -} - // GetCustomerWalletTransactions is GET /customer/wallet/transactions: the ledger, newest // first, filterable by currency, type and date (docs/prd-point-coin.md F6). func (h *CustomerPointsHandler) GetCustomerWalletTransactions(c *gin.Context) { diff --git a/internal/handler/customer_wallet_db_test.go b/internal/handler/customer_wallet_db_test.go index 7a0a6fc..5de2fe0 100644 --- a/internal/handler/customer_wallet_db_test.go +++ b/internal/handler/customer_wallet_db_test.go @@ -98,7 +98,6 @@ func TestCustomerWalletEndpoints_AgainstPostgres(t *testing.T) { h := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor( processor.NewWalletQueryProcessor(repository.NewWalletQueryRepository(db), processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))), - nil, ))) gin.SetMode(gin.TestMode) router := gin.New() diff --git a/internal/handler/enakgame_db_test.go b/internal/handler/enakgame_db_test.go index 6802818..044a19a 100644 --- a/internal/handler/enakgame_db_test.go +++ b/internal/handler/enakgame_db_test.go @@ -476,3 +476,134 @@ func TestEnakGameEventEndpoints_AgainstPostgres(t *testing.T) { assert.EqualValues(t, 5, events[0].(map[string]any)["bonus"]) assert.NotContains(t, events[0], "budget_id", "the budget stays internal") } + +// EG-1001: the admin steps of docs/enakgame-spin.md make a spin wheel, and a customer +// plays it through /customer/enakgame/sessions, over HTTP down to Postgres. +func TestEnakGameSpin_AgainstPostgres(t *testing.T) { + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL not set") + } + applogger.Setup("fatal", "json") + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}) + require.NoError(t, err) + + org, admin, customer := uuid.New(), uuid.New(), uuid.New() + require.NoError(t, db.Exec(`INSERT INTO organizations (id, name, plan_type) VALUES (?, 'enakgame spin', 'basic')`, org).Error) + require.NoError(t, db.Exec(`INSERT INTO customers (id, organization_id, name) VALUES (?, ?, 'Budi')`, customer, org).Error) + t.Cleanup(func() { + for _, q := range []string{ + `DELETE FROM audit_logs WHERE organization_id = ?`, + `DELETE FROM game_reward_counters WHERE organization_id = ?`, + `DELETE FROM game_session_rewards WHERE session_id IN (SELECT id FROM game_sessions WHERE organization_id = ?)`, + `DELETE FROM game_sessions WHERE organization_id = ?`, + `DELETE FROM wallet_lot_allocations WHERE lot_id IN (SELECT id FROM wallet_lots WHERE organization_id = ?)`, + `DELETE FROM wallet_lots WHERE organization_id = ?`, + `DELETE FROM wallet_transactions WHERE organization_id = ?`, + `DELETE FROM customer_wallets WHERE organization_id = ?`, + `DELETE FROM game_reward_configs WHERE organization_id = ?`, + `DELETE FROM game_budgets WHERE organization_id = ?`, + `DELETE FROM games WHERE organization_id = ?`, + `DELETE FROM customers WHERE organization_id = ?`, + `DELETE FROM organizations WHERE id = ?`, + } { + db.Exec(q, org) + } + }) + + txm := repository.NewTxManager(db) + wallet := processor.NewWalletProcessor(repository.NewWalletRepository(db)) + require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { + _, err := wallet.Credit(ctx, processor.WalletCreditInput{WalletEntry: processor.WalletEntry{ + CustomerID: customer, Currency: "COIN", Type: "MIGRATION", Amount: 20, + ReferenceType: "LEGACY_TOKENS", ReferenceID: uuid.New(), Description: "Saldo awal"}}) + return err + })) + + adminHandler, customerHandler := enakGameHandlers(db) + gin.SetMode(gin.TestMode) + router := gin.New() + m := router.Group("/marketing/enakgame", func(c *gin.Context) { + ctx := context.WithValue(c.Request.Context(), appcontext.OrganizationIDKey, org.String()) + ctx = context.WithValue(ctx, appcontext.UserIDKey, admin.String()) + ctx = context.WithValue(ctx, appcontext.UserRoleKey, "manager") + c.Request = c.Request.WithContext(ctx) + }) + m.POST("/games", adminHandler.CreateGame) + m.POST("/games/:id/reward-configs", adminHandler.CreateRewardConfig) + m.POST("/reward-configs/:id/activate", adminHandler.ActivateRewardConfig) + m.POST("/budgets", adminHandler.CreateBudget) + c := router.Group("/customer/enakgame", func(c *gin.Context) { c.Set("customer_id", customer.String()) }) + c.GET("/games", customerHandler.ListGames) + c.POST("/sessions", customerHandler.StartSession) + c.POST("/sessions/:id/complete", customerHandler.CompleteSession) + + call := func(method, path, body string, headers ...string) map[string]any { + t.Helper() + req := httptest.NewRequest(method, path, bytes.NewBufferString(body)) + for i := 0; i+1 < len(headers); i += 2 { + req.Header.Set(headers[i], headers[i+1]) + } + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + var out map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), rec.Body.String()) + require.Equal(t, http.StatusOK, rec.Code, out) + return out["data"].(map[string]any) + } + + // The admin steps. + game := call(http.MethodPost, "/marketing/enakgame/games", + `{"name": "Spin Harian", "slug": "spin", "type": "SPIN", "entry_cost": 5, "status": "ACTIVE"}`) + gameID := game["id"].(string) + config := call(http.MethodPost, "/marketing/enakgame/games/"+gameID+"/reward-configs", `{"reward_type": "PROBABILITY", "max_reward": 50, + "rules": {"table": [ + {"weight": 50, "amount": 0, "label": "Zonk"}, + {"weight": 30, "amount": 3, "label": "3 Coin"}, + {"weight": 15, "amount": 10, "label": "10 Coin"}, + {"weight": 5, "amount": 50, "label": "Jackpot"}]}}`) + call(http.MethodPost, "/marketing/enakgame/reward-configs/"+config["id"].(string)+"/activate", `{}`) + now := time.Now().In(time.FixedZone("WIB", 7*3600)) + first := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC) + call(http.MethodPost, "/marketing/enakgame/budgets", `{"scope": "GLOBAL", "name": "Bulan ini", "period_start": "`+ + first.Format("2006-01-02")+`", "period_end": "`+first.AddDate(0, 1, -1).Format("2006-01-02")+`", "amount": 1000000}`) + + // The wheel shows the segments, never their odds. + req := httptest.NewRequest(http.MethodGet, "/customer/enakgame/games", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + var listed struct { + Data []struct { + ID string `json:"id"` + Prizes []struct { + Entry int `json:"entry"` + Label *string `json:"label"` + Amount int64 `json:"amount"` + } `json:"prizes"` + } `json:"data"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &listed)) + require.Len(t, listed.Data, 1) + require.Len(t, listed.Data[0].Prizes, 4) + assert.Equal(t, "Jackpot", *listed.Data[0].Prizes[3].Label) + assert.NotContains(t, rec.Body.String(), "weight") + + // Two spins: each lands on a listed segment and pays what it shows. + balance := int64(20) + for _, key := range []string{"spin-1", "spin-2"} { + started := call(http.MethodPost, "/customer/enakgame/sessions", `{"game_id": "`+gameID+`"}`, "Idempotency-Key", key) + balance -= 5 + assert.EqualValues(t, balance, started["coin_balance"]) + done := call(http.MethodPost, "/customer/enakgame/sessions/"+started["session_id"].(string)+"/complete", `{}`) + prize := done["prize"].(map[string]any) + entry := int(prize["entry"].(float64)) + require.True(t, entry >= 1 && entry <= 4, prize) + segment := listed.Data[0].Prizes[entry-1] + assert.Equal(t, *segment.Label, prize["label"]) + assert.EqualValues(t, segment.Amount, prize["amount"]) + assert.EqualValues(t, segment.Amount, done["reward_total"]) + balance += segment.Amount + assert.EqualValues(t, balance, done["coin_balance"]) + } +} diff --git a/internal/handler/gamification_handler.go b/internal/handler/gamification_handler.go index 4c3c606..4e2d9a0 100644 --- a/internal/handler/gamification_handler.go +++ b/internal/handler/gamification_handler.go @@ -1,14 +1,8 @@ package handler import ( - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/logger" "apskel-pos-be/internal/service" - "apskel-pos-be/internal/util" "apskel-pos-be/internal/validator" - - "github.com/gin-gonic/gin" ) type GamificationHandler struct { @@ -25,32 +19,3 @@ func NewGamificationHandler( gamificationValidator: gamificationValidator, } } - -// Play Game Handler -func (h *GamificationHandler) PlayGame(c *gin.Context) { - ctx := c.Request.Context() - var req contract.PlayGameRequest - if err := c.ShouldBindJSON(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::PlayGame -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::PlayGame") - return - } - - validationError, validationErrorCode := h.gamificationValidator.ValidatePlayGameRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::PlayGame -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::PlayGame") - return - } - - response, err := h.gamificationService.PlayGame(ctx, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::PlayGame -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GameEntity, err.Error())}), "GamificationHandler::PlayGame") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::PlayGame") -} diff --git a/internal/handler/gamification_handler_additional.go b/internal/handler/gamification_handler_additional.go index a5bf803..0cc4470 100644 --- a/internal/handler/gamification_handler_additional.go +++ b/internal/handler/gamification_handler_additional.go @@ -168,410 +168,6 @@ func (h *GamificationHandler) GetTierByPoints(c *gin.Context) { util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetTierByPoints") } -// Game Handlers -func (h *GamificationHandler) CreateGame(c *gin.Context) { - ctx := c.Request.Context() - var req contract.CreateGameRequest - if err := c.ShouldBindJSON(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateGame -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateGame") - return - } - - validationError, validationErrorCode := h.gamificationValidator.ValidateCreateGameRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::CreateGame -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateGame") - return - } - - response, err := h.gamificationService.CreateGame(ctx, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateGame -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GameEntity, err.Error())}), "GamificationHandler::CreateGame") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::CreateGame") -} - -func (h *GamificationHandler) GetGame(c *gin.Context) { - ctx := c.Request.Context() - idStr := c.Param("id") - id, err := uuid.Parse(idStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetGame -> invalid ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.GameEntity, "Invalid ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetGame") - return - } - - response, err := h.gamificationService.GetGame(ctx, id) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetGame -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GameEntity, err.Error())}), "GamificationHandler::GetGame") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetGame") -} - -func (h *GamificationHandler) ListGames(c *gin.Context) { - ctx := c.Request.Context() - var req contract.ListGamesRequest - if err := c.ShouldBindQuery(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListGames -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListGames") - return - } - - validationError, validationErrorCode := h.gamificationValidator.ValidateListGamesRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::ListGames -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListGames") - return - } - - response, err := h.gamificationService.ListGames(ctx, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListGames -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GameEntity, err.Error())}), "GamificationHandler::ListGames") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::ListGames") -} - -func (h *GamificationHandler) GetActiveGames(c *gin.Context) { - ctx := c.Request.Context() - response, err := h.gamificationService.GetActiveGames(ctx) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetActiveGames -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GameEntity, err.Error())}), "GamificationHandler::GetActiveGames") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetActiveGames") -} - -func (h *GamificationHandler) UpdateGame(c *gin.Context) { - ctx := c.Request.Context() - idStr := c.Param("id") - id, err := uuid.Parse(idStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateGame -> invalid ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.GameEntity, "Invalid ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateGame") - return - } - - var req contract.UpdateGameRequest - if err := c.ShouldBindJSON(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateGame -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateGame") - return - } - - validationError, validationErrorCode := h.gamificationValidator.ValidateUpdateGameRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::UpdateGame -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateGame") - return - } - - response, err := h.gamificationService.UpdateGame(ctx, id, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateGame -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GameEntity, err.Error())}), "GamificationHandler::UpdateGame") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::UpdateGame") -} - -func (h *GamificationHandler) DeleteGame(c *gin.Context) { - ctx := c.Request.Context() - idStr := c.Param("id") - id, err := uuid.Parse(idStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteGame -> invalid ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.GameEntity, "Invalid ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeleteGame") - return - } - - err = h.gamificationService.DeleteGame(ctx, id) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteGame -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GameEntity, err.Error())}), "GamificationHandler::DeleteGame") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(nil), "GamificationHandler::DeleteGame") -} - -// Game Prize Handlers -func (h *GamificationHandler) CreateGamePrize(c *gin.Context) { - ctx := c.Request.Context() - var req contract.CreateGamePrizeRequest - if err := c.ShouldBindJSON(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateGamePrize -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateGamePrize") - return - } - - validationError, validationErrorCode := h.gamificationValidator.ValidateCreateGamePrizeRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::CreateGamePrize -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateGamePrize") - return - } - - response, err := h.gamificationService.CreateGamePrize(ctx, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateGamePrize -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePrizeEntity, err.Error())}), "GamificationHandler::CreateGamePrize") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::CreateGamePrize") -} - -func (h *GamificationHandler) GetGamePrize(c *gin.Context) { - ctx := c.Request.Context() - idStr := c.Param("id") - id, err := uuid.Parse(idStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetGamePrize -> invalid ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.GamePrizeEntity, "Invalid ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetGamePrize") - return - } - - response, err := h.gamificationService.GetGamePrize(ctx, id) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetGamePrize -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePrizeEntity, err.Error())}), "GamificationHandler::GetGamePrize") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetGamePrize") -} - -func (h *GamificationHandler) ListGamePrizes(c *gin.Context) { - ctx := c.Request.Context() - var req contract.ListGamePrizesRequest - if err := c.ShouldBindQuery(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListGamePrizes -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListGamePrizes") - return - } - - validationError, validationErrorCode := h.gamificationValidator.ValidateListGamePrizesRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::ListGamePrizes -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListGamePrizes") - return - } - - response, err := h.gamificationService.ListGamePrizes(ctx, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListGamePrizes -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePrizeEntity, err.Error())}), "GamificationHandler::ListGamePrizes") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::ListGamePrizes") -} - -func (h *GamificationHandler) UpdateGamePrize(c *gin.Context) { - ctx := c.Request.Context() - idStr := c.Param("id") - id, err := uuid.Parse(idStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateGamePrize -> invalid ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.GamePrizeEntity, "Invalid ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateGamePrize") - return - } - - var req contract.UpdateGamePrizeRequest - if err := c.ShouldBindJSON(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateGamePrize -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateGamePrize") - return - } - - validationError, validationErrorCode := h.gamificationValidator.ValidateUpdateGamePrizeRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::UpdateGamePrize -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::UpdateGamePrize") - return - } - - response, err := h.gamificationService.UpdateGamePrize(ctx, id, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::UpdateGamePrize -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePrizeEntity, err.Error())}), "GamificationHandler::UpdateGamePrize") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::UpdateGamePrize") -} - -func (h *GamificationHandler) DeleteGamePrize(c *gin.Context) { - ctx := c.Request.Context() - idStr := c.Param("id") - id, err := uuid.Parse(idStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteGamePrize -> invalid ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.GamePrizeEntity, "Invalid ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::DeleteGamePrize") - return - } - - err = h.gamificationService.DeleteGamePrize(ctx, id) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::DeleteGamePrize -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePrizeEntity, err.Error())}), "GamificationHandler::DeleteGamePrize") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(nil), "GamificationHandler::DeleteGamePrize") -} - -func (h *GamificationHandler) GetGamePrizesByGameID(c *gin.Context) { - ctx := c.Request.Context() - gameIDStr := c.Param("game_id") - gameID, err := uuid.Parse(gameIDStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetGamePrizesByGameID -> invalid game ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.GamePrizeEntity, "Invalid game ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetGamePrizesByGameID") - return - } - - response, err := h.gamificationService.GetGamePrizesByGameID(ctx, gameID) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetGamePrizesByGameID -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePrizeEntity, err.Error())}), "GamificationHandler::GetGamePrizesByGameID") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetGamePrizesByGameID") -} - -func (h *GamificationHandler) GetAvailablePrizes(c *gin.Context) { - ctx := c.Request.Context() - gameIDStr := c.Param("game_id") - gameID, err := uuid.Parse(gameIDStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetAvailablePrizes -> invalid game ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.GamePrizeEntity, "Invalid game ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetAvailablePrizes") - return - } - - response, err := h.gamificationService.GetAvailablePrizes(ctx, gameID) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetAvailablePrizes -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePrizeEntity, err.Error())}), "GamificationHandler::GetAvailablePrizes") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetAvailablePrizes") -} - -// Game Play Handlers -func (h *GamificationHandler) CreateGamePlay(c *gin.Context) { - ctx := c.Request.Context() - var req contract.CreateGamePlayRequest - if err := c.ShouldBindJSON(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateGamePlay -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateGamePlay") - return - } - - validationError, validationErrorCode := h.gamificationValidator.ValidateCreateGamePlayRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::CreateGamePlay -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::CreateGamePlay") - return - } - - response, err := h.gamificationService.CreateGamePlay(ctx, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::CreateGamePlay -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePlayEntity, err.Error())}), "GamificationHandler::CreateGamePlay") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::CreateGamePlay") -} - -func (h *GamificationHandler) GetGamePlay(c *gin.Context) { - ctx := c.Request.Context() - idStr := c.Param("id") - id, err := uuid.Parse(idStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetGamePlay -> invalid ID") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.GamePlayEntity, "Invalid ID format") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::GetGamePlay") - return - } - - response, err := h.gamificationService.GetGamePlay(ctx, id) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::GetGamePlay -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePlayEntity, err.Error())}), "GamificationHandler::GetGamePlay") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::GetGamePlay") -} - -func (h *GamificationHandler) ListGamePlays(c *gin.Context) { - ctx := c.Request.Context() - var req contract.ListGamePlaysRequest - if err := c.ShouldBindQuery(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListGamePlays -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListGamePlays") - return - } - - validationError, validationErrorCode := h.gamificationValidator.ValidateListGamePlaysRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("GamificationHandler::ListGamePlays -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "GamificationHandler::ListGamePlays") - return - } - - response, err := h.gamificationService.ListGamePlays(ctx, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("GamificationHandler::ListGamePlays -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.GamePlayEntity, err.Error())}), "GamificationHandler::ListGamePlays") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "GamificationHandler::ListGamePlays") -} - // Omset Tracker Handlers func (h *GamificationHandler) CreateOmsetTracker(c *gin.Context) { ctx := c.Request.Context() diff --git a/internal/handler/reward_handler.go b/internal/handler/reward_handler.go deleted file mode 100644 index 4988667..0000000 --- a/internal/handler/reward_handler.go +++ /dev/null @@ -1,206 +0,0 @@ -package handler - -import ( - "strconv" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/logger" - "apskel-pos-be/internal/service" - "apskel-pos-be/internal/util" - "apskel-pos-be/internal/validator" - - "github.com/gin-gonic/gin" -) - -type RewardHandler struct { - rewardService service.RewardService - rewardValidator validator.RewardValidator -} - -func NewRewardHandler(rewardService service.RewardService, rewardValidator validator.RewardValidator) *RewardHandler { - return &RewardHandler{ - rewardService: rewardService, - rewardValidator: rewardValidator, - } -} - -func (h *RewardHandler) CreateReward(c *gin.Context) { - ctx := c.Request.Context() - - var req contract.CreateRewardRequest - if err := c.ShouldBindJSON(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::CreateReward -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::CreateReward") - return - } - - validationError, validationErrorCode := h.rewardValidator.ValidateCreateRewardRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("RewardHandler::CreateReward -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::CreateReward") - return - } - - response, err := h.rewardService.CreateReward(ctx, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::CreateReward -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RewardEntity, err.Error())}), "RewardHandler::CreateReward") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "RewardHandler::CreateReward") -} - -func (h *RewardHandler) GetReward(c *gin.Context) { - ctx := c.Request.Context() - - idStr := c.Param("id") - if idStr == "" { - logger.FromContext(c.Request.Context()).Error("RewardHandler::GetReward -> missing ID parameter") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RewardEntity, "ID parameter is required") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::GetReward") - return - } - - response, err := h.rewardService.GetReward(ctx, idStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::GetReward -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RewardEntity, err.Error())}), "RewardHandler::GetReward") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "RewardHandler::GetReward") -} - -func (h *RewardHandler) ListRewards(c *gin.Context) { - ctx := c.Request.Context() - - var req contract.ListRewardsRequest - if err := c.ShouldBindQuery(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::ListRewards -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::ListRewards") - return - } - - validationError, validationErrorCode := h.rewardValidator.ValidateListRewardsRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("RewardHandler::ListRewards -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::ListRewards") - return - } - - response, err := h.rewardService.ListRewards(ctx, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::ListRewards -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RewardEntity, err.Error())}), "RewardHandler::ListRewards") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "RewardHandler::ListRewards") -} - -func (h *RewardHandler) UpdateReward(c *gin.Context) { - ctx := c.Request.Context() - - var req contract.UpdateRewardRequest - if err := c.ShouldBindJSON(&req); err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::UpdateReward -> request binding failed") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RequestEntity, err.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::UpdateReward") - return - } - - validationError, validationErrorCode := h.rewardValidator.ValidateUpdateRewardRequest(&req) - if validationError != nil { - logger.FromContext(c.Request.Context()).WithError(validationError).Error("RewardHandler::UpdateReward -> request validation failed") - validationResponseError := contract.NewResponseError(validationErrorCode, constants.RequestEntity, validationError.Error()) - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::UpdateReward") - return - } - - response, err := h.rewardService.UpdateReward(ctx, &req) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::UpdateReward -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RewardEntity, err.Error())}), "RewardHandler::UpdateReward") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "RewardHandler::UpdateReward") -} - -func (h *RewardHandler) DeleteReward(c *gin.Context) { - ctx := c.Request.Context() - - idStr := c.Param("id") - if idStr == "" { - logger.FromContext(c.Request.Context()).Error("RewardHandler::DeleteReward -> missing ID parameter") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RewardEntity, "ID parameter is required") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::DeleteReward") - return - } - - err := h.rewardService.DeleteReward(ctx, idStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::DeleteReward -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RewardEntity, err.Error())}), "RewardHandler::DeleteReward") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse("Reward deleted successfully"), "RewardHandler::DeleteReward") -} - -func (h *RewardHandler) UpdateRewardStock(c *gin.Context) { - ctx := c.Request.Context() - - idStr := c.Param("id") - if idStr == "" { - logger.FromContext(c.Request.Context()).Error("RewardHandler::UpdateRewardStock -> missing ID parameter") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RewardEntity, "ID parameter is required") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::UpdateRewardStock") - return - } - - stockStr := c.Param("stock") - stock, err := strconv.Atoi(stockStr) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::UpdateRewardStock -> invalid stock parameter") - validationResponseError := contract.NewResponseError(constants.InvalidFieldErrorCode, constants.RewardEntity, "Invalid stock parameter") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::UpdateRewardStock") - return - } - - err = h.rewardService.UpdateRewardStock(ctx, idStr, stock) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::UpdateRewardStock -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RewardEntity, err.Error())}), "RewardHandler::UpdateRewardStock") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse("Reward stock updated successfully"), "RewardHandler::UpdateRewardStock") -} - -func (h *RewardHandler) GetRewardsByType(c *gin.Context) { - ctx := c.Request.Context() - - rewardType := c.Param("type") - if rewardType == "" { - logger.FromContext(c.Request.Context()).Error("RewardHandler::GetRewardsByType -> missing type parameter") - validationResponseError := contract.NewResponseError(constants.MissingFieldErrorCode, constants.RewardEntity, "Type parameter is required") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{validationResponseError}), "RewardHandler::GetRewardsByType") - return - } - - response, err := h.rewardService.GetRewardsByType(ctx, rewardType) - if err != nil { - logger.FromContext(c.Request.Context()).WithError(err).Error("RewardHandler::GetRewardsByType -> service call failed") - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError(constants.InternalServerErrorCode, constants.RewardEntity, err.Error())}), "RewardHandler::GetRewardsByType") - return - } - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(response), "RewardHandler::GetRewardsByType") -} diff --git a/internal/handler/spin_game_handler.go b/internal/handler/spin_game_handler.go deleted file mode 100644 index b7c033d..0000000 --- a/internal/handler/spin_game_handler.go +++ /dev/null @@ -1,71 +0,0 @@ -package handler - -import ( - "errors" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/mappers" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/processor" - "apskel-pos-be/internal/service" - "apskel-pos-be/internal/util" - - "github.com/gin-gonic/gin" -) - -type SpinGameHandler struct { - spinGameService service.SpinGameService -} - -func NewSpinGameHandler(spinGameService service.SpinGameService) *SpinGameHandler { - return &SpinGameHandler{ - spinGameService: spinGameService, - } -} - -// PlaySpinGame handles the spin game play request -func (h *SpinGameHandler) PlaySpinGame(c *gin.Context) { - // Get customer ID from JWT token (set by middleware) - customerID, exists := c.Get("customer_id") - if !exists { - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.ValidationErrorCode, constants.SpinGameHandlerEntity, "Customer ID not found in context"), - }), "SpinGameHandler::PlaySpinGame") - return - } - - // Parse spin ID from request body - var req contract.SpinGameRequest - if err := c.ShouldBindJSON(&req); err != nil { - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(constants.ValidationErrorCode, constants.SpinGameHandlerEntity, err.Error()), - }), "SpinGameHandler::PlaySpinGame") - return - } - - // Convert to model - modelReq := &models.SpinGameRequest{ - SpinID: req.SpinID, - } - - // Play the spin game - response, err := h.spinGameService.PlaySpinGame(c.Request.Context(), customerID.(string), modelReq) - if err != nil { - // Not enough EnakCoin, an inactive game or a prize that just ran out is the - // customer's to fix, not a server failure. - code := constants.InternalServerErrorCode - if errors.Is(err, processor.ErrGamePlayRefused) { - code = constants.ValidationErrorCode - } - util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{ - contract.NewResponseError(code, constants.SpinGameHandlerEntity, err.Error()), - }), "SpinGameHandler::PlaySpinGame") - return - } - - // Convert model response to contract response - contractResponse := mappers.SpinGameResponseModelToContract(response) - - util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(contractResponse.Data), "SpinGameHandler::PlaySpinGame") -} diff --git a/internal/handler/wallet_admin_db_test.go b/internal/handler/wallet_admin_db_test.go index 7c4de20..9958c87 100644 --- a/internal/handler/wallet_admin_db_test.go +++ b/internal/handler/wallet_admin_db_test.go @@ -186,7 +186,7 @@ func TestWalletAdminEndpoints_AgainstPostgres(t *testing.T) { // The customer's own history shows the adjustment too, with the reason in the // description and without the admin's identity fields. customerView := NewCustomerPointsHandler(service.NewCustomerPointsService(processor.NewCustomerPointsProcessor( - processor.NewWalletQueryProcessor(queryRepo, processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db))), nil))) + processor.NewWalletQueryProcessor(queryRepo, processor.NewLoyaltySettingsProcessor(repository.NewLoyaltySettingsRepository(db), repository.NewTxManager(db)))))) crouter := gin.New() crouter.GET("/wallet/transactions", func(c *gin.Context) { c.Set("customer_id", customer.String()) }, customerView.GetCustomerWalletTransactions) rec := httptest.NewRecorder() diff --git a/internal/mappers/game_mapper.go b/internal/mappers/game_mapper.go deleted file mode 100644 index 5e7001f..0000000 --- a/internal/mappers/game_mapper.go +++ /dev/null @@ -1,58 +0,0 @@ -package mappers - -import ( - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/models" -) - -// ToGameResponse converts a game entity to a game response -func ToGameResponse(game *entities.Game) *models.GameResponse { - if game == nil { - return nil - } - - return &models.GameResponse{ - ID: game.ID, - Name: game.Name, - Type: string(game.Type), - IsActive: game.IsActive, - Metadata: game.Metadata, - CreatedAt: game.CreatedAt, - UpdatedAt: game.UpdatedAt, - } -} - -// ToGameResponses converts a slice of game entities to game responses -func ToGameResponses(games []entities.Game) []models.GameResponse { - responses := make([]models.GameResponse, len(games)) - for i, game := range games { - responses[i] = *ToGameResponse(&game) - } - return responses -} - -// ToGameEntity converts a create game request to a game entity -func ToGameEntity(req *models.CreateGameRequest) *entities.Game { - return &entities.Game{ - Name: req.Name, - Type: entities.GameType(req.Type), - IsActive: req.IsActive, - Metadata: req.Metadata, - } -} - -// UpdateGameEntity updates a game entity with update request data -func UpdateGameEntity(game *entities.Game, req *models.UpdateGameRequest) { - if req.Name != nil { - game.Name = *req.Name - } - if req.Type != nil { - game.Type = entities.GameType(*req.Type) - } - if req.IsActive != nil { - game.IsActive = *req.IsActive - } - if req.Metadata != nil { - game.Metadata = req.Metadata - } -} diff --git a/internal/mappers/game_play_mapper.go b/internal/mappers/game_play_mapper.go deleted file mode 100644 index 659ca51..0000000 --- a/internal/mappers/game_play_mapper.go +++ /dev/null @@ -1,54 +0,0 @@ -package mappers - -import ( - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/models" -) - -// ToGamePlayResponse converts a game play entity to a game play response -func ToGamePlayResponse(gamePlay *entities.GamePlay) *models.GamePlayResponse { - if gamePlay == nil { - return nil - } - - return &models.GamePlayResponse{ - ID: gamePlay.ID, - GameID: gamePlay.GameID, - CustomerID: gamePlay.CustomerID, - PrizeID: gamePlay.PrizeID, - CoinsUsed: gamePlay.CoinsUsed, - RandomSeed: gamePlay.RandomSeed, - CreatedAt: gamePlay.CreatedAt, - Game: ToGameResponse(&gamePlay.Game), - Customer: ToCustomerResponse(&gamePlay.Customer), - Prize: ToGamePrizeResponse(gamePlay.Prize), - } -} - -// ToGamePlayResponses converts a slice of game play entities to game play responses -func ToGamePlayResponses(gamePlays []entities.GamePlay) []models.GamePlayResponse { - responses := make([]models.GamePlayResponse, len(gamePlays)) - for i, gp := range gamePlays { - responses[i] = *ToGamePlayResponse(&gp) - } - return responses -} - -// ToGamePlayResponsesFromPointers converts a slice of game play entity pointers to game play responses -func ToGamePlayResponsesFromPointers(gamePlays []*entities.GamePlay) []models.GamePlayResponse { - responses := make([]models.GamePlayResponse, len(gamePlays)) - for i, gp := range gamePlays { - responses[i] = *ToGamePlayResponse(gp) - } - return responses -} - -// ToGamePlayEntity converts a create game play request to a game play entity -func ToGamePlayEntity(req *models.CreateGamePlayRequest) *entities.GamePlay { - return &entities.GamePlay{ - GameID: req.GameID, - CustomerID: req.CustomerID, - CoinsUsed: req.CoinsUsed, - RandomSeed: req.RandomSeed, - } -} diff --git a/internal/mappers/game_prize_mapper.go b/internal/mappers/game_prize_mapper.go deleted file mode 100644 index bf09fc7..0000000 --- a/internal/mappers/game_prize_mapper.go +++ /dev/null @@ -1,82 +0,0 @@ -package mappers - -import ( - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/models" -) - -// ToGamePrizeResponse converts a game prize entity to a game prize response -func ToGamePrizeResponse(gamePrize *entities.GamePrize) *models.GamePrizeResponse { - if gamePrize == nil { - return nil - } - - return &models.GamePrizeResponse{ - ID: gamePrize.ID, - GameID: gamePrize.GameID, - Name: gamePrize.Name, - Weight: gamePrize.Weight, - Stock: gamePrize.Stock, - MaxStock: gamePrize.MaxStock, - Threshold: gamePrize.Threshold, - FallbackPrizeID: gamePrize.FallbackPrizeID, - Image: gamePrize.Image, - Metadata: gamePrize.Metadata, - Game: ToGameResponse(&gamePrize.Game), - FallbackPrize: ToGamePrizeResponse(gamePrize.FallbackPrize), - CreatedAt: gamePrize.CreatedAt, - UpdatedAt: gamePrize.UpdatedAt, - } -} - -// ToGamePrizeResponses converts a slice of game prize entities to game prize responses -func ToGamePrizeResponses(gamePrizes []entities.GamePrize) []models.GamePrizeResponse { - responses := make([]models.GamePrizeResponse, len(gamePrizes)) - for i, gp := range gamePrizes { - responses[i] = *ToGamePrizeResponse(&gp) - } - return responses -} - -// ToGamePrizeEntity converts a create game prize request to a game prize entity -func ToGamePrizeEntity(req *models.CreateGamePrizeRequest) *entities.GamePrize { - return &entities.GamePrize{ - GameID: req.GameID, - Name: req.Name, - Weight: req.Weight, - Stock: req.Stock, - MaxStock: req.MaxStock, - Threshold: req.Threshold, - FallbackPrizeID: req.FallbackPrizeID, - Image: req.Image, - Metadata: req.Metadata, - } -} - -// UpdateGamePrizeEntity updates a game prize entity with update request data -func UpdateGamePrizeEntity(gamePrize *entities.GamePrize, req *models.UpdateGamePrizeRequest) { - if req.Name != nil { - gamePrize.Name = *req.Name - } - if req.Weight != nil { - gamePrize.Weight = *req.Weight - } - if req.Stock != nil { - gamePrize.Stock = *req.Stock - } - if req.MaxStock != nil { - gamePrize.MaxStock = req.MaxStock - } - if req.Threshold != nil { - gamePrize.Threshold = req.Threshold - } - if req.FallbackPrizeID != nil { - gamePrize.FallbackPrizeID = req.FallbackPrizeID - } - if req.Image != nil { - gamePrize.Image = req.Image - } - if req.Metadata != nil { - gamePrize.Metadata = req.Metadata - } -} diff --git a/internal/mappers/omset_tracker_mapper.go b/internal/mappers/omset_tracker_mapper.go index dacf25b..f85c2bd 100644 --- a/internal/mappers/omset_tracker_mapper.go +++ b/internal/mappers/omset_tracker_mapper.go @@ -18,7 +18,6 @@ func ToOmsetTrackerResponse(omsetTracker *entities.OmsetTracker) *models.OmsetTr PeriodEnd: omsetTracker.PeriodEnd, Total: omsetTracker.Total, GameID: omsetTracker.GameID, - Game: ToGameResponse(omsetTracker.Game), CreatedAt: omsetTracker.CreatedAt, UpdatedAt: omsetTracker.UpdatedAt, } diff --git a/internal/mappers/reward_mapper.go b/internal/mappers/reward_mapper.go deleted file mode 100644 index d908ab7..0000000 --- a/internal/mappers/reward_mapper.go +++ /dev/null @@ -1,138 +0,0 @@ -package mappers - -import ( - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/models" -) - -func ToRewardResponse(entity *entities.Reward) *models.RewardResponse { - if entity == nil { - return nil - } - - var tnc *models.TermsAndConditionsStruct - if entity.Tnc != nil { - var sections []models.TncSectionStruct - for _, section := range entity.Tnc.Sections { - sections = append(sections, models.TncSectionStruct{ - Title: section.Title, - Rules: section.Rules, - }) - } - tnc = &models.TermsAndConditionsStruct{ - Sections: sections, - ExpiryDays: entity.Tnc.ExpiryDays, - } - } - - var images *[]string - if entity.Images != nil { - imagesSlice := []string(*entity.Images) - images = &imagesSlice - } - - return &models.RewardResponse{ - ID: entity.ID, - Name: entity.Name, - RewardType: string(entity.RewardType), - CostPoints: entity.CostPoints, - Stock: entity.Stock, - MaxPerCustomer: entity.MaxPerCustomer, - Tnc: tnc, - Metadata: entity.Metadata, - Images: images, - CreatedAt: entity.CreatedAt, - UpdatedAt: entity.UpdatedAt, - } -} - -func ToRewardEntity(request *contract.CreateRewardRequest) *entities.Reward { - if request == nil { - return nil - } - - var tnc *entities.TermsAndConditions - if request.Tnc != nil { - var sections []entities.TncSection - for _, section := range request.Tnc.Sections { - sections = append(sections, entities.TncSection{ - Title: section.Title, - Rules: section.Rules, - }) - } - tnc = &entities.TermsAndConditions{ - Sections: sections, - ExpiryDays: request.Tnc.ExpiryDays, - } - } - - var images *entities.StringSlice - if request.Images != nil { - stringSlice := entities.StringSlice(*request.Images) - images = &stringSlice - } - - return &entities.Reward{ - Name: request.Name, - RewardType: entities.RewardType(request.RewardType), - CostPoints: request.CostPoints, - Stock: request.Stock, - MaxPerCustomer: request.MaxPerCustomer, - Tnc: tnc, - Metadata: request.Metadata, - Images: images, - } -} - -func ToRewardEntityFromUpdate(request *contract.UpdateRewardRequest) *entities.Reward { - if request == nil { - return nil - } - - var tnc *entities.TermsAndConditions - if request.Tnc != nil { - var sections []entities.TncSection - for _, section := range request.Tnc.Sections { - sections = append(sections, entities.TncSection{ - Title: section.Title, - Rules: section.Rules, - }) - } - tnc = &entities.TermsAndConditions{ - Sections: sections, - ExpiryDays: request.Tnc.ExpiryDays, - } - } - - var images *entities.StringSlice - if request.Images != nil { - stringSlice := entities.StringSlice(*request.Images) - images = &stringSlice - } - - return &entities.Reward{ - ID: request.ID, - Name: request.Name, - RewardType: entities.RewardType(request.RewardType), - CostPoints: request.CostPoints, - Stock: request.Stock, - MaxPerCustomer: request.MaxPerCustomer, - Tnc: tnc, - Metadata: request.Metadata, - Images: images, - } -} - -func ToRewardResponseSlice(entities []entities.Reward) []models.RewardResponse { - if entities == nil { - return nil - } - - responses := make([]models.RewardResponse, len(entities)) - for i, entity := range entities { - responses[i] = *ToRewardResponse(&entity) - } - - return responses -} diff --git a/internal/mappers/spin_game_mapper.go b/internal/mappers/spin_game_mapper.go deleted file mode 100644 index b38be64..0000000 --- a/internal/mappers/spin_game_mapper.go +++ /dev/null @@ -1,80 +0,0 @@ -package mappers - -import ( - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/models" -) - -// SpinGameRequestContractToModel converts spin game request from contract to model -func SpinGameRequestContractToModel(req *contract.SpinGameRequest) *models.SpinGameRequest { - if req == nil { - return nil - } - - return &models.SpinGameRequest{ - SpinID: req.SpinID, - } -} - -// SpinGameResponseModelToContract converts spin game response from model to contract -func SpinGameResponseModelToContract(resp *models.SpinGameResponse) *contract.SpinGameResponse { - if resp == nil { - return nil - } - - return &contract.SpinGameResponse{ - Status: resp.Status, - Message: resp.Message, - Data: SpinGameResponseDataModelToContract(resp.Data), - } -} - -// SpinGameResponseDataModelToContract converts spin game response data from model to contract -func SpinGameResponseDataModelToContract(data *models.SpinGameResponseData) *contract.SpinGameResponseData { - if data == nil { - return nil - } - - return &contract.SpinGameResponseData{ - GamePlay: GamePlayResponseModelToContract(&data.GamePlay), - PrizeWon: CustomerGamePrizeResponseModelToContract(data.PrizeWon), - CoinsRemaining: data.CoinsRemaining, - } -} - -// GamePlayResponseModelToContract converts game play response from model to contract -func GamePlayResponseModelToContract(resp *models.GamePlayResponse) contract.GamePlayResponse { - if resp == nil { - return contract.GamePlayResponse{} - } - - return contract.GamePlayResponse{ - ID: resp.ID, - GameID: resp.GameID, - CustomerID: resp.CustomerID, - PrizeID: resp.PrizeID, - CoinsUsed: resp.CoinsUsed, - RandomSeed: resp.RandomSeed, - CreatedAt: resp.CreatedAt, - Game: nil, // Optional field - can be populated separately if needed - Customer: nil, // Optional field - can be populated separately if needed - Prize: nil, // Optional field - can be populated separately if needed - } -} - -// CustomerGamePrizeResponseModelToContract converts customer game prize response from model to contract -func CustomerGamePrizeResponseModelToContract(resp *models.CustomerGamePrizeResponse) *contract.CustomerGamePrizeResponse { - if resp == nil { - return nil - } - - return &contract.CustomerGamePrizeResponse{ - ID: resp.ID, - GameID: resp.GameID, - Name: resp.Name, - Image: resp.Image, - Metadata: resp.Metadata, - CreatedAt: resp.CreatedAt, - UpdatedAt: resp.UpdatedAt, - } -} diff --git a/internal/models/customer_game.go b/internal/models/customer_game.go deleted file mode 100644 index 6fb7e9d..0000000 --- a/internal/models/customer_game.go +++ /dev/null @@ -1,59 +0,0 @@ -package models - -import ( - "time" - - "github.com/google/uuid" -) - -// Request Models -type GetCustomerGamesRequest struct { - // No additional fields needed - customer ID comes from JWT token -} - -// Response Models -type GetCustomerGamesResponse struct { - Status string `json:"status"` - Message string `json:"message"` - Data *GetCustomerGamesResponseData `json:"data,omitempty"` -} - -type GetCustomerGamesResponseData struct { - Games []CustomerGameResponse `json:"games"` -} - -type CustomerGameResponse struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - IsActive bool `json:"is_active"` - Metadata *map[string]interface{} `json:"metadata,omitempty"` - Prizes []CustomerGamePrizeResponse `json:"prizes,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type CustomerGamePrizeResponse struct { - ID uuid.UUID `json:"id"` - GameID uuid.UUID `json:"game_id"` - Name string `json:"name"` - Image *string `json:"image,omitempty"` - Metadata *map[string]interface{} `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -// Ferris Wheel Game Models -type GetFerrisWheelGameRequest struct { - // No additional fields needed - customer ID comes from JWT token -} - -type GetFerrisWheelGameResponse struct { - Status string `json:"status"` - Message string `json:"message"` - Data *GetFerrisWheelGameResponseData `json:"data,omitempty"` -} - -type GetFerrisWheelGameResponseData struct { - Game CustomerGameResponse `json:"game"` -} diff --git a/internal/models/enakgame.go b/internal/models/enakgame.go index c1d9bd3..e342aef 100644 --- a/internal/models/enakgame.go +++ b/internal/models/enakgame.go @@ -150,6 +150,18 @@ type CustomerEnakGame struct { SessionTTLSeconds int `json:"session_ttl_seconds"` // The events making the game pay more right now, highest priority first. Events []CustomerGameEvent `json:"events"` + // What a PROBABILITY game, such as a spin wheel, can pay, in the order of its + // configuration. Left out for the other reward types. + Prizes []GamePrize `json:"prizes,omitempty"` +} + +// GamePrize is one entry of a PROBABILITY game, such as a segment of a spin wheel, as +// the customer sees it: never its odds. +type GamePrize struct { + // 1 for the first entry of the configuration. + Entry int `json:"entry"` + Label *string `json:"label"` + Amount int64 `json:"amount"` } // GameSessionStart is the response of starting a game (§7.1). @@ -199,6 +211,9 @@ type GameSessionCompletion struct { // The daily limits that made the reward smaller than earned: USER_DAILY, // GAME_DAILY or GLOBAL_DAILY. LimitedBy []string `json:"limited_by,omitempty"` + // The entry a PROBABILITY game drew, for a spin wheel to stop on. Its amount is + // the base reward, before events and limits. + Prize *GamePrize `json:"prize,omitempty"` } type GameSessionRewardParts struct { diff --git a/internal/models/game.go b/internal/models/game.go deleted file mode 100644 index cd7f637..0000000 --- a/internal/models/game.go +++ /dev/null @@ -1,41 +0,0 @@ -package models - -import ( - "time" - - "github.com/google/uuid" -) - -type CreateGameRequest struct { - Name string `json:"name" validate:"required"` - Type string `json:"type" validate:"required,oneof=SPIN RAFFLE MINIGAME"` - IsActive bool `json:"is_active"` - Metadata map[string]interface{} `json:"metadata"` -} - -type UpdateGameRequest struct { - Name *string `json:"name,omitempty" validate:"omitempty,required"` - Type *string `json:"type,omitempty" validate:"omitempty,oneof=SPIN RAFFLE MINIGAME"` - IsActive *bool `json:"is_active,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` -} - -type GameResponse struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - IsActive bool `json:"is_active"` - Metadata map[string]interface{} `json:"metadata"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type ListGamesQuery struct { - Page int `query:"page" validate:"min=1"` - Limit int `query:"limit" validate:"min=1,max=100"` - Search string `query:"search"` - Type string `query:"type" validate:"omitempty,oneof=SPIN RAFFLE MINIGAME"` - IsActive *bool `query:"is_active"` - SortBy string `query:"sort_by" validate:"omitempty,oneof=name type created_at updated_at"` - SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` -} diff --git a/internal/models/game_play.go b/internal/models/game_play.go deleted file mode 100644 index db62ce3..0000000 --- a/internal/models/game_play.go +++ /dev/null @@ -1,50 +0,0 @@ -package models - -import ( - "time" - - "github.com/google/uuid" -) - -type CreateGamePlayRequest struct { - GameID uuid.UUID `json:"game_id" validate:"required"` - CustomerID uuid.UUID `json:"customer_id" validate:"required"` - CoinsUsed int `json:"coins_used" validate:"min=0"` - RandomSeed *string `json:"random_seed,omitempty"` -} - -type GamePlayResponse struct { - ID uuid.UUID `json:"id"` - GameID uuid.UUID `json:"game_id"` - CustomerID uuid.UUID `json:"customer_id"` - PrizeID *uuid.UUID `json:"prize_id,omitempty"` - CoinsUsed int `json:"coins_used"` - RandomSeed *string `json:"random_seed,omitempty"` - CreatedAt time.Time `json:"created_at"` - Game *GameResponse `json:"game,omitempty"` - Customer *CustomerResponse `json:"customer,omitempty"` - Prize *GamePrizeResponse `json:"prize,omitempty"` -} - -type ListGamePlaysQuery struct { - Page int `query:"page" validate:"min=1"` - Limit int `query:"limit" validate:"min=1,max=100"` - Search string `query:"search"` - GameID *uuid.UUID `query:"game_id"` - CustomerID *uuid.UUID `query:"customer_id"` - PrizeID *uuid.UUID `query:"prize_id"` - SortBy string `query:"sort_by" validate:"omitempty,oneof=created_at coins_used"` - SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` -} - -// PlayGameRequest plays a game for a customer, at the game's metadata.coin_cost. -type PlayGameRequest struct { - GameID uuid.UUID `json:"game_id" validate:"required"` - CustomerID uuid.UUID `json:"customer_id" validate:"required"` -} - -type PlayGameResponse struct { - GamePlay GamePlayResponse `json:"game_play"` - PrizeWon *GamePrizeResponse `json:"prize_won,omitempty"` - CoinsRemaining int64 `json:"coins_remaining"` -} diff --git a/internal/models/game_prize.go b/internal/models/game_prize.go deleted file mode 100644 index 2616cf1..0000000 --- a/internal/models/game_prize.go +++ /dev/null @@ -1,56 +0,0 @@ -package models - -import ( - "time" - - "github.com/google/uuid" -) - -type CreateGamePrizeRequest struct { - GameID uuid.UUID `json:"game_id" validate:"required"` - Name string `json:"name" validate:"required"` - Weight int `json:"weight" validate:"min=1"` - Stock int `json:"stock" validate:"min=0"` - MaxStock *int `json:"max_stock,omitempty"` - Threshold *int64 `json:"threshold,omitempty"` - FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"` - Image *string `json:"image,omitempty" validate:"omitempty,max=500"` - Metadata map[string]interface{} `json:"metadata"` -} - -type UpdateGamePrizeRequest struct { - Name *string `json:"name,omitempty" validate:"omitempty,required"` - Weight *int `json:"weight,omitempty" validate:"omitempty,min=1"` - Stock *int `json:"stock,omitempty" validate:"omitempty,min=0"` - MaxStock *int `json:"max_stock,omitempty"` - Threshold *int64 `json:"threshold,omitempty"` - FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"` - Image *string `json:"image,omitempty" validate:"omitempty,max=500"` - Metadata map[string]interface{} `json:"metadata,omitempty"` -} - -type GamePrizeResponse struct { - ID uuid.UUID `json:"id"` - GameID uuid.UUID `json:"game_id"` - Name string `json:"name"` - Weight int `json:"weight"` - Stock int `json:"stock"` - MaxStock *int `json:"max_stock,omitempty"` - Threshold *int64 `json:"threshold,omitempty"` - FallbackPrizeID *uuid.UUID `json:"fallback_prize_id,omitempty"` - Image *string `json:"image,omitempty"` - Metadata map[string]interface{} `json:"metadata"` - Game *GameResponse `json:"game,omitempty"` - FallbackPrize *GamePrizeResponse `json:"fallback_prize,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type ListGamePrizesQuery struct { - Page int `query:"page" validate:"min=1"` - Limit int `query:"limit" validate:"min=1,max=100"` - Search string `query:"search"` - GameID *uuid.UUID `query:"game_id"` - SortBy string `query:"sort_by" validate:"omitempty,oneof=name weight stock created_at updated_at"` - SortOrder string `query:"sort_order" validate:"omitempty,oneof=asc desc"` -} diff --git a/internal/models/omset_tracker.go b/internal/models/omset_tracker.go index d364663..ea8ad05 100644 --- a/internal/models/omset_tracker.go +++ b/internal/models/omset_tracker.go @@ -27,15 +27,14 @@ type AddOmsetRequest struct { } type OmsetTrackerResponse struct { - ID uuid.UUID `json:"id"` - PeriodType string `json:"period_type"` - PeriodStart time.Time `json:"period_start"` - PeriodEnd time.Time `json:"period_end"` - Total int64 `json:"total"` - GameID *uuid.UUID `json:"game_id,omitempty"` - Game *GameResponse `json:"game,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID uuid.UUID `json:"id"` + PeriodType string `json:"period_type"` + PeriodStart time.Time `json:"period_start"` + PeriodEnd time.Time `json:"period_end"` + Total int64 `json:"total"` + GameID *uuid.UUID `json:"game_id,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } type ListOmsetTrackerQuery struct { diff --git a/internal/models/reward.go b/internal/models/reward.go deleted file mode 100644 index e2b29a5..0000000 --- a/internal/models/reward.go +++ /dev/null @@ -1,71 +0,0 @@ -package models - -import ( - "time" - - "github.com/google/uuid" -) - -type CreateRewardRequest struct { - Name string `json:"name" binding:"required,min=1,max=150"` - RewardType string `json:"reward_type" binding:"required,oneof=VOUCHER PHYSICAL DIGITAL BALANCE"` - CostPoints int64 `json:"cost_points" binding:"required,min=1"` - Stock *int `json:"stock,omitempty"` - MaxPerCustomer int `json:"max_per_customer" binding:"min=1"` - Tnc *TermsAndConditionsStruct `json:"tnc,omitempty"` - Metadata *map[string]interface{} `json:"metadata,omitempty"` - Images *[]string `json:"images,omitempty"` -} - -type UpdateRewardRequest struct { - ID uuid.UUID `json:"id" binding:"required"` - Name string `json:"name" binding:"required,min=1,max=150"` - RewardType string `json:"reward_type" binding:"required,oneof=VOUCHER PHYSICAL DIGITAL BALANCE"` - CostPoints int64 `json:"cost_points" binding:"required,min=1"` - Stock *int `json:"stock,omitempty"` - MaxPerCustomer int `json:"max_per_customer" binding:"min=1"` - Tnc *TermsAndConditionsStruct `json:"tnc,omitempty"` - Metadata *map[string]interface{} `json:"metadata,omitempty"` - Images *[]string `json:"images,omitempty"` -} - -type ListRewardsRequest struct { - Page int `form:"page" binding:"min=1"` - Limit int `form:"limit" binding:"min=1,max=100"` - Search string `form:"search"` - RewardType string `form:"reward_type"` - MinPoints *int64 `form:"min_points"` - MaxPoints *int64 `form:"max_points"` - HasStock *bool `form:"has_stock"` -} - -type RewardResponse struct { - ID uuid.UUID `json:"id"` - Name string `json:"name"` - RewardType string `json:"reward_type"` - CostPoints int64 `json:"cost_points"` - Stock *int `json:"stock,omitempty"` - MaxPerCustomer int `json:"max_per_customer"` - Tnc *TermsAndConditionsStruct `json:"tnc,omitempty"` - Metadata *map[string]interface{} `json:"metadata,omitempty"` - Images *[]string `json:"images,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} - -type ListRewardsResponse struct { - Rewards []RewardResponse `json:"rewards"` - Total int64 `json:"total"` - Page int `json:"page"` - Limit int `json:"limit"` -} - -type TermsAndConditionsStruct struct { - Sections []TncSectionStruct `json:"sections"` - ExpiryDays int `json:"expiry_days"` -} - -type TncSectionStruct struct { - Title string `json:"title"` - Rules []string `json:"rules"` -} diff --git a/internal/models/spin_game.go b/internal/models/spin_game.go deleted file mode 100644 index a384b7a..0000000 --- a/internal/models/spin_game.go +++ /dev/null @@ -1,37 +0,0 @@ -package models - -import ( - "time" - - "github.com/google/uuid" -) - -// SpinGameRequest represents the request to play a spin game -type SpinGameRequest struct { - SpinID string `json:"spin_id" validate:"required,uuid"` -} - -// SpinGameResponse represents the response from playing a spin game -type SpinGameResponse struct { - Status string `json:"status"` - Message string `json:"message"` - Data *SpinGameResponseData `json:"data,omitempty"` -} - -// SpinGameResponseData contains the game play result -type SpinGameResponseData struct { - GamePlay GamePlayResponse `json:"game_play"` - PrizeWon *CustomerGamePrizeResponse `json:"prize_won,omitempty"` - CoinsRemaining int64 `json:"coins_remaining"` -} - -// SpinGamePrizeResponse represents a prize won in the spin game -type SpinGamePrizeResponse struct { - ID uuid.UUID `json:"id"` - GameID uuid.UUID `json:"game_id"` - Name string `json:"name"` - Image *string `json:"image,omitempty"` - Metadata *map[string]interface{} `json:"metadata,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} diff --git a/internal/processor/customer_points_processor.go b/internal/processor/customer_points_processor.go index 6e6095e..7df1744 100644 --- a/internal/processor/customer_points_processor.go +++ b/internal/processor/customer_points_processor.go @@ -5,20 +5,17 @@ import ( "fmt" "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" "github.com/google/uuid" ) type CustomerPointsProcessor struct { walletQuery *WalletQueryProcessor - gameRepo *repository.GameRepository } -func NewCustomerPointsProcessor(walletQuery *WalletQueryProcessor, gameRepo *repository.GameRepository) *CustomerPointsProcessor { +func NewCustomerPointsProcessor(walletQuery *WalletQueryProcessor) *CustomerPointsProcessor { return &CustomerPointsProcessor{ walletQuery: walletQuery, - gameRepo: gameRepo, } } @@ -74,94 +71,6 @@ func parseWalletCustomerID(customerID string) (uuid.UUID, error) { return id, nil } -// GetCustomerGamesAPI gets active SPIN games for customers -func (p *CustomerPointsProcessor) GetCustomerGamesAPI(ctx context.Context) (*models.GetCustomerGamesResponse, error) { - // Get active SPIN games - games, err := p.gameRepo.GetActiveSpinGames(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get active SPIN games: %w", err) - } - - // Convert to response format - var gameResponses []models.CustomerGameResponse - for _, game := range games { - var prizeResponses []models.CustomerGamePrizeResponse - for _, prize := range game.Prizes { - prizeResponses = append(prizeResponses, models.CustomerGamePrizeResponse{ - ID: prize.ID, - GameID: prize.GameID, - Name: prize.Name, - Image: prize.Image, - Metadata: (*map[string]interface{})(&prize.Metadata), - CreatedAt: prize.CreatedAt, - UpdatedAt: prize.UpdatedAt, - }) - } - - gameResponses = append(gameResponses, models.CustomerGameResponse{ - ID: game.ID, - Name: game.Name, - Type: string(game.Type), - IsActive: game.IsActive, - Metadata: (*map[string]interface{})(&game.Metadata), - Prizes: prizeResponses, - CreatedAt: game.CreatedAt, - UpdatedAt: game.UpdatedAt, - }) - } - - return &models.GetCustomerGamesResponse{ - Status: "SUCCESS", - Message: "Customer games retrieved successfully.", - Data: &models.GetCustomerGamesResponseData{ - Games: gameResponses, - }, - }, nil -} - -// GetFerrisWheelGameAPI gets the Ferris Wheel game for customers -func (p *CustomerPointsProcessor) GetFerrisWheelGameAPI(ctx context.Context) (*models.GetFerrisWheelGameResponse, error) { - // Get Ferris Wheel game - game, err := p.gameRepo.GetFerrisWheelGame(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get Ferris Wheel game: %w", err) - } - - // Convert prizes to response format - var prizeResponses []models.CustomerGamePrizeResponse - for _, prize := range game.Prizes { - prizeResponses = append(prizeResponses, models.CustomerGamePrizeResponse{ - ID: prize.ID, - GameID: prize.GameID, - Name: prize.Name, - Image: prize.Image, - Metadata: (*map[string]interface{})(&prize.Metadata), - CreatedAt: prize.CreatedAt, - UpdatedAt: prize.UpdatedAt, - }) - } - - // Convert game to response format - gameResponse := models.CustomerGameResponse{ - ID: game.ID, - Name: game.Name, - Type: string(game.Type), - IsActive: game.IsActive, - Metadata: (*map[string]interface{})(&game.Metadata), - Prizes: prizeResponses, - CreatedAt: game.CreatedAt, - UpdatedAt: game.UpdatedAt, - } - - return &models.GetFerrisWheelGameResponse{ - Status: "SUCCESS", - Message: "Ferris Wheel game retrieved successfully.", - Data: &models.GetFerrisWheelGameResponseData{ - Game: gameResponse, - }, - }, nil -} - func (p *CustomerPointsProcessor) GetCustomerWalletExpiringAPI(ctx context.Context, customerID string) (*models.CustomerWalletExpiringList, error) { id, err := parseWalletCustomerID(customerID) if err != nil { diff --git a/internal/processor/game_play_processor.go b/internal/processor/game_play_processor.go deleted file mode 100644 index e6fef4f..0000000 --- a/internal/processor/game_play_processor.go +++ /dev/null @@ -1,301 +0,0 @@ -package processor - -import ( - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/mappers" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" - "context" - "errors" - "fmt" - "math" - "math/rand" - "time" - - "github.com/google/uuid" -) - -// ErrGamePlayRefused wraps every reason a customer cannot play right now: the game, -// its prizes or their EnakCoin. The message says which. -var ErrGamePlayRefused = errors.New("game cannot be played") - -const ( - gameCoinCostKey = "coin_cost" - defaultGameCoinCost = 1 -) - -type gameReader interface { - GetByID(ctx context.Context, id uuid.UUID) (*entities.Game, error) -} - -type gamePrizeStock interface { - GetAvailablePrizes(ctx context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) - DecreaseStock(ctx context.Context, id uuid.UUID, amount int) error -} - -type GamePlayProcessor struct { - gamePlayRepo repository.GamePlayRepository - gameRepo gameReader - gamePrizeRepo gamePrizeStock - wallet *WalletProcessor - spendable spendableReader - tx TxRunner -} - -func NewGamePlayProcessor( - gamePlayRepo repository.GamePlayRepository, - gameRepo gameReader, - gamePrizeRepo gamePrizeStock, - wallet *WalletProcessor, - spendable spendableReader, - tx TxRunner, -) *GamePlayProcessor { - return &GamePlayProcessor{ - gamePlayRepo: gamePlayRepo, - gameRepo: gameRepo, - gamePrizeRepo: gamePrizeRepo, - wallet: wallet, - spendable: spendable, - tx: tx, - } -} - -// CreateGamePlay creates a new game play record -func (p *GamePlayProcessor) CreateGamePlay(ctx context.Context, req *models.CreateGamePlayRequest) (*models.GamePlayResponse, error) { - // Convert request to entity - gamePlay := mappers.ToGamePlayEntity(req) - - // Create game play - err := p.gamePlayRepo.Create(ctx, gamePlay) - if err != nil { - return nil, fmt.Errorf("failed to create game play: %w", err) - } - - return mappers.ToGamePlayResponse(gamePlay), nil -} - -// GetGamePlay retrieves a game play by ID -func (p *GamePlayProcessor) GetGamePlay(ctx context.Context, id uuid.UUID) (*models.GamePlayResponse, error) { - gamePlay, err := p.gamePlayRepo.GetByID(ctx, id) - if err != nil { - return nil, fmt.Errorf("game play not found: %w", err) - } - - return mappers.ToGamePlayResponse(gamePlay), nil -} - -// ListGamePlays retrieves game plays with pagination and filtering -func (p *GamePlayProcessor) ListGamePlays(ctx context.Context, query *models.ListGamePlaysQuery) (*models.PaginatedResponse[models.GamePlayResponse], error) { - // Set default values - if query.Page <= 0 { - query.Page = 1 - } - if query.Limit <= 0 { - query.Limit = 10 - } - if query.Limit > 100 { - query.Limit = 100 - } - - offset := (query.Page - 1) * query.Limit - - // Get game plays from repository - gamePlays, total, err := p.gamePlayRepo.List( - ctx, - offset, - query.Limit, - query.Search, - query.GameID, - query.CustomerID, - query.PrizeID, - query.SortBy, - query.SortOrder, - ) - if err != nil { - return nil, fmt.Errorf("failed to list game plays: %w", err) - } - - // Convert to responses - responses := mappers.ToGamePlayResponsesFromPointers(gamePlays) - - // Calculate pagination info - totalPages := int((total + int64(query.Limit) - 1) / int64(query.Limit)) - - return &models.PaginatedResponse[models.GamePlayResponse]{ - Data: responses, - Pagination: models.Pagination{ - Page: query.Page, - Limit: query.Limit, - Total: total, - TotalPages: totalPages, - }, - }, nil -} - -// PlayGame plays a game for a customer, paid with EnakCoin (docs/prd-point-coin.md -// F8, K1). Every kind of game spends the same EnakCoin, at the game's -// metadata.coin_cost. Taking the EnakCoin (GAME_SPEND), recording the play and taking -// the prize from stock happen in one transaction, so a play that cannot be completed, -// for instance because the prize just ran out, takes nothing and records nothing. -func (p *GamePlayProcessor) PlayGame(ctx context.Context, req *models.PlayGameRequest) (*models.PlayGameResponse, error) { - refuse := func(format string, args ...any) error { - return fmt.Errorf("%w: %s", ErrGamePlayRefused, fmt.Sprintf(format, args...)) - } - game, err := p.gameRepo.GetByID(ctx, req.GameID) - if err != nil { - return nil, fmt.Errorf("game not found: %w", err) - } - if !game.IsActive { - return nil, refuse("game is not active") - } - cost, err := gameCoinCost(game.Metadata) - if err != nil { - return nil, refuse("%s", err.Error()) - } - - var gamePlay *entities.GamePlay - var selectedPrize *models.GamePrizeResponse - err = p.tx.WithTransaction(ctx, func(ctx context.Context) error { - availablePrizes, err := p.gamePrizeRepo.GetAvailablePrizes(ctx, req.GameID) - if err != nil { - return fmt.Errorf("failed to get available prizes: %w", err) - } - if len(availablePrizes) == 0 { - return refuse("no prizes available") - } - prizeResponses := make([]models.GamePrizeResponse, len(availablePrizes)) - for i, prize := range availablePrizes { - prizeResponses[i] = *mappers.ToGamePrizeResponse(&prize) - } - selectedPrize = p.selectPrizeByWeight(prizeResponses) - - // Random seed kept for audit. - randomSeed := fmt.Sprintf("%d", time.Now().UnixNano()) - gamePlay = &entities.GamePlay{ - ID: uuid.New(), - GameID: req.GameID, - CustomerID: req.CustomerID, - CoinsUsed: int(cost), - RandomSeed: &randomSeed, - } - if selectedPrize != nil { - gamePlay.PrizeID = &selectedPrize.ID - } - - // The EnakCoin go first: the debit locks the wallet, so plays by the same - // customer at the same time queue up instead of spending the same balance. - _, err = p.wallet.Debit(ctx, WalletDebitInput{WalletEntry: WalletEntry{ - CustomerID: req.CustomerID, - Currency: constants.WalletCurrencyCoin, - Type: constants.WalletTxTypeGameSpend, - Amount: cost, - ReferenceType: constants.WalletRefTypeGamePlay, - ReferenceID: gamePlay.ID, - Description: gameSpendDescription(game.Name, selectedPrize), - Metadata: entities.Metadata{"game_id": game.ID.String(), "coin_cost": cost}, - }}) - if errors.Is(err, repository.ErrWalletInsufficientBalance) { - return refuse("not enough EnakCoin: this game costs %d", cost) - } - if err != nil { - return err - } - if err := p.gamePlayRepo.Create(ctx, gamePlay); err != nil { - return err - } - if selectedPrize != nil { - err := p.gamePrizeRepo.DecreaseStock(ctx, selectedPrize.ID, 1) - if errors.Is(err, repository.ErrGamePrizeOutOfStock) { - return refuse("the prize just ran out, please play again") - } - if err != nil { - return err - } - } - return nil - }) - if err != nil { - return nil, err - } - - balances, err := p.spendable.SpendableBalances(ctx, req.CustomerID, time.Now()) - if err != nil { - return nil, err - } - return &models.PlayGameResponse{ - GamePlay: *mappers.ToGamePlayResponse(gamePlay), - PrizeWon: selectedPrize, - CoinsRemaining: balances[constants.WalletCurrencyCoin], - }, nil -} - -// gameCoinCost is what one play of a game costs, from games.metadata.coin_cost: a -// whole number of EnakCoin, 1 when it is not set (F8). -func gameCoinCost(metadata entities.Metadata) (int64, error) { - raw, ok := metadata[gameCoinCostKey] - if !ok || raw == nil { - return defaultGameCoinCost, nil - } - var cost int64 - switch v := raw.(type) { - case float64: - if v != math.Trunc(v) { - return 0, fmt.Errorf("the game's coin_cost %v is not a whole number", v) - } - cost = int64(v) - case int: - cost = int64(v) - case int64: - cost = v - default: - return 0, fmt.Errorf("the game's coin_cost %v is not a number", raw) - } - if cost < 1 { - return 0, fmt.Errorf("the game's coin_cost must be at least 1, got %d", cost) - } - return cost, nil -} - -// gameSpendDescription is what the customer's history shows for a play, frozen when it -// happens (§8.1): "Main Spin Wheel: dapat Voucher 10rb". -func gameSpendDescription(gameName string, prize *models.GamePrizeResponse) string { - description := "Main " + gameName - if prize != nil { - description += ": dapat " + prize.Name - } - return truncateRunes(description, walletDescriptionLimit) -} - -// selectPrizeByWeight selects a prize based on weight distribution -func (p *GamePlayProcessor) selectPrizeByWeight(prizes []models.GamePrizeResponse) *models.GamePrizeResponse { - if len(prizes) == 0 { - return nil - } - - // Calculate total weight - totalWeight := 0 - for _, prize := range prizes { - totalWeight += prize.Weight - } - - if totalWeight == 0 { - return nil - } - - // Generate random number - rand.Seed(time.Now().UnixNano()) - randomNumber := rand.Intn(totalWeight) - - // Select prize based on cumulative weight - currentWeight := 0 - for _, prize := range prizes { - currentWeight += prize.Weight - if randomNumber < currentWeight { - return &prize - } - } - - // Fallback to last prize - return &prizes[len(prizes)-1] -} diff --git a/internal/processor/game_play_processor_db_test.go b/internal/processor/game_play_processor_db_test.go deleted file mode 100644 index 095a916..0000000 --- a/internal/processor/game_play_processor_db_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package processor - -import ( - "context" - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -// Plays against Postgres, so the game repositories really join the transaction: a -// refused play leaves no game_plays row and no GAME_SPEND. -func TestPlayGame_AgainstPostgres(t *testing.T) { - db, _, player, _ := walletMoveDB(t) - gameID, prizeID := uuid.New(), uuid.New() - // An old game: archived for EnakGame (000103) but still active for the old spin. - require.NoError(t, db.Exec(`INSERT INTO games (id, name, type, metadata, status, entry_cost) VALUES (?, 'Raffle', 'RAFFLE', '{"coin_cost": 2}', 'ARCHIVED', 2)`, gameID).Error) - require.NoError(t, db.Exec(`INSERT INTO game_prizes (id, game_id, name, weight, stock) VALUES (?, ?, 'Tumbler', 1, 1)`, prizeID, gameID).Error) - t.Cleanup(func() { - db.Exec(`DELETE FROM wallet_lot_allocations WHERE transaction_id IN (SELECT id FROM wallet_transactions WHERE customer_id = ?)`, player) - db.Exec(`DELETE FROM game_plays WHERE game_id = ?`, gameID) - db.Exec(`DELETE FROM game_prizes WHERE game_id = ?`, gameID) - db.Exec(`DELETE FROM games WHERE id = ?`, gameID) - }) - - wallet := NewWalletProcessor(repository.NewWalletRepository(db)) - txm := repository.NewTxManager(db) - require.NoError(t, txm.WithTransaction(context.Background(), func(ctx context.Context) error { - in := earn(player, 3, nil) - in.Currency = constants.WalletCurrencyCoin - _, err := wallet.Credit(ctx, in) - return err - })) - p := NewGamePlayProcessor(repository.NewGamePlayRepository(db), repository.NewGameRepository(db), repository.NewGamePrizeRepository(db), - wallet, repository.NewWalletQueryRepository(db), txm) - play := func() (*models.PlayGameResponse, error) { - return p.PlayGame(context.Background(), &models.PlayGameRequest{GameID: gameID, CustomerID: player}) - } - - res, err := play() - require.NoError(t, err) - assert.Equal(t, int64(1), res.CoinsRemaining) - assert.Equal(t, 2, res.GamePlay.CoinsUsed) - - var spend struct { - ReferenceID uuid.UUID - Amount int64 - } - require.NoError(t, db.Raw(`SELECT reference_id, amount FROM wallet_transactions WHERE customer_id = ? AND type = 'GAME_SPEND'`, player).Scan(&spend).Error) - assert.Equal(t, res.GamePlay.ID, spend.ReferenceID) - assert.Equal(t, int64(-2), spend.Amount) - - // The only prize is gone, then the EnakCoin are too short: neither play leaves a row. - _, err = play() - assert.ErrorIs(t, err, ErrGamePlayRefused) - require.NoError(t, db.Exec(`UPDATE game_prizes SET stock = 5 WHERE id = ?`, prizeID).Error) - _, err = play() - assert.ErrorIs(t, err, ErrGamePlayRefused) - - var plays, spends int64 - require.NoError(t, db.Raw(`SELECT COUNT(*) FROM game_plays WHERE game_id = ? AND coins_used = 2`, gameID).Scan(&plays).Error) - require.NoError(t, db.Raw(`SELECT COUNT(*) FROM wallet_transactions WHERE customer_id = ? AND type = 'GAME_SPEND'`, player).Scan(&spends).Error) - assert.Equal(t, int64(1), plays) - assert.Equal(t, int64(1), spends) -} diff --git a/internal/processor/game_play_processor_test.go b/internal/processor/game_play_processor_test.go deleted file mode 100644 index 6a43670..0000000 --- a/internal/processor/game_play_processor_test.go +++ /dev/null @@ -1,245 +0,0 @@ -package processor - -import ( - "context" - "errors" - "testing" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -// snapshot saves everything the fake wallet holds and returns what puts it back, so -// a test transaction can roll back like a real one. -func (f *walletRepoFake) snapshot() func() { - wallets := make(map[uuid.UUID]*entities.CustomerWallet, len(f.wallets)) - for id, w := range f.wallets { - c := *w - wallets[id] = &c - } - transactions := make([]*entities.WalletTransaction, len(f.transactions)) - for i, t := range f.transactions { - c := *t - transactions[i] = &c - } - lots := make([]*entities.WalletLot, len(f.lots)) - for i, l := range f.lots { - c := *l - lots[i] = &c - } - allocations := append([]entities.WalletLotAllocation(nil), f.allocations...) - return func() { - f.wallets, f.transactions, f.lots, f.allocations = wallets, transactions, lots, allocations - } -} - -// rollbackTx runs fn in place and, when it fails, puts back what each save saved. -type rollbackTx struct{ saves []func() func() } - -func (r rollbackTx) WithTransaction(ctx context.Context, fn func(ctx context.Context) error) error { - restores := make([]func(), 0, len(r.saves)) - for _, save := range r.saves { - restores = append(restores, save()) - } - err := fn(ctx) - if err != nil { - for _, restore := range restores { - restore() - } - } - return err -} - -type gameFake struct{ games map[uuid.UUID]*entities.Game } - -func (f gameFake) GetByID(_ context.Context, id uuid.UUID) (*entities.Game, error) { - g, ok := f.games[id] - if !ok { - return nil, errors.New("record not found") - } - return g, nil -} - -type prizeStockFake struct { - prizes []entities.GamePrize - stockErr error -} - -func (f *prizeStockFake) GetAvailablePrizes(_ context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) { - var out []entities.GamePrize - for _, p := range f.prizes { - if p.GameID == gameID && p.Stock > 0 { - out = append(out, p) - } - } - return out, nil -} - -func (f *prizeStockFake) DecreaseStock(_ context.Context, id uuid.UUID, amount int) error { - if f.stockErr != nil { - return f.stockErr - } - for i := range f.prizes { - if f.prizes[i].ID == id { - if f.prizes[i].Stock < amount { - return repository.ErrGamePrizeOutOfStock - } - f.prizes[i].Stock -= amount - return nil - } - } - return repository.ErrGamePrizeOutOfStock -} - -func (f *prizeStockFake) snapshot() func() { - prizes := append([]entities.GamePrize(nil), f.prizes...) - return func() { f.prizes = prizes } -} - -// gamePlayRepoFake records plays; the other methods of the interface are not used. -type gamePlayRepoFake struct { - repository.GamePlayRepository - plays []entities.GamePlay -} - -func (f *gamePlayRepoFake) Create(_ context.Context, gp *entities.GamePlay) error { - f.plays = append(f.plays, *gp) - return nil -} - -func (f *gamePlayRepoFake) snapshot() func() { - plays := append([]entities.GamePlay(nil), f.plays...) - return func() { f.plays = plays } -} - -type gameTestEnv struct { - *walletMoveEnv - game *entities.Game - prize entities.GamePrize - stock *prizeStockFake - plays *gamePlayRepoFake - player uuid.UUID - p *GamePlayProcessor -} - -func newGameTestEnv(t *testing.T, metadata entities.Metadata) *gameTestEnv { - e := &gameTestEnv{walletMoveEnv: newWalletMoveEnv(t)} - e.game = &entities.Game{ID: uuid.New(), Name: "Spin Wheel", Type: entities.GameTypeSpin, IsActive: true, Metadata: metadata} - e.prize = entities.GamePrize{ID: uuid.New(), GameID: e.game.ID, Name: "Voucher 10rb", Weight: 1, Stock: 5} - e.stock = &prizeStockFake{prizes: []entities.GamePrize{e.prize}} - e.plays = &gamePlayRepoFake{} - e.player = e.member("Budi", "081234561234") - tx := rollbackTx{saves: []func() func(){e.repo.snapshot, e.stock.snapshot, e.plays.snapshot}} - e.p = NewGamePlayProcessor(e.plays, gameFake{games: map[uuid.UUID]*entities.Game{e.game.ID: e.game}}, e.stock, e.walletTestEnv.p, e, tx) - return e -} - -func (e *gameTestEnv) play() (*models.PlayGameResponse, error) { - return e.p.PlayGame(e.ctx, &models.PlayGameRequest{GameID: e.game.ID, CustomerID: e.player}) -} - -func TestPlayGame_SpendsTheGamesCoinCost(t *testing.T) { - // coin_cost comes back from JSONB as a float64. - e := newGameTestEnv(t, entities.Metadata{"coin_cost": float64(3)}) - e.earnCoins(t, e.player, 10, nil) - - res, err := e.play() - require.NoError(t, err) - - assert.Equal(t, int64(7), res.CoinsRemaining) - require.NotNil(t, res.PrizeWon) - assert.Equal(t, e.prize.ID, res.PrizeWon.ID) - require.Len(t, e.plays.plays, 1) - play := e.plays.plays[0] - assert.Equal(t, 3, play.CoinsUsed) - assert.Equal(t, e.prize.ID, *play.PrizeID) - assert.Equal(t, 4, e.stock.prizes[0].Stock) - - spend := e.repo.transactions[len(e.repo.transactions)-1] - assert.Equal(t, constants.WalletTxTypeGameSpend, spend.Type) - assert.Equal(t, constants.WalletCurrencyCoin, spend.Currency) - assert.Equal(t, int64(-3), spend.Amount) - assert.Equal(t, constants.WalletRefTypeGamePlay, spend.ReferenceType) - assert.Equal(t, play.ID, spend.ReferenceID, "GAME_SPEND points at the game play") - assert.Equal(t, "Main Spin Wheel: dapat Voucher 10rb", spend.Description) -} - -func TestPlayGame_CostsOneCoinByDefault(t *testing.T) { - e := newGameTestEnv(t, entities.Metadata{}) - e.earnCoins(t, e.player, 2, nil) - - res, err := e.play() - require.NoError(t, err) - assert.Equal(t, int64(1), res.CoinsRemaining) - assert.Equal(t, 1, e.plays.plays[0].CoinsUsed) -} - -func TestPlayGame_NotEnoughCoinsRecordsNothing(t *testing.T) { - e := newGameTestEnv(t, entities.Metadata{"coin_cost": float64(3)}) - e.earnCoins(t, e.player, 2, nil) - - _, err := e.play() - assert.ErrorIs(t, err, ErrGamePlayRefused) - assert.Empty(t, e.plays.plays) - assert.Equal(t, 5, e.stock.prizes[0].Stock) - assert.Equal(t, int64(2), e.coinBalance(t, e.player)) -} - -func TestPlayGame_PrizeStockFailureCancelsThePlay(t *testing.T) { - e := newGameTestEnv(t, entities.Metadata{}) - e.earnCoins(t, e.player, 5, nil) - rows := len(e.repo.transactions) - - // Another play took the last one between reading the prizes and taking it. - e.stock.stockErr = repository.ErrGamePrizeOutOfStock - _, err := e.play() - assert.ErrorIs(t, err, ErrGamePlayRefused) - - // Anything else going wrong with the stock cancels it too. - e.stock.stockErr = errors.New("connection reset") - _, err = e.play() - assert.Error(t, err) - - assert.Empty(t, e.plays.plays) - assert.Len(t, e.repo.transactions, rows, "no GAME_SPEND is left behind") - assert.Equal(t, int64(5), e.coinBalance(t, e.player)) -} - -func TestPlayGame_RefusesWhatCannotBePlayed(t *testing.T) { - for name, metadata := range map[string]entities.Metadata{ - "zero": {"coin_cost": float64(0)}, - "fraction": {"coin_cost": 1.5}, - "not number": {"coin_cost": "2"}, - } { - t.Run(name, func(t *testing.T) { - e := newGameTestEnv(t, metadata) - e.earnCoins(t, e.player, 5, nil) - _, err := e.play() - assert.ErrorIs(t, err, ErrGamePlayRefused) - assert.Equal(t, int64(5), e.coinBalance(t, e.player)) - }) - } - - t.Run("inactive", func(t *testing.T) { - e := newGameTestEnv(t, entities.Metadata{}) - e.earnCoins(t, e.player, 5, nil) - e.game.IsActive = false - _, err := e.play() - assert.ErrorIs(t, err, ErrGamePlayRefused) - }) - - t.Run("no prizes left", func(t *testing.T) { - e := newGameTestEnv(t, entities.Metadata{}) - e.earnCoins(t, e.player, 5, nil) - e.stock.prizes[0].Stock = 0 - _, err := e.play() - assert.ErrorIs(t, err, ErrGamePlayRefused) - assert.Equal(t, int64(5), e.coinBalance(t, e.player)) - }) -} diff --git a/internal/processor/game_prize_processor.go b/internal/processor/game_prize_processor.go deleted file mode 100644 index 6527e3a..0000000 --- a/internal/processor/game_prize_processor.go +++ /dev/null @@ -1,148 +0,0 @@ -package processor - -import ( - "apskel-pos-be/internal/mappers" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" - "context" - "fmt" - - "github.com/google/uuid" -) - -type GamePrizeProcessor struct { - gamePrizeRepo *repository.GamePrizeRepository -} - -func NewGamePrizeProcessor(gamePrizeRepo *repository.GamePrizeRepository) *GamePrizeProcessor { - return &GamePrizeProcessor{ - gamePrizeRepo: gamePrizeRepo, - } -} - -// CreateGamePrize creates a new game prize -func (p *GamePrizeProcessor) CreateGamePrize(ctx context.Context, req *models.CreateGamePrizeRequest) (*models.GamePrizeResponse, error) { - // Convert request to entity - gamePrize := mappers.ToGamePrizeEntity(req) - - // Create game prize - err := p.gamePrizeRepo.Create(ctx, gamePrize) - if err != nil { - return nil, fmt.Errorf("failed to create game prize: %w", err) - } - - return mappers.ToGamePrizeResponse(gamePrize), nil -} - -// GetGamePrize retrieves a game prize by ID -func (p *GamePrizeProcessor) GetGamePrize(ctx context.Context, id uuid.UUID) (*models.GamePrizeResponse, error) { - gamePrize, err := p.gamePrizeRepo.GetByID(ctx, id) - if err != nil { - return nil, fmt.Errorf("game prize not found: %w", err) - } - - return mappers.ToGamePrizeResponse(gamePrize), nil -} - -// GetGamePrizesByGameID retrieves all prizes for a specific game -func (p *GamePrizeProcessor) GetGamePrizesByGameID(ctx context.Context, gameID uuid.UUID) ([]models.GamePrizeResponse, error) { - gamePrizes, err := p.gamePrizeRepo.GetByGameID(ctx, gameID) - if err != nil { - return nil, fmt.Errorf("failed to get game prizes: %w", err) - } - - return mappers.ToGamePrizeResponses(gamePrizes), nil -} - -// ListGamePrizes retrieves game prizes with pagination and filtering -func (p *GamePrizeProcessor) ListGamePrizes(ctx context.Context, query *models.ListGamePrizesQuery) (*models.PaginatedResponse[models.GamePrizeResponse], error) { - // Set default values - if query.Page <= 0 { - query.Page = 1 - } - if query.Limit <= 0 { - query.Limit = 10 - } - if query.Limit > 100 { - query.Limit = 100 - } - - offset := (query.Page - 1) * query.Limit - - // Get game prizes from repository - gamePrizes, total, err := p.gamePrizeRepo.List( - ctx, - offset, - query.Limit, - query.Search, - query.GameID, - query.SortBy, - query.SortOrder, - ) - if err != nil { - return nil, fmt.Errorf("failed to list game prizes: %w", err) - } - - // Convert to responses - responses := mappers.ToGamePrizeResponses(gamePrizes) - - // Calculate pagination info - totalPages := int((total + int64(query.Limit) - 1) / int64(query.Limit)) - - return &models.PaginatedResponse[models.GamePrizeResponse]{ - Data: responses, - Pagination: models.Pagination{ - Page: query.Page, - Limit: query.Limit, - Total: total, - TotalPages: totalPages, - }, - }, nil -} - -// UpdateGamePrize updates an existing game prize -func (p *GamePrizeProcessor) UpdateGamePrize(ctx context.Context, id uuid.UUID, req *models.UpdateGamePrizeRequest) (*models.GamePrizeResponse, error) { - // Get existing game prize - gamePrize, err := p.gamePrizeRepo.GetByID(ctx, id) - if err != nil { - return nil, fmt.Errorf("game prize not found: %w", err) - } - - // Update game prize fields - mappers.UpdateGamePrizeEntity(gamePrize, req) - - // Save updated game prize - err = p.gamePrizeRepo.Update(ctx, gamePrize) - if err != nil { - return nil, fmt.Errorf("failed to update game prize: %w", err) - } - - return mappers.ToGamePrizeResponse(gamePrize), nil -} - -// DeleteGamePrize deletes a game prize -func (p *GamePrizeProcessor) DeleteGamePrize(ctx context.Context, id uuid.UUID) error { - // Get existing game prize - _, err := p.gamePrizeRepo.GetByID(ctx, id) - if err != nil { - return fmt.Errorf("game prize not found: %w", err) - } - - // Delete game prize - err = p.gamePrizeRepo.Delete(ctx, id) - if err != nil { - return fmt.Errorf("failed to delete game prize: %w", err) - } - - return nil -} - -// GetAvailablePrizes gets all available prizes for a game (with stock > 0) -func (p *GamePrizeProcessor) GetAvailablePrizes(ctx context.Context, gameID uuid.UUID) ([]models.GamePrizeResponse, error) { - gamePrizes, err := p.gamePrizeRepo.GetAvailablePrizes(ctx, gameID) - if err != nil { - return nil, fmt.Errorf("failed to get available prizes: %w", err) - } - - return mappers.ToGamePrizeResponses(gamePrizes), nil -} diff --git a/internal/processor/game_processor.go b/internal/processor/game_processor.go deleted file mode 100644 index 6c9dea0..0000000 --- a/internal/processor/game_processor.go +++ /dev/null @@ -1,139 +0,0 @@ -package processor - -import ( - "apskel-pos-be/internal/mappers" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" - "context" - "fmt" - - "github.com/google/uuid" -) - -type GameProcessor struct { - gameRepo *repository.GameRepository -} - -func NewGameProcessor(gameRepo *repository.GameRepository) *GameProcessor { - return &GameProcessor{ - gameRepo: gameRepo, - } -} - -// CreateGame creates a new game -func (p *GameProcessor) CreateGame(ctx context.Context, req *models.CreateGameRequest) (*models.GameResponse, error) { - // Convert request to entity - game := mappers.ToGameEntity(req) - - // Create game - err := p.gameRepo.Create(ctx, game) - if err != nil { - return nil, fmt.Errorf("failed to create game: %w", err) - } - - return mappers.ToGameResponse(game), nil -} - -// GetGame retrieves a game by ID -func (p *GameProcessor) GetGame(ctx context.Context, id uuid.UUID) (*models.GameResponse, error) { - game, err := p.gameRepo.GetByID(ctx, id) - if err != nil { - return nil, fmt.Errorf("game not found: %w", err) - } - - return mappers.ToGameResponse(game), nil -} - -// ListGames retrieves games with pagination and filtering -func (p *GameProcessor) ListGames(ctx context.Context, query *models.ListGamesQuery) (*models.PaginatedResponse[models.GameResponse], error) { - // Set default values - if query.Page <= 0 { - query.Page = 1 - } - if query.Limit <= 0 { - query.Limit = 10 - } - if query.Limit > 100 { - query.Limit = 100 - } - - offset := (query.Page - 1) * query.Limit - - // Get games from repository - games, total, err := p.gameRepo.List( - ctx, - offset, - query.Limit, - query.Search, - query.Type, - query.IsActive, - query.SortBy, - query.SortOrder, - ) - if err != nil { - return nil, fmt.Errorf("failed to list games: %w", err) - } - - // Convert to responses - responses := mappers.ToGameResponses(games) - - // Calculate pagination info - totalPages := int((total + int64(query.Limit) - 1) / int64(query.Limit)) - - return &models.PaginatedResponse[models.GameResponse]{ - Data: responses, - Pagination: models.Pagination{ - Page: query.Page, - Limit: query.Limit, - Total: total, - TotalPages: totalPages, - }, - }, nil -} - -// UpdateGame updates an existing game -func (p *GameProcessor) UpdateGame(ctx context.Context, id uuid.UUID, req *models.UpdateGameRequest) (*models.GameResponse, error) { - // Get existing game - game, err := p.gameRepo.GetByID(ctx, id) - if err != nil { - return nil, fmt.Errorf("game not found: %w", err) - } - - // Update game fields - mappers.UpdateGameEntity(game, req) - - // Save updated game - err = p.gameRepo.Update(ctx, game) - if err != nil { - return nil, fmt.Errorf("failed to update game: %w", err) - } - - return mappers.ToGameResponse(game), nil -} - -// DeleteGame deletes a game -func (p *GameProcessor) DeleteGame(ctx context.Context, id uuid.UUID) error { - // Get existing game - _, err := p.gameRepo.GetByID(ctx, id) - if err != nil { - return fmt.Errorf("game not found: %w", err) - } - - // Delete game - err = p.gameRepo.Delete(ctx, id) - if err != nil { - return fmt.Errorf("failed to delete game: %w", err) - } - - return nil -} - -// GetActiveGames gets all active games -func (p *GameProcessor) GetActiveGames(ctx context.Context) ([]models.GameResponse, error) { - games, err := p.gameRepo.GetActiveGames(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get active games: %w", err) - } - - return mappers.ToGameResponses(games), nil -} diff --git a/internal/processor/game_session_complete.go b/internal/processor/game_session_complete.go index 272c038..5b83284 100644 --- a/internal/processor/game_session_complete.go +++ b/internal/processor/game_session_complete.go @@ -123,6 +123,7 @@ func (p *GameSessionProcessor) Complete(ctx context.Context, customerID, session if err := json.Unmarshal(session.RewardBreakdown, &stored); err == nil { out.LimitedBy = stored.LimitedBy out.Reward = rewardParts(stored) + out.Prize = drawnPrize(stored.RewardType, stored.Base, stored.Detail) } } return out, nil diff --git a/internal/processor/game_session_processor.go b/internal/processor/game_session_processor.go index a22b21c..387d6df 100644 --- a/internal/processor/game_session_processor.go +++ b/internal/processor/game_session_processor.go @@ -2,6 +2,7 @@ package processor import ( "context" + "encoding/json" "errors" "fmt" "strings" @@ -384,11 +385,26 @@ func (p *GameSessionProcessor) ListGames(ctx context.Context, customerID uuid.UU if err != nil { return nil, err } + configs, err := p.games.ListActiveRewardConfigs(ctx, customer.OrganizationID) + if err != nil { + return nil, err + } + prizes := make(map[uuid.UUID][]models.GamePrize, len(configs)) + for _, c := range configs { + if c.RewardType != constants.GameRewardTypeProbability { + continue + } + // Rules passed Validate when the configuration was made. + if list, err := probabilityPrizes(json.RawMessage(c.Rules)); err == nil { + prizes[c.GameID] = list + } + } out := make([]models.CustomerEnakGame, 0, len(games)) for _, g := range games { item := models.CustomerEnakGame{ ID: g.ID, Name: g.Name, Description: g.Description, ThumbnailURL: g.ThumbnailURL, GameURL: g.GameURL, Version: g.Version, EntryCost: g.EntryCost, SessionTTLSeconds: g.SessionTTLSeconds, Events: []models.CustomerGameEvent{}, + Prizes: prizes[g.ID], } for _, e := range events[g.ID] { item.Events = append(item.Events, models.CustomerGameEvent{ diff --git a/internal/processor/reward_calculator.go b/internal/processor/reward_calculator.go index 68ddb90..3d82726 100644 --- a/internal/processor/reward_calculator.go +++ b/internal/processor/reward_calculator.go @@ -12,6 +12,7 @@ import ( "strings" "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/models" ) var ( @@ -332,12 +333,18 @@ func (o outcomeBasedReward) Scale(rules json.RawMessage, multiplier int64) (json // PROBABILITY: {"table": [{"weight": 1, "amount": 1000}, {"weight": 999, "amount": 0}]}. // Weights are whole numbers, so no check depends on floating point (§8). The draw is -// kept in the detail for audit. +// kept in the detail for audit. An entry may carry a label, such as a spin wheel's +// segment ("Zonk", "Jackpot"); the customer sees the entries and the one drawn, never +// the weights. type probabilityReward struct{} +// probabilityLabelLimit bounds a label's length. +const probabilityLabelLimit = 100 + type probabilityEntry struct { - Weight *int64 `json:"weight"` - Amount *int64 `json:"amount"` + Weight *int64 `json:"weight"` + Amount *int64 `json:"amount"` + Label *string `json:"label,omitempty"` } type probabilityRules struct { @@ -363,6 +370,9 @@ func (probabilityReward) parse(rules json.RawMessage) (probabilityRules, int64, if *e.Amount < 0 { return r, 0, invalidRules("entry %d amount must be 0 or more", i+1) } + if e.Label != nil && (strings.TrimSpace(*e.Label) == "" || len(*e.Label) > probabilityLabelLimit) { + return r, 0, invalidRules("entry %d label must not be empty, at most %d characters", i+1, probabilityLabelLimit) + } total += *e.Weight if total > rewardWeightLimit { return r, 0, invalidRules("the weights add up to more than %d", rewardWeightLimit) @@ -389,9 +399,13 @@ func (p probabilityReward) Calculate(rules json.RawMessage, _ SessionResult, rng for i, e := range r.Table { cumulative += *e.Weight if roll < cumulative { - return *e.Amount, map[string]any{ + detail := map[string]any{ "reward_type": constants.GameRewardTypeProbability, "roll": roll, "total_weight": total, "entry": i + 1, - }, nil + } + if e.Label != nil { + detail["label"] = *e.Label + } + return *e.Amount, detail, nil } } return 0, nil, fmt.Errorf("draw %d is outside the total weight %d", roll, total) @@ -410,3 +424,40 @@ func (p probabilityReward) Scale(rules json.RawMessage, multiplier int64) (json. } return marshalRules(r) } + +// probabilityPrizes lists the entries of PROBABILITY rules as the customer sees them: +// in order, without their weights. +func probabilityPrizes(rules json.RawMessage) ([]models.GamePrize, error) { + r, _, err := probabilityReward{}.parse(rules) + if err != nil { + return nil, err + } + prizes := make([]models.GamePrize, 0, len(r.Table)) + for i, e := range r.Table { + prizes = append(prizes, models.GamePrize{Entry: i + 1, Label: e.Label, Amount: *e.Amount}) + } + return prizes, nil +} + +// drawnPrize is the entry a PROBABILITY reward drew, read back from its detail; nil +// for another type or when nothing was drawn. +func drawnPrize(rewardType string, base int64, detail map[string]any) *models.GamePrize { + if rewardType != constants.GameRewardTypeProbability { + return nil + } + // A number read back from JSON is a float64; one just drawn is an int. + var entry int + switch v := detail["entry"].(type) { + case int: + entry = v + case float64: + entry = int(v) + default: + return nil + } + prize := &models.GamePrize{Entry: entry, Amount: base} + if label, ok := detail["label"].(string); ok { + prize.Label = &label + } + return prize +} diff --git a/internal/processor/reward_calculator_test.go b/internal/processor/reward_calculator_test.go index 4646030..ac6d2e3 100644 --- a/internal/processor/reward_calculator_test.go +++ b/internal/processor/reward_calculator_test.go @@ -3,12 +3,14 @@ package processor import ( "encoding/json" "math" + "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "apskel-pos-be/internal/constants" + "apskel-pos-be/internal/models" ) // fixedRNG returns the given draws in turn. @@ -179,3 +181,49 @@ func TestRewardCalculator_Scale(t *testing.T) { require.NoError(t, err) assert.EqualValues(t, math.MaxInt64/15_000*15_000/10_000, v) } + +// EG-1001: a spin wheel is a PROBABILITY game whose entries carry labels. +func TestRewardCalculator_SpinPrizes(t *testing.T) { + rules := json.RawMessage(`{"table": [ + {"weight": 50, "amount": 0, "label": "Zonk"}, + {"weight": 45, "amount": 5, "label": "5 Coin"}, + {"weight": 5, "amount": 50}]}`) + c, err := RewardCalculatorFor(constants.GameRewardTypeProbability) + require.NoError(t, err) + require.NoError(t, c.Validate(rules)) + + prizes, err := probabilityPrizes(rules) + require.NoError(t, err) + assert.Equal(t, []models.GamePrize{ + {Entry: 1, Label: ptr("Zonk"), Amount: 0}, {Entry: 2, Label: ptr("5 Coin"), Amount: 5}, {Entry: 3, Amount: 50}, + }, prizes, "no weights") + + base, detail, err := c.Calculate(rules, SessionResult{}, &fixedRNG{draws: []int64{60}}) + require.NoError(t, err) + assert.EqualValues(t, 5, base) + assert.Equal(t, &models.GamePrize{Entry: 2, Label: ptr("5 Coin"), Amount: 5}, drawnPrize(constants.GameRewardTypeProbability, base, detail)) + + // As read back from reward_breakdown. + stored, err := json.Marshal(detail) + require.NoError(t, err) + var back map[string]any + require.NoError(t, json.Unmarshal(stored, &back)) + assert.Equal(t, &models.GamePrize{Entry: 2, Label: ptr("5 Coin"), Amount: 5}, drawnPrize(constants.GameRewardTypeProbability, base, back)) + + _, detail, err = c.Calculate(rules, SessionResult{}, &fixedRNG{draws: []int64{99}}) + require.NoError(t, err) + assert.Equal(t, &models.GamePrize{Entry: 3, Amount: 50}, drawnPrize(constants.GameRewardTypeProbability, 50, detail), "no label") + assert.Nil(t, drawnPrize(constants.GameRewardTypeFixed, 5, map[string]any{"reward_type": "FIXED"})) + assert.Nil(t, drawnPrize(constants.GameRewardTypeProbability, 0, nil), "rejected: nothing drawn") + + scaled, err := c.Scale(rules, 9_000) + require.NoError(t, err) + assert.Contains(t, string(scaled), `"label":"Zonk"`, "scaling keeps the labels") + + for name, bad := range map[string]string{ + "empty label": `{"table": [{"weight": 1, "amount": 1, "label": " "}]}`, + "label too long": `{"table": [{"weight": 1, "amount": 1, "label": "` + strings.Repeat("x", 101) + `"}]}`, + } { + assert.ErrorIs(t, c.Validate(json.RawMessage(bad)), ErrInvalidRewardRules, name) + } +} diff --git a/internal/processor/reward_processor.go b/internal/processor/reward_processor.go deleted file mode 100644 index 2f880f1..0000000 --- a/internal/processor/reward_processor.go +++ /dev/null @@ -1,149 +0,0 @@ -package processor - -import ( - "context" - "fmt" - - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/entities" - "apskel-pos-be/internal/mappers" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/repository" -) - -type RewardProcessor interface { - CreateReward(ctx context.Context, req *contract.CreateRewardRequest) (*models.RewardResponse, error) - GetReward(ctx context.Context, id string) (*models.RewardResponse, error) - ListRewards(ctx context.Context, req *contract.ListRewardsRequest) (*models.ListRewardsResponse, error) - UpdateReward(ctx context.Context, req *contract.UpdateRewardRequest) (*models.RewardResponse, error) - DeleteReward(ctx context.Context, id string) error - UpdateRewardStock(ctx context.Context, id string, newStock int) error - GetRewardsByType(ctx context.Context, rewardType string) ([]models.RewardResponse, error) -} - -type rewardProcessor struct { - rewardRepo repository.RewardRepository -} - -func NewRewardProcessor(rewardRepo repository.RewardRepository) RewardProcessor { - return &rewardProcessor{ - rewardRepo: rewardRepo, - } -} - -func (p *rewardProcessor) CreateReward(ctx context.Context, req *contract.CreateRewardRequest) (*models.RewardResponse, error) { - // Convert request to entity - entity := mappers.ToRewardEntity(req) - if entity == nil { - return nil, fmt.Errorf("invalid request data") - } - - // Create in repository - if err := p.rewardRepo.Create(ctx, entity); err != nil { - return nil, fmt.Errorf("failed to create reward: %w", err) - } - - // Convert entity to response - response := mappers.ToRewardResponse(entity) - return response, nil -} - -func (p *rewardProcessor) GetReward(ctx context.Context, id string) (*models.RewardResponse, error) { - // Get from repository - entity, err := p.rewardRepo.GetByID(ctx, id) - if err != nil { - return nil, fmt.Errorf("failed to get reward: %w", err) - } - - if entity == nil { - return nil, fmt.Errorf("reward not found") - } - - // Convert entity to response - response := mappers.ToRewardResponse(entity) - return response, nil -} - -func (p *rewardProcessor) ListRewards(ctx context.Context, req *contract.ListRewardsRequest) (*models.ListRewardsResponse, error) { - // Convert request to entity request - entityReq := &entities.ListRewardsRequest{ - Page: req.Page, - Limit: req.Limit, - Search: req.Search, - RewardType: req.RewardType, - MinPoints: req.MinPoints, - MaxPoints: req.MaxPoints, - HasStock: req.HasStock, - } - - // Get from repository - entities, total, err := p.rewardRepo.List(ctx, entityReq) - if err != nil { - return nil, fmt.Errorf("failed to list rewards: %w", err) - } - - // Convert entities to response - rewards := mappers.ToRewardResponseSlice(entities) - - response := &models.ListRewardsResponse{ - Rewards: rewards, - Total: total, - Page: req.Page, - Limit: req.Limit, - } - - return response, nil -} - -func (p *rewardProcessor) UpdateReward(ctx context.Context, req *contract.UpdateRewardRequest) (*models.RewardResponse, error) { - // Convert request to entity - entity := mappers.ToRewardEntityFromUpdate(req) - if entity == nil { - return nil, fmt.Errorf("invalid request data") - } - - // Update in repository - if err := p.rewardRepo.Update(ctx, entity); err != nil { - return nil, fmt.Errorf("failed to update reward: %w", err) - } - - // Get updated entity - updatedEntity, err := p.rewardRepo.GetByID(ctx, req.ID.String()) - if err != nil { - return nil, fmt.Errorf("failed to get updated reward: %w", err) - } - - // Convert entity to response - response := mappers.ToRewardResponse(updatedEntity) - return response, nil -} - -func (p *rewardProcessor) DeleteReward(ctx context.Context, id string) error { - // Delete from repository - if err := p.rewardRepo.Delete(ctx, id); err != nil { - return fmt.Errorf("failed to delete reward: %w", err) - } - - return nil -} - -func (p *rewardProcessor) UpdateRewardStock(ctx context.Context, id string, newStock int) error { - // Update stock in repository - if err := p.rewardRepo.UpdateStock(ctx, id, newStock); err != nil { - return fmt.Errorf("failed to update reward stock: %w", err) - } - - return nil -} - -func (p *rewardProcessor) GetRewardsByType(ctx context.Context, rewardType string) ([]models.RewardResponse, error) { - // Get from repository - entities, err := p.rewardRepo.GetByRewardType(ctx, entities.RewardType(rewardType)) - if err != nil { - return nil, fmt.Errorf("failed to get rewards by type: %w", err) - } - - // Convert entities to response - rewards := mappers.ToRewardResponseSlice(entities) - return rewards, nil -} diff --git a/internal/repository/game_play_repository.go b/internal/repository/game_play_repository.go deleted file mode 100644 index 48c8d01..0000000 --- a/internal/repository/game_play_repository.go +++ /dev/null @@ -1,170 +0,0 @@ -package repository - -import ( - "context" - "fmt" - - "apskel-pos-be/internal/entities" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -type GamePlayRepository interface { - Create(ctx context.Context, gamePlay *entities.GamePlay) error - GetByID(ctx context.Context, id uuid.UUID) (*entities.GamePlay, error) - List(ctx context.Context, offset, limit int, search string, gameID, customerID, prizeID *uuid.UUID, sortBy, sortOrder string) ([]*entities.GamePlay, int64, error) - GetByCustomerID(ctx context.Context, customerID uuid.UUID, limit int) ([]*entities.GamePlay, error) - GetByGameID(ctx context.Context, gameID uuid.UUID, limit int) ([]*entities.GamePlay, error) - CountByCustomerID(ctx context.Context, customerID uuid.UUID) (int64, error) - CountByGameID(ctx context.Context, gameID uuid.UUID) (int64, error) -} - -type gamePlayRepository struct { - db *gorm.DB -} - -func NewGamePlayRepository(db *gorm.DB) GamePlayRepository { - return &gamePlayRepository{ - db: db, - } -} - -func (r *gamePlayRepository) Create(ctx context.Context, gamePlay *entities.GamePlay) error { - if err := DBFromContext(ctx, r.db).WithContext(ctx).Create(gamePlay).Error; err != nil { - return fmt.Errorf("failed to create game play: %w", err) - } - return nil -} - -func (r *gamePlayRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.GamePlay, error) { - var gamePlay entities.GamePlay - err := DBFromContext(ctx, r.db).WithContext(ctx). - Preload("Game"). - Preload("Customer"). - Preload("Prize"). - First(&gamePlay, "id = ?", id).Error - if err != nil { - if err == gorm.ErrRecordNotFound { - return nil, fmt.Errorf("game play not found") - } - return nil, fmt.Errorf("failed to get game play: %w", err) - } - return &gamePlay, nil -} - -func (r *gamePlayRepository) List(ctx context.Context, offset, limit int, search string, gameID, customerID, prizeID *uuid.UUID, sortBy, sortOrder string) ([]*entities.GamePlay, int64, error) { - var gamePlays []*entities.GamePlay - var total int64 - - query := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GamePlay{}) - - // Apply filters - if search != "" { - query = query.Where("random_seed ILIKE ?", "%"+search+"%") - } - if gameID != nil { - query = query.Where("game_id = ?", *gameID) - } - if customerID != nil { - query = query.Where("customer_id = ?", *customerID) - } - if prizeID != nil { - query = query.Where("prize_id = ?", *prizeID) - } - - // Count total records - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count game plays: %w", err) - } - - // Apply sorting - if sortBy != "" { - if sortOrder == "desc" { - query = query.Order(fmt.Sprintf("%s DESC", sortBy)) - } else { - query = query.Order(fmt.Sprintf("%s ASC", sortBy)) - } - } else { - query = query.Order("created_at DESC") - } - - // Apply pagination and preload relations - err := query. - Preload("Game"). - Preload("Customer"). - Preload("Prize"). - Offset(offset). - Limit(limit). - Find(&gamePlays).Error - - if err != nil { - return nil, 0, fmt.Errorf("failed to list game plays: %w", err) - } - - return gamePlays, total, nil -} - -func (r *gamePlayRepository) GetByCustomerID(ctx context.Context, customerID uuid.UUID, limit int) ([]*entities.GamePlay, error) { - var gamePlays []*entities.GamePlay - query := DBFromContext(ctx, r.db).WithContext(ctx). - Where("customer_id = ?", customerID). - Preload("Game"). - Preload("Prize"). - Order("created_at DESC") - - if limit > 0 { - query = query.Limit(limit) - } - - err := query.Find(&gamePlays).Error - if err != nil { - return nil, fmt.Errorf("failed to get game plays by customer ID: %w", err) - } - - return gamePlays, nil -} - -func (r *gamePlayRepository) GetByGameID(ctx context.Context, gameID uuid.UUID, limit int) ([]*entities.GamePlay, error) { - var gamePlays []*entities.GamePlay - query := DBFromContext(ctx, r.db).WithContext(ctx). - Where("game_id = ?", gameID). - Preload("Customer"). - Preload("Prize"). - Order("created_at DESC") - - if limit > 0 { - query = query.Limit(limit) - } - - err := query.Find(&gamePlays).Error - if err != nil { - return nil, fmt.Errorf("failed to get game plays by game ID: %w", err) - } - - return gamePlays, nil -} - -func (r *gamePlayRepository) CountByCustomerID(ctx context.Context, customerID uuid.UUID) (int64, error) { - var count int64 - err := DBFromContext(ctx, r.db).WithContext(ctx). - Model(&entities.GamePlay{}). - Where("customer_id = ?", customerID). - Count(&count).Error - if err != nil { - return 0, fmt.Errorf("failed to count game plays by customer ID: %w", err) - } - return count, nil -} - -func (r *gamePlayRepository) CountByGameID(ctx context.Context, gameID uuid.UUID) (int64, error) { - var count int64 - err := DBFromContext(ctx, r.db).WithContext(ctx). - Model(&entities.GamePlay{}). - Where("game_id = ?", gameID). - Count(&count).Error - if err != nil { - return 0, fmt.Errorf("failed to count game plays by game ID: %w", err) - } - return count, nil -} diff --git a/internal/repository/game_prize_repository.go b/internal/repository/game_prize_repository.go deleted file mode 100644 index 2b2f13c..0000000 --- a/internal/repository/game_prize_repository.go +++ /dev/null @@ -1,115 +0,0 @@ -package repository - -import ( - "apskel-pos-be/internal/entities" - "context" - "errors" - "fmt" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -// ErrGamePrizeOutOfStock means a prize had less stock than was taken from it. -var ErrGamePrizeOutOfStock = errors.New("game prize is out of stock") - -type GamePrizeRepository struct { - db *gorm.DB -} - -func NewGamePrizeRepository(db *gorm.DB) *GamePrizeRepository { - return &GamePrizeRepository{db: db} -} - -func (r *GamePrizeRepository) Create(ctx context.Context, gamePrize *entities.GamePrize) error { - return DBFromContext(ctx, r.db).WithContext(ctx).Create(gamePrize).Error -} - -func (r *GamePrizeRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.GamePrize, error) { - var gamePrize entities.GamePrize - err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize").Where("id = ?", id).First(&gamePrize).Error - if err != nil { - return nil, err - } - return &gamePrize, nil -} - -func (r *GamePrizeRepository) GetByGameID(ctx context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) { - var gamePrizes []entities.GamePrize - err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize").Where("game_id = ?", gameID).Find(&gamePrizes).Error - if err != nil { - return nil, err - } - return gamePrizes, nil -} - -func (r *GamePrizeRepository) List(ctx context.Context, offset, limit int, search string, gameID *uuid.UUID, sortBy, sortOrder string) ([]entities.GamePrize, int64, error) { - var gamePrizes []entities.GamePrize - var total int64 - - query := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize") - - if search != "" { - searchTerm := "%" + search + "%" - query = query.Where("name ILIKE ?", searchTerm) - } - - if gameID != nil { - query = query.Where("game_id = ?", *gameID) - } - - if err := query.Model(&entities.GamePrize{}).Count(&total).Error; err != nil { - return nil, 0, err - } - - if sortBy != "" { - if sortOrder == "" { - sortOrder = "asc" - } - query = query.Order(fmt.Sprintf("game_prizes.%s %s", sortBy, sortOrder)) - } else { - query = query.Order("game_prizes.weight DESC") - } - - err := query.Offset(offset).Limit(limit).Find(&gamePrizes).Error - if err != nil { - return nil, 0, err - } - - return gamePrizes, total, nil -} - -func (r *GamePrizeRepository) Update(ctx context.Context, gamePrize *entities.GamePrize) error { - return DBFromContext(ctx, r.db).WithContext(ctx).Save(gamePrize).Error -} - -func (r *GamePrizeRepository) Delete(ctx context.Context, id uuid.UUID) error { - return DBFromContext(ctx, r.db).WithContext(ctx).Delete(&entities.GamePrize{}, id).Error -} - -// DecreaseStock takes amount from a prize's stock. When less than amount is left it -// changes nothing and returns ErrGamePrizeOutOfStock. -func (r *GamePrizeRepository) DecreaseStock(ctx context.Context, id uuid.UUID, amount int) error { - res := DBFromContext(ctx, r.db).WithContext(ctx).Model(&entities.GamePrize{}). - Where("id = ? AND stock >= ?", id, amount). - Update("stock", gorm.Expr("stock - ?", amount)) - if res.Error != nil { - return fmt.Errorf("failed to decrease prize stock: %w", res.Error) - } - if res.RowsAffected == 0 { - return ErrGamePrizeOutOfStock - } - return nil -} - -func (r *GamePrizeRepository) GetAvailablePrizes(ctx context.Context, gameID uuid.UUID) ([]entities.GamePrize, error) { - var gamePrizes []entities.GamePrize - err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Game").Preload("FallbackPrize"). - Where("game_id = ? AND stock > 0", gameID). - Order("weight DESC"). - Find(&gamePrizes).Error - if err != nil { - return nil, err - } - return gamePrizes, nil -} diff --git a/internal/repository/game_repository.go b/internal/repository/game_repository.go deleted file mode 100644 index 22d281a..0000000 --- a/internal/repository/game_repository.go +++ /dev/null @@ -1,112 +0,0 @@ -package repository - -import ( - "apskel-pos-be/internal/entities" - "context" - "fmt" - - "github.com/google/uuid" - "gorm.io/gorm" -) - -type GameRepository struct { - db *gorm.DB -} - -func NewGameRepository(db *gorm.DB) *GameRepository { - return &GameRepository{db: db} -} - -func (r *GameRepository) Create(ctx context.Context, game *entities.Game) error { - return DBFromContext(ctx, r.db).WithContext(ctx).Create(game).Error -} - -func (r *GameRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.Game, error) { - var game entities.Game - err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Prizes").Where("id = ?", id).First(&game).Error - if err != nil { - return nil, err - } - return &game, nil -} - -func (r *GameRepository) List(ctx context.Context, offset, limit int, search, gameType string, isActive *bool, sortBy, sortOrder string) ([]entities.Game, int64, error) { - var games []entities.Game - var total int64 - - query := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Prizes") - - if search != "" { - searchTerm := "%" + search + "%" - query = query.Where("name ILIKE ?", searchTerm) - } - - if gameType != "" { - query = query.Where("type = ?", gameType) - } - - if isActive != nil { - query = query.Where("is_active = ?", *isActive) - } - - if err := query.Model(&entities.Game{}).Count(&total).Error; err != nil { - return nil, 0, err - } - - if sortBy != "" { - if sortOrder == "" { - sortOrder = "asc" - } - query = query.Order(fmt.Sprintf("%s %s", sortBy, sortOrder)) - } else { - query = query.Order("created_at DESC") - } - - err := query.Offset(offset).Limit(limit).Find(&games).Error - if err != nil { - return nil, 0, err - } - - return games, total, nil -} - -func (r *GameRepository) Update(ctx context.Context, game *entities.Game) error { - return DBFromContext(ctx, r.db).WithContext(ctx).Save(game).Error -} - -func (r *GameRepository) Delete(ctx context.Context, id uuid.UUID) error { - return DBFromContext(ctx, r.db).WithContext(ctx).Delete(&entities.Game{}, id).Error -} - -func (r *GameRepository) GetActiveGames(ctx context.Context) ([]entities.Game, error) { - var games []entities.Game - err := DBFromContext(ctx, r.db).WithContext(ctx).Preload("Prizes").Where("is_active = ?", true).Find(&games).Error - if err != nil { - return nil, err - } - return games, nil -} - -func (r *GameRepository) GetActiveSpinGames(ctx context.Context) ([]entities.Game, error) { - var games []entities.Game - err := DBFromContext(ctx, r.db).WithContext(ctx). - Preload("Prizes"). - Where("is_active = ? AND type = ?", true, entities.GameTypeSpin). - Find(&games).Error - if err != nil { - return nil, err - } - return games, nil -} - -func (r *GameRepository) GetFerrisWheelGame(ctx context.Context) (*entities.Game, error) { - var game entities.Game - err := DBFromContext(ctx, r.db).WithContext(ctx). - Preload("Prizes"). - Where("is_active = ? AND type = ?", true, "SPIN"). - First(&game).Error - if err != nil { - return nil, err - } - return &game, nil -} diff --git a/internal/repository/reward_repository.go b/internal/repository/reward_repository.go deleted file mode 100644 index a143c58..0000000 --- a/internal/repository/reward_repository.go +++ /dev/null @@ -1,129 +0,0 @@ -package repository - -import ( - "context" - "fmt" - - "apskel-pos-be/internal/entities" - - "gorm.io/gorm" -) - -type RewardRepository interface { - Create(ctx context.Context, reward *entities.Reward) error - GetByID(ctx context.Context, id string) (*entities.Reward, error) - List(ctx context.Context, req *entities.ListRewardsRequest) ([]entities.Reward, int64, error) - Update(ctx context.Context, reward *entities.Reward) error - Delete(ctx context.Context, id string) error - UpdateStock(ctx context.Context, id string, newStock int) error - GetByRewardType(ctx context.Context, rewardType entities.RewardType) ([]entities.Reward, error) -} - -type rewardRepository struct { - db *gorm.DB -} - -func NewRewardRepository(db *gorm.DB) RewardRepository { - return &rewardRepository{ - db: db, - } -} - -func (r *rewardRepository) Create(ctx context.Context, reward *entities.Reward) error { - if err := r.db.WithContext(ctx).Create(reward).Error; err != nil { - return fmt.Errorf("failed to create reward: %w", err) - } - return nil -} - -func (r *rewardRepository) GetByID(ctx context.Context, id string) (*entities.Reward, error) { - var reward entities.Reward - if err := r.db.WithContext(ctx).Where("id = ?", id).First(&reward).Error; err != nil { - if err == gorm.ErrRecordNotFound { - return nil, fmt.Errorf("reward not found") - } - return nil, fmt.Errorf("failed to get reward: %w", err) - } - return &reward, nil -} - -func (r *rewardRepository) List(ctx context.Context, req *entities.ListRewardsRequest) ([]entities.Reward, int64, error) { - var rewards []entities.Reward - var total int64 - - query := r.db.WithContext(ctx).Model(&entities.Reward{}) - - // Apply filters - if req.Search != "" { - query = query.Where("name ILIKE ?", "%"+req.Search+"%") - } - - if req.RewardType != "" { - query = query.Where("reward_type = ?", req.RewardType) - } - - if req.MinPoints != nil { - query = query.Where("cost_points >= ?", *req.MinPoints) - } - - if req.MaxPoints != nil { - query = query.Where("cost_points <= ?", *req.MaxPoints) - } - - if req.HasStock != nil { - if *req.HasStock { - query = query.Where("stock > 0 OR stock IS NULL") - } else { - query = query.Where("stock <= 0") - } - } - - // Count total records - if err := query.Count(&total).Error; err != nil { - return nil, 0, fmt.Errorf("failed to count rewards: %w", err) - } - - // Apply pagination - if req.Page > 0 && req.Limit > 0 { - offset := (req.Page - 1) * req.Limit - query = query.Offset(offset).Limit(req.Limit) - } - - // Apply sorting - query = query.Order("created_at DESC") - - if err := query.Find(&rewards).Error; err != nil { - return nil, 0, fmt.Errorf("failed to list rewards: %w", err) - } - - return rewards, total, nil -} - -func (r *rewardRepository) Update(ctx context.Context, reward *entities.Reward) error { - if err := r.db.WithContext(ctx).Save(reward).Error; err != nil { - return fmt.Errorf("failed to update reward: %w", err) - } - return nil -} - -func (r *rewardRepository) Delete(ctx context.Context, id string) error { - if err := r.db.WithContext(ctx).Where("id = ?", id).Delete(&entities.Reward{}).Error; err != nil { - return fmt.Errorf("failed to delete reward: %w", err) - } - return nil -} - -func (r *rewardRepository) UpdateStock(ctx context.Context, id string, newStock int) error { - if err := r.db.WithContext(ctx).Model(&entities.Reward{}).Where("id = ?", id).Update("stock", newStock).Error; err != nil { - return fmt.Errorf("failed to update reward stock: %w", err) - } - return nil -} - -func (r *rewardRepository) GetByRewardType(ctx context.Context, rewardType entities.RewardType) ([]entities.Reward, error) { - var rewards []entities.Reward - if err := r.db.WithContext(ctx).Where("reward_type = ?", rewardType).Find(&rewards).Error; err != nil { - return nil, fmt.Errorf("failed to get rewards by type: %w", err) - } - return rewards, nil -} diff --git a/internal/router/router.go b/internal/router/router.go index 6f6f666..c4778f3 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -43,11 +43,9 @@ type Router struct { accountHandler *handler.AccountHandler orderIngredientTransactionHandler *handler.OrderIngredientTransactionHandler gamificationHandler *handler.GamificationHandler - rewardHandler *handler.RewardHandler campaignHandler *handler.CampaignHandler customerAuthHandler *handler.CustomerAuthHandler customerPointsHandler *handler.CustomerPointsHandler - spinGameHandler *handler.SpinGameHandler userDeviceHandler *handler.UserDeviceHandler notificationHandler *handler.NotificationHandler selfOrderHandler *handler.SelfOrderHandler @@ -68,7 +66,7 @@ type Router struct { redisClient *redis.Client } -func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, rewardService service.RewardService, rewardValidator validator.RewardValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, spinGameService service.SpinGameService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, customerPinService service.CustomerPinService, customerWalletService service.CustomerWalletService, customerDeviceService service.CustomerDeviceService, customerOutletService service.CustomerOutletService, customerOrderService service.CustomerOrderService, enakGameAdminService service.EnakGameAdminService, enakGameCustomerService service.EnakGameCustomerService, redisClient *redis.Client) *Router { +func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authService service.AuthService, authMiddleware *middleware.AuthMiddleware, userService *service.UserServiceImpl, userValidator *validator.UserValidatorImpl, organizationService service.OrganizationService, organizationValidator validator.OrganizationValidator, outletService service.OutletService, outletValidator validator.OutletValidator, outletSettingService service.OutletSettingService, categoryService service.CategoryService, categoryValidator validator.CategoryValidator, productService service.ProductService, productValidator validator.ProductValidator, productVariantService service.ProductVariantService, productVariantValidator validator.ProductVariantValidator, inventoryService service.InventoryService, inventoryValidator validator.InventoryValidator, orderService service.OrderService, orderValidator validator.OrderValidator, fileService service.FileService, fileValidator validator.FileValidator, customerService service.CustomerService, customerValidator validator.CustomerValidator, paymentMethodService service.PaymentMethodService, paymentMethodValidator validator.PaymentMethodValidator, analyticsService *service.AnalyticsServiceImpl, reportService service.ReportService, tableService *service.TableServiceImpl, tableValidator *validator.TableValidator, unitService handler.UnitService, ingredientService handler.IngredientService, productRecipeService service.ProductRecipeService, vendorService service.VendorService, vendorValidator validator.VendorValidator, purchaseOrderService service.PurchaseOrderService, purchaseOrderValidator validator.PurchaseOrderValidator, purchaseCategoryService service.PurchaseCategoryService, purchaseCategoryValidator validator.PurchaseCategoryValidator, unitConverterService service.IngredientUnitConverterService, unitConverterValidator validator.IngredientUnitConverterValidator, chartOfAccountTypeService service.ChartOfAccountTypeService, chartOfAccountTypeValidator validator.ChartOfAccountTypeValidator, chartOfAccountService service.ChartOfAccountService, chartOfAccountValidator validator.ChartOfAccountValidator, accountService service.AccountService, accountValidator validator.AccountValidator, orderIngredientTransactionService service.OrderIngredientTransactionService, orderIngredientTransactionValidator validator.OrderIngredientTransactionValidator, gamificationService service.GamificationService, gamificationValidator validator.GamificationValidator, campaignService service.CampaignService, campaignValidator validator.CampaignValidator, customerAuthService service.CustomerAuthService, customerAuthValidator validator.CustomerAuthValidator, customerPointsService service.CustomerPointsService, customerAuthMiddleware *middleware.CustomerAuthMiddleware, userDeviceService service.UserDeviceService, userDeviceValidator validator.UserDeviceValidator, notificationService service.NotificationService, notificationValidator validator.NotificationValidator, productOutletPriceService service.ProductOutletPriceService, productOutletPriceValidator validator.ProductOutletPriceValidator, selfOrderHandler *handler.SelfOrderHandler, expenseService *service.ExpenseServiceImpl, expenseValidator *validator.ExpenseValidatorImpl, cashAdvanceService service.CashAdvanceService, cashAdvanceValidator validator.CashAdvanceValidator, walletAdminService service.WalletAdminService, walletValidator validator.WalletValidator, loyaltySettingsService service.LoyaltySettingsService, customerPinService service.CustomerPinService, customerWalletService service.CustomerWalletService, customerDeviceService service.CustomerDeviceService, customerOutletService service.CustomerOutletService, customerOrderService service.CustomerOrderService, enakGameAdminService service.EnakGameAdminService, enakGameCustomerService service.EnakGameCustomerService, redisClient *redis.Client) *Router { return &Router{ config: cfg, @@ -100,11 +98,9 @@ func NewRouter(cfg *config.Config, healthHandler *handler.HealthHandler, authSer accountHandler: handler.NewAccountHandler(accountService, accountValidator), orderIngredientTransactionHandler: handler.NewOrderIngredientTransactionHandler(&orderIngredientTransactionService, orderIngredientTransactionValidator), gamificationHandler: handler.NewGamificationHandler(gamificationService, gamificationValidator), - rewardHandler: handler.NewRewardHandler(rewardService, rewardValidator), campaignHandler: handler.NewCampaignHandler(campaignService, campaignValidator), customerAuthHandler: handler.NewCustomerAuthHandler(customerAuthService, customerAuthValidator), customerPointsHandler: handler.NewCustomerPointsHandler(customerPointsService), - spinGameHandler: handler.NewSpinGameHandler(spinGameService), authMiddleware: authMiddleware, customerAuthMiddleware: customerAuthMiddleware, productVariantHandler: handler.NewProductVariantHandler(productVariantService, productVariantValidator), @@ -191,11 +187,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { customer.POST("/pin", r.customerPinHandler.CreatePin) customer.PUT("/pin", r.customerPinHandler.ChangePin) customer.POST("/pin/reset", r.customerPinHandler.ResetPin) - customer.GET("/games", r.customerPointsHandler.GetCustomerGames) - customer.GET("/ferris-wheel", r.customerPointsHandler.GetFerrisWheelGame) - customer.POST("/spin", r.spinGameHandler.PlaySpinGame) - // EnakGame (docs/rfc-enakgame.md §11). /enakgame because /customer/games is + // EnakGame (docs/rfc-enakgame.md §11). /enakgame because /customer/games was // the old spin. enakGame := customer.Group("/enakgame") { @@ -570,38 +563,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { tiers.GET("/by-points/:points", r.gamificationHandler.GetTierByPoints) } - // Games - games := gamification.Group("/games") - { - games.POST("", r.gamificationHandler.CreateGame) - games.GET("", r.gamificationHandler.ListGames) - games.GET("/active", r.gamificationHandler.GetActiveGames) - games.GET("/:id", r.gamificationHandler.GetGame) - games.PUT("/:id", r.gamificationHandler.UpdateGame) - games.DELETE("/:id", r.gamificationHandler.DeleteGame) - } - - // Game Prizes - gamePrizes := gamification.Group("/game-prizes") - { - gamePrizes.POST("", r.gamificationHandler.CreateGamePrize) - gamePrizes.GET("", r.gamificationHandler.ListGamePrizes) - gamePrizes.GET("/:id", r.gamificationHandler.GetGamePrize) - gamePrizes.PUT("/:id", r.gamificationHandler.UpdateGamePrize) - gamePrizes.DELETE("/:id", r.gamificationHandler.DeleteGamePrize) - gamePrizes.GET("/game/:game_id", r.gamificationHandler.GetGamePrizesByGameID) - gamePrizes.GET("/game/:game_id/available", r.gamificationHandler.GetAvailablePrizes) - } - - //// Game Plays - //gamePlays := gamification.Group("/game-plays") - //{ - // gamePlays.POST("", r.gamificationHandler.CreateGamePlay) - // gamePlays.GET("", r.gamificationHandler.ListGamePlays) - // gamePlays.GET("/:id", r.gamificationHandler.GetGamePlay) - // gamePlays.POST("/play", r.gamificationHandler.PlayGame) - //} - // Omset Tracker //omsetTracker := gamification.Group("/omset-tracker") //{ @@ -612,18 +573,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { // omsetTracker.DELETE("/:id", r.gamificationHandler.DeleteOmsetTracker) //} - // Rewards - rewards := gamification.Group("/rewards") - { - rewards.POST("", r.rewardHandler.CreateReward) - rewards.GET("", r.rewardHandler.ListRewards) - rewards.GET("/:id", r.rewardHandler.GetReward) - rewards.PUT("/:id", r.rewardHandler.UpdateReward) - rewards.DELETE("/:id", r.rewardHandler.DeleteReward) - rewards.PUT("/:id/stock/:stock", r.rewardHandler.UpdateRewardStock) - rewards.GET("/type/:type", r.rewardHandler.GetRewardsByType) - } - // Campaigns campaigns := gamification.Group("/campaigns") { diff --git a/internal/service/customer_points_service.go b/internal/service/customer_points_service.go index 33c0728..2f7ee20 100644 --- a/internal/service/customer_points_service.go +++ b/internal/service/customer_points_service.go @@ -13,8 +13,6 @@ type CustomerPointsService interface { GetCustomerWallet(ctx context.Context, customerID string) (*models.GetCustomerWalletResponse, error) GetCustomerWalletTransactions(ctx context.Context, customerID string, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error) GetCustomerWalletExpiring(ctx context.Context, customerID string) (*models.CustomerWalletExpiringList, error) - GetCustomerGames(ctx context.Context) (*models.GetCustomerGamesResponse, error) - GetFerrisWheelGame(ctx context.Context) (*models.GetFerrisWheelGameResponse, error) } type customerPointsService struct { @@ -53,24 +51,6 @@ func (s *customerPointsService) GetCustomerWallet(ctx context.Context, customerI return response, nil } -func (s *customerPointsService) GetCustomerGames(ctx context.Context) (*models.GetCustomerGamesResponse, error) { - response, err := s.customerPointsProcessor.GetCustomerGamesAPI(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get customer games: %w", err) - } - - return response, nil -} - -func (s *customerPointsService) GetFerrisWheelGame(ctx context.Context) (*models.GetFerrisWheelGameResponse, error) { - response, err := s.customerPointsProcessor.GetFerrisWheelGameAPI(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get Ferris Wheel game: %w", err) - } - - return response, nil -} - func (s *customerPointsService) GetCustomerWalletTransactions(ctx context.Context, customerID string, query models.ListCustomerWalletTransactionsQuery) (*models.PaginatedResponse[models.CustomerWalletTransaction], error) { if customerID == "" { return nil, fmt.Errorf("customer ID is required") diff --git a/internal/service/gamification_service.go b/internal/service/gamification_service.go index 7096fe1..43f2d09 100644 --- a/internal/service/gamification_service.go +++ b/internal/service/gamification_service.go @@ -19,29 +19,6 @@ type GamificationService interface { DeleteTier(ctx context.Context, id uuid.UUID) error GetTierByPoints(ctx context.Context, points int64) (*contract.TierResponse, error) - // Games - CreateGame(ctx context.Context, req *contract.CreateGameRequest) (*contract.GameResponse, error) - GetGame(ctx context.Context, id uuid.UUID) (*contract.GameResponse, error) - ListGames(ctx context.Context, query *contract.ListGamesRequest) (*contract.PaginatedGamesResponse, error) - UpdateGame(ctx context.Context, id uuid.UUID, req *contract.UpdateGameRequest) (*contract.GameResponse, error) - DeleteGame(ctx context.Context, id uuid.UUID) error - GetActiveGames(ctx context.Context) ([]contract.GameResponse, error) - - // Game Prizes - CreateGamePrize(ctx context.Context, req *contract.CreateGamePrizeRequest) (*contract.GamePrizeResponse, error) - GetGamePrize(ctx context.Context, id uuid.UUID) (*contract.GamePrizeResponse, error) - GetGamePrizesByGameID(ctx context.Context, gameID uuid.UUID) ([]contract.GamePrizeResponse, error) - ListGamePrizes(ctx context.Context, query *contract.ListGamePrizesRequest) (*contract.PaginatedGamePrizesResponse, error) - UpdateGamePrize(ctx context.Context, id uuid.UUID, req *contract.UpdateGamePrizeRequest) (*contract.GamePrizeResponse, error) - DeleteGamePrize(ctx context.Context, id uuid.UUID) error - GetAvailablePrizes(ctx context.Context, gameID uuid.UUID) ([]contract.GamePrizeResponse, error) - - // Game Plays - CreateGamePlay(ctx context.Context, req *contract.CreateGamePlayRequest) (*contract.GamePlayResponse, error) - GetGamePlay(ctx context.Context, id uuid.UUID) (*contract.GamePlayResponse, error) - ListGamePlays(ctx context.Context, query *contract.ListGamePlaysRequest) (*contract.PaginatedGamePlaysResponse, error) - PlayGame(ctx context.Context, req *contract.PlayGameRequest) (*contract.PlayGameResponse, error) - // Omset Tracker CreateOmsetTracker(ctx context.Context, req *contract.CreateOmsetTrackerRequest) (*contract.OmsetTrackerResponse, error) GetOmsetTracker(ctx context.Context, id uuid.UUID) (*contract.OmsetTrackerResponse, error) @@ -53,24 +30,15 @@ type GamificationService interface { type GamificationServiceImpl struct { tierProcessor *processor.TierProcessor - gameProcessor *processor.GameProcessor - gamePrizeProcessor *processor.GamePrizeProcessor - gamePlayProcessor *processor.GamePlayProcessor omsetTrackerProcessor *processor.OmsetTrackerProcessor } func NewGamificationService( tierProcessor *processor.TierProcessor, - gameProcessor *processor.GameProcessor, - gamePrizeProcessor *processor.GamePrizeProcessor, - gamePlayProcessor *processor.GamePlayProcessor, omsetTrackerProcessor *processor.OmsetTrackerProcessor, ) *GamificationServiceImpl { return &GamificationServiceImpl{ tierProcessor: tierProcessor, - gameProcessor: gameProcessor, - gamePrizeProcessor: gamePrizeProcessor, - gamePlayProcessor: gamePlayProcessor, omsetTrackerProcessor: omsetTrackerProcessor, } } @@ -123,146 +91,6 @@ func (s *GamificationServiceImpl) GetTierByPoints(ctx context.Context, points in return transformer.TierModelToResponse(response), nil } -// Game Service Methods -func (s *GamificationServiceImpl) CreateGame(ctx context.Context, req *contract.CreateGameRequest) (*contract.GameResponse, error) { - modelReq := transformer.CreateGameRequestToModel(req) - response, err := s.gameProcessor.CreateGame(ctx, modelReq) - if err != nil { - return nil, err - } - return transformer.GameModelToResponse(response), nil -} - -func (s *GamificationServiceImpl) GetGame(ctx context.Context, id uuid.UUID) (*contract.GameResponse, error) { - response, err := s.gameProcessor.GetGame(ctx, id) - if err != nil { - return nil, err - } - return transformer.GameModelToResponse(response), nil -} - -func (s *GamificationServiceImpl) ListGames(ctx context.Context, query *contract.ListGamesRequest) (*contract.PaginatedGamesResponse, error) { - modelQuery := transformer.ListGamesRequestToModel(query) - response, err := s.gameProcessor.ListGames(ctx, modelQuery) - if err != nil { - return nil, err - } - return transformer.PaginatedGamesResponseToContract(response), nil -} - -func (s *GamificationServiceImpl) UpdateGame(ctx context.Context, id uuid.UUID, req *contract.UpdateGameRequest) (*contract.GameResponse, error) { - modelReq := transformer.UpdateGameRequestToModel(req) - response, err := s.gameProcessor.UpdateGame(ctx, id, modelReq) - if err != nil { - return nil, err - } - return transformer.GameModelToResponse(response), nil -} - -func (s *GamificationServiceImpl) DeleteGame(ctx context.Context, id uuid.UUID) error { - return s.gameProcessor.DeleteGame(ctx, id) -} - -func (s *GamificationServiceImpl) GetActiveGames(ctx context.Context) ([]contract.GameResponse, error) { - response, err := s.gameProcessor.GetActiveGames(ctx) - if err != nil { - return nil, err - } - return transformer.GameModelsToResponses(response), nil -} - -// Game Prize Service Methods -func (s *GamificationServiceImpl) CreateGamePrize(ctx context.Context, req *contract.CreateGamePrizeRequest) (*contract.GamePrizeResponse, error) { - modelReq := transformer.CreateGamePrizeRequestToModel(req) - response, err := s.gamePrizeProcessor.CreateGamePrize(ctx, modelReq) - if err != nil { - return nil, err - } - return transformer.GamePrizeModelToResponse(response), nil -} - -func (s *GamificationServiceImpl) GetGamePrize(ctx context.Context, id uuid.UUID) (*contract.GamePrizeResponse, error) { - response, err := s.gamePrizeProcessor.GetGamePrize(ctx, id) - if err != nil { - return nil, err - } - return transformer.GamePrizeModelToResponse(response), nil -} - -func (s *GamificationServiceImpl) GetGamePrizesByGameID(ctx context.Context, gameID uuid.UUID) ([]contract.GamePrizeResponse, error) { - response, err := s.gamePrizeProcessor.GetGamePrizesByGameID(ctx, gameID) - if err != nil { - return nil, err - } - return transformer.GamePrizeModelsToResponses(response), nil -} - -func (s *GamificationServiceImpl) ListGamePrizes(ctx context.Context, query *contract.ListGamePrizesRequest) (*contract.PaginatedGamePrizesResponse, error) { - modelQuery := transformer.ListGamePrizesRequestToModel(query) - response, err := s.gamePrizeProcessor.ListGamePrizes(ctx, modelQuery) - if err != nil { - return nil, err - } - return transformer.PaginatedGamePrizesResponseToContract(response), nil -} - -func (s *GamificationServiceImpl) UpdateGamePrize(ctx context.Context, id uuid.UUID, req *contract.UpdateGamePrizeRequest) (*contract.GamePrizeResponse, error) { - modelReq := transformer.UpdateGamePrizeRequestToModel(req) - response, err := s.gamePrizeProcessor.UpdateGamePrize(ctx, id, modelReq) - if err != nil { - return nil, err - } - return transformer.GamePrizeModelToResponse(response), nil -} - -func (s *GamificationServiceImpl) DeleteGamePrize(ctx context.Context, id uuid.UUID) error { - return s.gamePrizeProcessor.DeleteGamePrize(ctx, id) -} - -func (s *GamificationServiceImpl) GetAvailablePrizes(ctx context.Context, gameID uuid.UUID) ([]contract.GamePrizeResponse, error) { - response, err := s.gamePrizeProcessor.GetAvailablePrizes(ctx, gameID) - if err != nil { - return nil, err - } - return transformer.GamePrizeModelsToResponses(response), nil -} - -// Game Play Service Methods -func (s *GamificationServiceImpl) CreateGamePlay(ctx context.Context, req *contract.CreateGamePlayRequest) (*contract.GamePlayResponse, error) { - modelReq := transformer.CreateGamePlayRequestToModel(req) - response, err := s.gamePlayProcessor.CreateGamePlay(ctx, modelReq) - if err != nil { - return nil, err - } - return transformer.GamePlayModelToResponse(response), nil -} - -func (s *GamificationServiceImpl) GetGamePlay(ctx context.Context, id uuid.UUID) (*contract.GamePlayResponse, error) { - response, err := s.gamePlayProcessor.GetGamePlay(ctx, id) - if err != nil { - return nil, err - } - return transformer.GamePlayModelToResponse(response), nil -} - -func (s *GamificationServiceImpl) ListGamePlays(ctx context.Context, query *contract.ListGamePlaysRequest) (*contract.PaginatedGamePlaysResponse, error) { - modelQuery := transformer.ListGamePlaysRequestToModel(query) - response, err := s.gamePlayProcessor.ListGamePlays(ctx, modelQuery) - if err != nil { - return nil, err - } - return transformer.PaginatedGamePlaysResponseToContract(response), nil -} - -func (s *GamificationServiceImpl) PlayGame(ctx context.Context, req *contract.PlayGameRequest) (*contract.PlayGameResponse, error) { - modelReq := transformer.PlayGameRequestToModel(req) - response, err := s.gamePlayProcessor.PlayGame(ctx, modelReq) - if err != nil { - return nil, err - } - return transformer.PlayGameModelToResponse(response), nil -} - // Omset Tracker Service Methods func (s *GamificationServiceImpl) CreateOmsetTracker(ctx context.Context, req *contract.CreateOmsetTrackerRequest) (*contract.OmsetTrackerResponse, error) { modelReq := transformer.CreateOmsetTrackerRequestToModel(req) diff --git a/internal/service/reward_service.go b/internal/service/reward_service.go deleted file mode 100644 index e1960b6..0000000 --- a/internal/service/reward_service.go +++ /dev/null @@ -1,198 +0,0 @@ -package service - -import ( - "context" - "fmt" - - "apskel-pos-be/internal/contract" - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/processor" -) - -type RewardService interface { - CreateReward(ctx context.Context, req *contract.CreateRewardRequest) (*models.RewardResponse, error) - GetReward(ctx context.Context, id string) (*models.RewardResponse, error) - ListRewards(ctx context.Context, req *contract.ListRewardsRequest) (*models.ListRewardsResponse, error) - UpdateReward(ctx context.Context, req *contract.UpdateRewardRequest) (*models.RewardResponse, error) - DeleteReward(ctx context.Context, id string) error - UpdateRewardStock(ctx context.Context, id string, newStock int) error - GetRewardsByType(ctx context.Context, rewardType string) ([]models.RewardResponse, error) -} - -type rewardService struct { - rewardProcessor processor.RewardProcessor -} - -func NewRewardService(rewardProcessor processor.RewardProcessor) RewardService { - return &rewardService{ - rewardProcessor: rewardProcessor, - } -} - -func (s *rewardService) CreateReward(ctx context.Context, req *contract.CreateRewardRequest) (*models.RewardResponse, error) { - // Validate reward type - if err := s.validateRewardType(req.RewardType); err != nil { - return nil, fmt.Errorf("validation failed: %w", err) - } - - // Validate cost points - if req.CostPoints <= 0 { - return nil, fmt.Errorf("cost points must be greater than 0") - } - - // Validate stock for physical rewards - if req.RewardType == "PHYSICAL" && req.Stock != nil && *req.Stock <= 0 { - return nil, fmt.Errorf("physical rewards must have positive stock or unlimited stock") - } - - // Validate max per customer - if req.MaxPerCustomer <= 0 { - return nil, fmt.Errorf("max per customer must be greater than 0") - } - - // Create reward - response, err := s.rewardProcessor.CreateReward(ctx, req) - if err != nil { - return nil, fmt.Errorf("failed to create reward: %w", err) - } - - return response, nil -} - -func (s *rewardService) GetReward(ctx context.Context, id string) (*models.RewardResponse, error) { - if id == "" { - return nil, fmt.Errorf("reward ID is required") - } - - response, err := s.rewardProcessor.GetReward(ctx, id) - if err != nil { - return nil, fmt.Errorf("failed to get reward: %w", err) - } - - return response, nil -} - -func (s *rewardService) ListRewards(ctx context.Context, req *contract.ListRewardsRequest) (*models.ListRewardsResponse, error) { - // Set default pagination - if req.Page <= 0 { - req.Page = 1 - } - if req.Limit <= 0 { - req.Limit = 10 - } - if req.Limit > 100 { - req.Limit = 100 - } - - // Validate reward type filter if provided - if req.RewardType != "" { - if err := s.validateRewardType(req.RewardType); err != nil { - return nil, fmt.Errorf("validation failed: %w", err) - } - } - - // Validate points range if provided - if req.MinPoints != nil && req.MaxPoints != nil { - if *req.MinPoints > *req.MaxPoints { - return nil, fmt.Errorf("min points cannot be greater than max points") - } - } - - response, err := s.rewardProcessor.ListRewards(ctx, req) - if err != nil { - return nil, fmt.Errorf("failed to list rewards: %w", err) - } - - return response, nil -} - -func (s *rewardService) UpdateReward(ctx context.Context, req *contract.UpdateRewardRequest) (*models.RewardResponse, error) { - if req.ID.String() == "" { - return nil, fmt.Errorf("reward ID is required") - } - - // Validate reward type - if err := s.validateRewardType(req.RewardType); err != nil { - return nil, fmt.Errorf("validation failed: %w", err) - } - - // Validate cost points - if req.CostPoints <= 0 { - return nil, fmt.Errorf("cost points must be greater than 0") - } - - // Validate stock for physical rewards - if req.RewardType == "PHYSICAL" && req.Stock != nil && *req.Stock <= 0 { - return nil, fmt.Errorf("physical rewards must have positive stock or unlimited stock") - } - - // Validate max per customer - if req.MaxPerCustomer <= 0 { - return nil, fmt.Errorf("max per customer must be greater than 0") - } - - response, err := s.rewardProcessor.UpdateReward(ctx, req) - if err != nil { - return nil, fmt.Errorf("failed to update reward: %w", err) - } - - return response, nil -} - -func (s *rewardService) DeleteReward(ctx context.Context, id string) error { - if id == "" { - return fmt.Errorf("reward ID is required") - } - - err := s.rewardProcessor.DeleteReward(ctx, id) - if err != nil { - return fmt.Errorf("failed to delete reward: %w", err) - } - - return nil -} - -func (s *rewardService) UpdateRewardStock(ctx context.Context, id string, newStock int) error { - if id == "" { - return fmt.Errorf("reward ID is required") - } - - if newStock < 0 { - return fmt.Errorf("stock cannot be negative") - } - - err := s.rewardProcessor.UpdateRewardStock(ctx, id, newStock) - if err != nil { - return fmt.Errorf("failed to update reward stock: %w", err) - } - - return nil -} - -func (s *rewardService) GetRewardsByType(ctx context.Context, rewardType string) ([]models.RewardResponse, error) { - if rewardType == "" { - return nil, fmt.Errorf("reward type is required") - } - - // Validate reward type - if err := s.validateRewardType(rewardType); err != nil { - return nil, fmt.Errorf("validation failed: %w", err) - } - - rewards, err := s.rewardProcessor.GetRewardsByType(ctx, rewardType) - if err != nil { - return nil, fmt.Errorf("failed to get rewards by type: %w", err) - } - - return rewards, nil -} - -func (s *rewardService) validateRewardType(rewardType string) error { - validTypes := []string{"VOUCHER", "PHYSICAL", "DIGITAL", "BALANCE"} - for _, validType := range validTypes { - if rewardType == validType { - return nil - } - } - return fmt.Errorf("invalid reward type: %s. Valid types are: VOUCHER, PHYSICAL, DIGITAL, BALANCE", rewardType) -} diff --git a/internal/service/spin_game_service.go b/internal/service/spin_game_service.go deleted file mode 100644 index 42390c2..0000000 --- a/internal/service/spin_game_service.go +++ /dev/null @@ -1,80 +0,0 @@ -package service - -import ( - "context" - "fmt" - - "apskel-pos-be/internal/models" - "apskel-pos-be/internal/processor" - - "github.com/google/uuid" -) - -type SpinGameService interface { - PlaySpinGame(ctx context.Context, customerID string, req *models.SpinGameRequest) (*models.SpinGameResponse, error) -} - -type spinGameService struct { - gamePlayProcessor *processor.GamePlayProcessor -} - -func NewSpinGameService(gamePlayProcessor *processor.GamePlayProcessor) SpinGameService { - return &spinGameService{ - gamePlayProcessor: gamePlayProcessor, - } -} - -func (s *spinGameService) PlaySpinGame(ctx context.Context, customerID string, req *models.SpinGameRequest) (*models.SpinGameResponse, error) { - // Validate customer ID - if customerID == "" { - return nil, fmt.Errorf("customer ID is required") - } - - // Parse spin ID to UUID - spinID, err := uuid.Parse(req.SpinID) - if err != nil { - return nil, fmt.Errorf("invalid spin ID format: %w", err) - } - - // Parse customer ID to UUID - customerUUID, err := uuid.Parse(customerID) - if err != nil { - return nil, fmt.Errorf("invalid customer ID format: %w", err) - } - - // PlayGame runs its own transaction and charges the game's coin_cost in EnakCoin. - playResponse, err := s.gamePlayProcessor.PlayGame(ctx, &models.PlayGameRequest{ - GameID: spinID, - CustomerID: customerUUID, - }) - if err != nil { - return nil, fmt.Errorf("failed to play spin game: %w", err) - } - - // Convert prize to customer-facing format - var customerPrize *models.CustomerGamePrizeResponse - if playResponse.PrizeWon != nil { - customerPrize = &models.CustomerGamePrizeResponse{ - ID: playResponse.PrizeWon.ID, - GameID: playResponse.PrizeWon.GameID, - Name: playResponse.PrizeWon.Name, - Image: playResponse.PrizeWon.Image, - Metadata: &playResponse.PrizeWon.Metadata, - CreatedAt: playResponse.PrizeWon.CreatedAt, - UpdatedAt: playResponse.PrizeWon.UpdatedAt, - } - } - - // Convert to spin game response - response := &models.SpinGameResponse{ - Status: "SUCCESS", - Message: "Spin game completed successfully.", - Data: &models.SpinGameResponseData{ - GamePlay: playResponse.GamePlay, - PrizeWon: customerPrize, - CoinsRemaining: playResponse.CoinsRemaining, - }, - } - - return response, nil -} diff --git a/internal/transformer/gamification_transformer.go b/internal/transformer/gamification_transformer.go index 8c17e3c..1a85459 100644 --- a/internal/transformer/gamification_transformer.go +++ b/internal/transformer/gamification_transformer.go @@ -58,274 +58,6 @@ func PaginatedTiersResponseToContract(model *models.PaginatedResponse[models.Tie } } -// Game Transformers -func CreateGameRequestToModel(req *contract.CreateGameRequest) *models.CreateGameRequest { - return &models.CreateGameRequest{ - Name: req.Name, - Type: req.Type, - IsActive: req.IsActive, - Metadata: req.Metadata, - } -} - -func UpdateGameRequestToModel(req *contract.UpdateGameRequest) *models.UpdateGameRequest { - return &models.UpdateGameRequest{ - Name: req.Name, - Type: req.Type, - IsActive: req.IsActive, - Metadata: req.Metadata, - } -} - -func ListGamesRequestToModel(req *contract.ListGamesRequest) *models.ListGamesQuery { - return &models.ListGamesQuery{ - Page: req.Page, - Limit: req.Limit, - Search: req.Search, - Type: req.Type, - IsActive: req.IsActive, - SortBy: req.SortBy, - SortOrder: req.SortOrder, - } -} - -func GameModelToResponse(model *models.GameResponse) *contract.GameResponse { - if model == nil { - return nil - } - - return &contract.GameResponse{ - ID: model.ID, - Name: model.Name, - Type: model.Type, - IsActive: model.IsActive, - Metadata: model.Metadata, - CreatedAt: model.CreatedAt, - UpdatedAt: model.UpdatedAt, - } -} - -func GameModelsToResponses(models []models.GameResponse) []contract.GameResponse { - responses := make([]contract.GameResponse, len(models)) - for i, model := range models { - responses[i] = *GameModelToResponse(&model) - } - return responses -} - -func PaginatedGamesResponseToContract(model *models.PaginatedResponse[models.GameResponse]) *contract.PaginatedGamesResponse { - responses := make([]contract.GameResponse, len(model.Data)) - for i, item := range model.Data { - responses[i] = *GameModelToResponse(&item) - } - - return &contract.PaginatedGamesResponse{ - Data: responses, - TotalCount: int(model.Pagination.Total), - Page: model.Pagination.Page, - Limit: model.Pagination.Limit, - TotalPages: model.Pagination.TotalPages, - } -} - -// Game Prize Transformers -func CreateGamePrizeRequestToModel(req *contract.CreateGamePrizeRequest) *models.CreateGamePrizeRequest { - return &models.CreateGamePrizeRequest{ - GameID: req.GameID, - Name: req.Name, - Weight: req.Weight, - Stock: req.Stock, - MaxStock: req.MaxStock, - Threshold: req.Threshold, - FallbackPrizeID: req.FallbackPrizeID, - Metadata: req.Metadata, - } -} - -func UpdateGamePrizeRequestToModel(req *contract.UpdateGamePrizeRequest) *models.UpdateGamePrizeRequest { - return &models.UpdateGamePrizeRequest{ - Name: req.Name, - Weight: req.Weight, - Stock: req.Stock, - MaxStock: req.MaxStock, - Threshold: req.Threshold, - FallbackPrizeID: req.FallbackPrizeID, - Metadata: req.Metadata, - } -} - -func ListGamePrizesRequestToModel(req *contract.ListGamePrizesRequest) *models.ListGamePrizesQuery { - return &models.ListGamePrizesQuery{ - Page: req.Page, - Limit: req.Limit, - Search: req.Search, - GameID: req.GameID, - SortBy: req.SortBy, - SortOrder: req.SortOrder, - } -} - -func GamePrizeModelToResponse(model *models.GamePrizeResponse) *contract.GamePrizeResponse { - if model == nil { - return nil - } - - var game *contract.GameResponse - if model.Game != nil { - game = GameModelToResponse(model.Game) - } - - var fallbackPrize *contract.GamePrizeResponse - if model.FallbackPrize != nil { - fallbackPrize = GamePrizeModelToResponse(model.FallbackPrize) - } - - return &contract.GamePrizeResponse{ - ID: model.ID, - GameID: model.GameID, - Name: model.Name, - Weight: model.Weight, - Stock: model.Stock, - MaxStock: model.MaxStock, - Threshold: model.Threshold, - FallbackPrizeID: model.FallbackPrizeID, - Metadata: model.Metadata, - Game: game, - FallbackPrize: fallbackPrize, - CreatedAt: model.CreatedAt, - UpdatedAt: model.UpdatedAt, - } -} - -func GamePrizeModelsToResponses(models []models.GamePrizeResponse) []contract.GamePrizeResponse { - responses := make([]contract.GamePrizeResponse, len(models)) - for i, model := range models { - responses[i] = *GamePrizeModelToResponse(&model) - } - return responses -} - -func PaginatedGamePrizesResponseToContract(model *models.PaginatedResponse[models.GamePrizeResponse]) *contract.PaginatedGamePrizesResponse { - responses := make([]contract.GamePrizeResponse, len(model.Data)) - for i, item := range model.Data { - responses[i] = *GamePrizeModelToResponse(&item) - } - - return &contract.PaginatedGamePrizesResponse{ - Data: responses, - TotalCount: int(model.Pagination.Total), - Page: model.Pagination.Page, - Limit: model.Pagination.Limit, - TotalPages: model.Pagination.TotalPages, - } -} - -// Game Play Transformers -func CreateGamePlayRequestToModel(req *contract.CreateGamePlayRequest) *models.CreateGamePlayRequest { - return &models.CreateGamePlayRequest{ - GameID: req.GameID, - CustomerID: req.CustomerID, - CoinsUsed: req.CoinsUsed, - RandomSeed: req.RandomSeed, - } -} - -func PlayGameRequestToModel(req *contract.PlayGameRequest) *models.PlayGameRequest { - return &models.PlayGameRequest{ - GameID: req.GameID, - CustomerID: req.CustomerID, - } -} - -func ListGamePlaysRequestToModel(req *contract.ListGamePlaysRequest) *models.ListGamePlaysQuery { - return &models.ListGamePlaysQuery{ - Page: req.Page, - Limit: req.Limit, - Search: req.Search, - GameID: req.GameID, - CustomerID: req.CustomerID, - PrizeID: req.PrizeID, - SortBy: req.SortBy, - SortOrder: req.SortOrder, - } -} - -func GamePlayModelToResponse(model *models.GamePlayResponse) *contract.GamePlayResponse { - if model == nil { - return nil - } - - var game *contract.GameResponse - if model.Game != nil { - game = GameModelToResponse(model.Game) - } - - var customer *contract.CustomerResponse - if model.Customer != nil { - customer = CustomerModelToResponse(model.Customer) - } - - var prize *contract.GamePrizeResponse - if model.Prize != nil { - prize = GamePrizeModelToResponse(model.Prize) - } - - return &contract.GamePlayResponse{ - ID: model.ID, - GameID: model.GameID, - CustomerID: model.CustomerID, - PrizeID: model.PrizeID, - CoinsUsed: model.CoinsUsed, - RandomSeed: model.RandomSeed, - CreatedAt: model.CreatedAt, - Game: game, - Customer: customer, - Prize: prize, - } -} - -func PlayGameModelToResponse(model *models.PlayGameResponse) *contract.PlayGameResponse { - if model == nil { - return nil - } - - var gamePlay *contract.GamePlayResponse - if &model.GamePlay != nil { - gamePlay = GamePlayModelToResponse(&model.GamePlay) - } - - var prizeWon *contract.GamePrizeResponse - if model.PrizeWon != nil { - prizeWon = GamePrizeModelToResponse(model.PrizeWon) - } - - var gamePlayValue contract.GamePlayResponse - if gamePlay != nil { - gamePlayValue = *gamePlay - } - - return &contract.PlayGameResponse{ - GamePlay: gamePlayValue, - PrizeWon: prizeWon, - CoinsRemaining: model.CoinsRemaining, - } -} - -func PaginatedGamePlaysResponseToContract(model *models.PaginatedResponse[models.GamePlayResponse]) *contract.PaginatedGamePlaysResponse { - responses := make([]contract.GamePlayResponse, len(model.Data)) - for i, item := range model.Data { - responses[i] = *GamePlayModelToResponse(&item) - } - - return &contract.PaginatedGamePlaysResponse{ - Data: responses, - TotalCount: int(model.Pagination.Total), - Page: model.Pagination.Page, - Limit: model.Pagination.Limit, - TotalPages: model.Pagination.TotalPages, - } -} - // Omset Tracker Transformers func CreateOmsetTrackerRequestToModel(req *contract.CreateOmsetTrackerRequest) *models.CreateOmsetTrackerRequest { return &models.CreateOmsetTrackerRequest{ @@ -366,11 +98,6 @@ func OmsetTrackerModelToResponse(model *models.OmsetTrackerResponse) *contract.O return nil } - var game *contract.GameResponse - if model.Game != nil { - game = GameModelToResponse(model.Game) - } - return &contract.OmsetTrackerResponse{ ID: model.ID, PeriodType: model.PeriodType, @@ -378,7 +105,6 @@ func OmsetTrackerModelToResponse(model *models.OmsetTrackerResponse) *contract.O PeriodEnd: model.PeriodEnd, Total: model.Total, GameID: model.GameID, - Game: game, CreatedAt: model.CreatedAt, UpdatedAt: model.UpdatedAt, } diff --git a/internal/validator/gamification_validator.go b/internal/validator/gamification_validator.go index e0fc023..b5c8493 100644 --- a/internal/validator/gamification_validator.go +++ b/internal/validator/gamification_validator.go @@ -14,21 +14,6 @@ type GamificationValidator interface { ValidateUpdateTierRequest(req *contract.UpdateTierRequest) (error, string) ValidateListTiersRequest(req *contract.ListTiersRequest) (error, string) - // Games - ValidateCreateGameRequest(req *contract.CreateGameRequest) (error, string) - ValidateUpdateGameRequest(req *contract.UpdateGameRequest) (error, string) - ValidateListGamesRequest(req *contract.ListGamesRequest) (error, string) - - // Game Prizes - ValidateCreateGamePrizeRequest(req *contract.CreateGamePrizeRequest) (error, string) - ValidateUpdateGamePrizeRequest(req *contract.UpdateGamePrizeRequest) (error, string) - ValidateListGamePrizesRequest(req *contract.ListGamePrizesRequest) (error, string) - - // Game Plays - ValidateCreateGamePlayRequest(req *contract.CreateGamePlayRequest) (error, string) - ValidateListGamePlaysRequest(req *contract.ListGamePlaysRequest) (error, string) - ValidatePlayGameRequest(req *contract.PlayGameRequest) (error, string) - // Omset Tracker ValidateCreateOmsetTrackerRequest(req *contract.CreateOmsetTrackerRequest) (error, string) ValidateUpdateOmsetTrackerRequest(req *contract.UpdateOmsetTrackerRequest) (error, string) @@ -109,206 +94,6 @@ func (v *GamificationValidatorImpl) ValidateListTiersRequest(req *contract.ListT return nil, "" } -// Game Validators -func (v *GamificationValidatorImpl) ValidateCreateGameRequest(req *contract.CreateGameRequest) (error, string) { - if err := v.validate.Struct(req); err != nil { - return err, "VALIDATION_ERROR" - } - - if req.Name != "" { - req.Name = strings.TrimSpace(req.Name) - if req.Name == "" { - return errors.New("name cannot be empty or whitespace only"), "INVALID_NAME" - } - if len(req.Name) > 255 { - return errors.New("name cannot exceed 255 characters"), "INVALID_NAME" - } - } - - validGameTypes := []string{"SPIN", "RAFFLE", "MINIGAME"} - if !contains(validGameTypes, req.Type) { - return errors.New("invalid game type"), "INVALID_GAME_TYPE" - } - - return nil, "" -} - -func (v *GamificationValidatorImpl) ValidateUpdateGameRequest(req *contract.UpdateGameRequest) (error, string) { - if err := v.validate.Struct(req); err != nil { - return err, "VALIDATION_ERROR" - } - - if req.Name != nil && *req.Name != "" { - *req.Name = strings.TrimSpace(*req.Name) - if *req.Name == "" { - return errors.New("name cannot be empty or whitespace only"), "INVALID_NAME" - } - if len(*req.Name) > 255 { - return errors.New("name cannot exceed 255 characters"), "INVALID_NAME" - } - } - - if req.Type != nil { - validGameTypes := []string{"SPIN", "RAFFLE", "MINIGAME"} - if !contains(validGameTypes, *req.Type) { - return errors.New("invalid game type"), "INVALID_GAME_TYPE" - } - } - - return nil, "" -} - -func (v *GamificationValidatorImpl) ValidateListGamesRequest(req *contract.ListGamesRequest) (error, string) { - if err := v.validate.Struct(req); err != nil { - return err, "VALIDATION_ERROR" - } - - if req.Page <= 0 { - req.Page = 1 - } - if req.Limit <= 0 { - req.Limit = 10 - } - if req.Limit > 100 { - req.Limit = 100 - } - - if req.Type != "" { - validGameTypes := []string{"SPIN", "RAFFLE", "MINIGAME"} - if !contains(validGameTypes, req.Type) { - return errors.New("invalid game type"), "INVALID_GAME_TYPE" - } - } - - return nil, "" -} - -// Game Prize Validators -func (v *GamificationValidatorImpl) ValidateCreateGamePrizeRequest(req *contract.CreateGamePrizeRequest) (error, string) { - if err := v.validate.Struct(req); err != nil { - return err, "VALIDATION_ERROR" - } - - if req.Name != "" { - req.Name = strings.TrimSpace(req.Name) - if req.Name == "" { - return errors.New("name cannot be empty or whitespace only"), "INVALID_NAME" - } - if len(req.Name) > 255 { - return errors.New("name cannot exceed 255 characters"), "INVALID_NAME" - } - } - - if req.Weight <= 0 { - return errors.New("weight must be greater than 0"), "INVALID_WEIGHT" - } - - if req.Stock < 0 { - return errors.New("stock cannot be negative"), "INVALID_STOCK" - } - - if req.MaxStock != nil && *req.MaxStock <= 0 { - return errors.New("max stock must be greater than 0"), "INVALID_MAX_STOCK" - } - - if req.Threshold != nil && *req.Threshold < 0 { - return errors.New("threshold cannot be negative"), "INVALID_THRESHOLD" - } - - return nil, "" -} - -func (v *GamificationValidatorImpl) ValidateUpdateGamePrizeRequest(req *contract.UpdateGamePrizeRequest) (error, string) { - if err := v.validate.Struct(req); err != nil { - return err, "VALIDATION_ERROR" - } - - if req.Name != nil && *req.Name != "" { - *req.Name = strings.TrimSpace(*req.Name) - if *req.Name == "" { - return errors.New("name cannot be empty or whitespace only"), "INVALID_NAME" - } - if len(*req.Name) > 255 { - return errors.New("name cannot exceed 255 characters"), "INVALID_NAME" - } - } - - if req.Weight != nil && *req.Weight <= 0 { - return errors.New("weight must be greater than 0"), "INVALID_WEIGHT" - } - - if req.Stock != nil && *req.Stock < 0 { - return errors.New("stock cannot be negative"), "INVALID_STOCK" - } - - if req.MaxStock != nil && *req.MaxStock <= 0 { - return errors.New("max stock must be greater than 0"), "INVALID_MAX_STOCK" - } - - if req.Threshold != nil && *req.Threshold < 0 { - return errors.New("threshold cannot be negative"), "INVALID_THRESHOLD" - } - - return nil, "" -} - -func (v *GamificationValidatorImpl) ValidateListGamePrizesRequest(req *contract.ListGamePrizesRequest) (error, string) { - if err := v.validate.Struct(req); err != nil { - return err, "VALIDATION_ERROR" - } - - if req.Page <= 0 { - req.Page = 1 - } - if req.Limit <= 0 { - req.Limit = 10 - } - if req.Limit > 100 { - req.Limit = 100 - } - - return nil, "" -} - -// Game Play Validators -func (v *GamificationValidatorImpl) ValidateCreateGamePlayRequest(req *contract.CreateGamePlayRequest) (error, string) { - if err := v.validate.Struct(req); err != nil { - return err, "VALIDATION_ERROR" - } - - if req.CoinsUsed < 0 { - return errors.New("coins used cannot be negative"), "INVALID_COINS_USED" - } - - return nil, "" -} - -func (v *GamificationValidatorImpl) ValidateListGamePlaysRequest(req *contract.ListGamePlaysRequest) (error, string) { - if err := v.validate.Struct(req); err != nil { - return err, "VALIDATION_ERROR" - } - - if req.Page <= 0 { - req.Page = 1 - } - if req.Limit <= 0 { - req.Limit = 10 - } - if req.Limit > 100 { - req.Limit = 100 - } - - return nil, "" -} - -func (v *GamificationValidatorImpl) ValidatePlayGameRequest(req *contract.PlayGameRequest) (error, string) { - if err := v.validate.Struct(req); err != nil { - return err, "VALIDATION_ERROR" - } - - return nil, "" -} - // Omset Tracker Validators func (v *GamificationValidatorImpl) ValidateCreateOmsetTrackerRequest(req *contract.CreateOmsetTrackerRequest) (error, string) { if err := v.validate.Struct(req); err != nil { diff --git a/internal/validator/reward_validator.go b/internal/validator/reward_validator.go deleted file mode 100644 index cb23cdd..0000000 --- a/internal/validator/reward_validator.go +++ /dev/null @@ -1,263 +0,0 @@ -package validator - -import ( - "errors" - "regexp" - "strings" - - "apskel-pos-be/internal/constants" - "apskel-pos-be/internal/contract" -) - -type RewardValidator interface { - ValidateCreateRewardRequest(req *contract.CreateRewardRequest) (error, string) - ValidateUpdateRewardRequest(req *contract.UpdateRewardRequest) (error, string) - ValidateListRewardsRequest(req *contract.ListRewardsRequest) (error, string) - ValidateGetRewardRequest(req *contract.GetRewardRequest) (error, string) - ValidateDeleteRewardRequest(req *contract.DeleteRewardRequest) (error, string) -} - -type RewardValidatorImpl struct{} - -func NewRewardValidator() RewardValidator { - return &RewardValidatorImpl{} -} - -func (v *RewardValidatorImpl) ValidateCreateRewardRequest(req *contract.CreateRewardRequest) (error, string) { - if req == nil { - return errors.New("request is required"), constants.ValidationErrorCode - } - - // Validate name - if strings.TrimSpace(req.Name) == "" { - return errors.New("reward name is required"), constants.ValidationErrorCode - } - if len(req.Name) > 150 { - return errors.New("reward name cannot exceed 150 characters"), constants.ValidationErrorCode - } - - // Validate reward type - if !v.isValidRewardType(req.RewardType) { - return errors.New("invalid reward type. Valid types are: VOUCHER, PHYSICAL, DIGITAL, BALANCE"), constants.ValidationErrorCode - } - - // Validate cost points - if req.CostPoints <= 0 { - return errors.New("cost points must be greater than 0"), constants.ValidationErrorCode - } - - // Validate stock - if req.Stock != nil && *req.Stock < 0 { - return errors.New("stock cannot be negative"), constants.ValidationErrorCode - } - - // Validate max per customer - if req.MaxPerCustomer <= 0 { - return errors.New("max per customer must be greater than 0"), constants.ValidationErrorCode - } - - // Validate TNC if provided - if req.Tnc != nil { - if err := v.validateTermsAndConditions(req.Tnc); err != nil { - return err, constants.ValidationErrorCode - } - } - - // Validate images if provided - if req.Images != nil { - if err := v.validateImages(*req.Images); err != nil { - return err, constants.ValidationErrorCode - } - } - - return nil, "" -} - -func (v *RewardValidatorImpl) ValidateUpdateRewardRequest(req *contract.UpdateRewardRequest) (error, string) { - if req == nil { - return errors.New("request is required"), constants.ValidationErrorCode - } - - // Validate ID - if req.ID.String() == "" { - return errors.New("reward ID is required"), constants.ValidationErrorCode - } - - // Validate name - if strings.TrimSpace(req.Name) == "" { - return errors.New("reward name is required"), constants.ValidationErrorCode - } - if len(req.Name) > 150 { - return errors.New("reward name cannot exceed 150 characters"), constants.ValidationErrorCode - } - - // Validate reward type - if !v.isValidRewardType(req.RewardType) { - return errors.New("invalid reward type. Valid types are: VOUCHER, PHYSICAL, DIGITAL, BALANCE"), constants.ValidationErrorCode - } - - // Validate cost points - if req.CostPoints <= 0 { - return errors.New("cost points must be greater than 0"), constants.ValidationErrorCode - } - - // Validate stock - if req.Stock != nil && *req.Stock < 0 { - return errors.New("stock cannot be negative"), constants.ValidationErrorCode - } - - // Validate max per customer - if req.MaxPerCustomer <= 0 { - return errors.New("max per customer must be greater than 0"), constants.ValidationErrorCode - } - - // Validate TNC if provided - if req.Tnc != nil { - if err := v.validateTermsAndConditions(req.Tnc); err != nil { - return err, constants.ValidationErrorCode - } - } - - // Validate images if provided - if req.Images != nil { - if err := v.validateImages(*req.Images); err != nil { - return err, constants.ValidationErrorCode - } - } - - return nil, "" -} - -func (v *RewardValidatorImpl) ValidateListRewardsRequest(req *contract.ListRewardsRequest) (error, string) { - if req == nil { - return errors.New("request is required"), constants.ValidationErrorCode - } - - // Validate pagination - if req.Page < 1 { - return errors.New("page must be greater than 0"), constants.ValidationErrorCode - } - if req.Limit < 1 { - return errors.New("limit must be greater than 0"), constants.ValidationErrorCode - } - if req.Limit > 100 { - return errors.New("limit cannot exceed 100"), constants.ValidationErrorCode - } - - // Validate reward type filter if provided - if req.RewardType != "" && !v.isValidRewardType(req.RewardType) { - return errors.New("invalid reward type filter. Valid types are: VOUCHER, PHYSICAL, DIGITAL, BALANCE"), constants.ValidationErrorCode - } - - // Validate points range if provided - if req.MinPoints != nil && req.MaxPoints != nil { - if *req.MinPoints > *req.MaxPoints { - return errors.New("min points cannot be greater than max points"), constants.ValidationErrorCode - } - if *req.MinPoints < 0 { - return errors.New("min points cannot be negative"), constants.ValidationErrorCode - } - if *req.MaxPoints < 0 { - return errors.New("max points cannot be negative"), constants.ValidationErrorCode - } - } - - return nil, "" -} - -func (v *RewardValidatorImpl) ValidateGetRewardRequest(req *contract.GetRewardRequest) (error, string) { - if req == nil { - return errors.New("request is required"), constants.ValidationErrorCode - } - - if req.ID.String() == "" { - return errors.New("reward ID is required"), constants.ValidationErrorCode - } - - return nil, "" -} - -func (v *RewardValidatorImpl) ValidateDeleteRewardRequest(req *contract.DeleteRewardRequest) (error, string) { - if req == nil { - return errors.New("request is required"), constants.ValidationErrorCode - } - - if req.ID.String() == "" { - return errors.New("reward ID is required"), constants.ValidationErrorCode - } - - return nil, "" -} - -func (v *RewardValidatorImpl) isValidRewardType(rewardType string) bool { - validTypes := []string{"VOUCHER", "PHYSICAL", "DIGITAL", "BALANCE"} - return contains(validTypes, rewardType) -} - -func (v *RewardValidatorImpl) validateTermsAndConditions(tnc *contract.TermsAndConditionsStruct) error { - if tnc == nil { - return nil - } - - // Validate expiry days - if tnc.ExpiryDays < 0 { - return errors.New("expiry days cannot be negative") - } - - // Validate sections - if len(tnc.Sections) == 0 { - return errors.New("terms and conditions must have at least one section") - } - - for i, section := range tnc.Sections { - if strings.TrimSpace(section.Title) == "" { - return errors.New("section title is required") - } - if len(section.Rules) == 0 { - return errors.New("section must have at least one rule") - } - for j, rule := range section.Rules { - if strings.TrimSpace(rule) == "" { - return errors.New("rule cannot be empty") - } - if len(rule) > 500 { - return errors.New("rule cannot exceed 500 characters") - } - // Validate rule content (basic validation) - if len(rule) < 10 { - return errors.New("rule must be at least 10 characters long") - } - _ = j // Avoid unused variable warning - } - _ = i // Avoid unused variable warning - } - - return nil -} - -func (v *RewardValidatorImpl) validateImages(images []string) error { - if len(images) == 0 { - return errors.New("at least one image is required") - } - - for i, imgURL := range images { - if strings.TrimSpace(imgURL) == "" { - return errors.New("image URL is required") - } - - // Validate URL format (basic validation) - if !v.isValidURL(imgURL) { - return errors.New("invalid image URL format") - } - - _ = i // Avoid unused variable warning - } - - return nil -} - -func (v *RewardValidatorImpl) isValidURL(url string) bool { - // Basic URL validation regex - urlRegex := regexp.MustCompile(`^https?://[^\s/$.?#].[^\s]*$`) - return urlRegex.MatchString(url) -} diff --git a/migrations/000115_drop_games_is_active.down.sql b/migrations/000115_drop_games_is_active.down.sql new file mode 100644 index 0000000..7d4685c --- /dev/null +++ b/migrations/000115_drop_games_is_active.down.sql @@ -0,0 +1,3 @@ +ALTER TABLE games ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT TRUE; +UPDATE games SET is_active = (status = 'ACTIVE'); +CREATE INDEX IF NOT EXISTS idx_games_is_active ON games(is_active); diff --git a/migrations/000115_drop_games_is_active.up.sql b/migrations/000115_drop_games_is_active.up.sql new file mode 100644 index 0000000..e85315a --- /dev/null +++ b/migrations/000115_drop_games_is_active.up.sql @@ -0,0 +1,4 @@ +-- games.status replaced is_active (docs/rfc-enakgame.md §5.1, EG-1003). Nothing reads +-- it since the old game flow was removed (EG-1002). Its index goes with it. +-- metadata stays: the old games' coin_cost was copied into entry_cost by 000103. +ALTER TABLE games DROP COLUMN IF EXISTS is_active;