feat(enakgame): spin as an EnakGame game; remove the old game flow

EnakGame phase 10 of docs/tasks-enakgame.md (EG-1001 to EG-1003).

Spin (EG-1001)
- PROBABILITY entries take an optional label (a wheel segment). The customer game
  list shows a PROBABILITY game's prizes (entry, label, amount, never weights), and
  completing returns the drawn prize, so the client can draw the wheel and stop it
  on the server's draw.
- docs/enakgame-spin.md: the admin steps to set up spin per organization (no
  seeder) and the customer app flow. An HTTP test plays it end to end.

Old game flow removed (EG-1002)
- Routes POST /customer/spin, GET /customer/games, GET /customer/ferris-wheel, and
  admin /marketing/games, /marketing/game-prizes, /marketing/rewards, with their
  handlers, services, processors, repositories, validators, models, contracts,
  mappers and tests (GamePlayProcessor, SpinGameService, rewards, ...). This also
  closes RFC §15 findings 1 and 2 (double charge, spinning another org's game).
- Tables games, game_prizes, game_plays and rewards stay for ledger history.
  entities.StringSlice moves to its own file; the omset tracker (unrouted) keeps
  game_id but no longer embeds the old game response.

games.is_active dropped (EG-1003)
- Migration 000115; nothing reads metadata.coin_cost any more.

The EnakPoint integration docs now point at /customer/enakgame. The Postgres tests
were not run: no test database here. Migration 000115 has not been run anywhere.
The customer app must stop calling the removed endpoints before this is deployed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
efrilm
2026-10-07 21:31:56 +07:00
co-authored by Claude Opus 5.5
parent 296708244e
commit 18e87398bb
68 changed files with 467 additions and 5064 deletions
-170
View File
@@ -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
}
@@ -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
}
-112
View File
@@ -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
}
-129
View File
@@ -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
}