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
-58
View File
@@ -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
}
}
-54
View File
@@ -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,
}
}
-82
View File
@@ -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
}
}
-1
View File
@@ -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,
}
-138
View File
@@ -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
}
-80
View File
@@ -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,
}
}