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:
co-authored by
Claude Opus 5.5
parent
296708244e
commit
18e87398bb
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user