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
-3
View File
@@ -28,10 +28,7 @@ func GetAllEntities() []interface{} {
&CustomerTokens{},
&Tier{},
&Game{},
&GamePrize{},
&GamePlay{},
&OmsetTracker{},
&Reward{},
&Campaign{},
&CampaignRule{},
&OtpSession{},
+6 -8
View File
@@ -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 {
-33
View File
@@ -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"
}
-38
View File
@@ -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"
}
-126
View File
@@ -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"`
}
+38
View File
@@ -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)
}