- ChipSetService with full CRUD, duplication, builtin protection - BlindStructure service with level validation and CRUD - PayoutStructure service with bracket/tier nesting and 100% sum validation - BuyinConfig service with rake split validation and all rebuy/addon fields - TournamentTemplate service with FK validation and expanded view - WizardService generates blind structures from high-level inputs - API routes: /chip-sets, /blind-structures, /payout-structures, /buyin-configs, /tournament-templates - All mutations require admin role, reads require floor+ - Wired template routes into server protected group Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
342 lines
13 KiB
Go
342 lines
13 KiB
Go
package template
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
|
|
"github.com/felt-app/felt/internal/blind"
|
|
)
|
|
|
|
// TournamentTemplate composes building blocks into a reusable tournament configuration.
|
|
type TournamentTemplate struct {
|
|
ID int64 `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
ChipSetID int64 `json:"chip_set_id"`
|
|
BlindStructureID int64 `json:"blind_structure_id"`
|
|
PayoutStructureID int64 `json:"payout_structure_id"`
|
|
BuyinConfigID int64 `json:"buyin_config_id"`
|
|
PointsFormulaID *int64 `json:"points_formula_id,omitempty"`
|
|
MinPlayers int `json:"min_players"`
|
|
MaxPlayers *int `json:"max_players,omitempty"`
|
|
EarlySignupBonusChips int64 `json:"early_signup_bonus_chips"`
|
|
EarlySignupCutoff *string `json:"early_signup_cutoff,omitempty"`
|
|
PunctualityBonusChips int64 `json:"punctuality_bonus_chips"`
|
|
IsPKO bool `json:"is_pko"`
|
|
IsBuiltin bool `json:"is_builtin"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
|
|
// Summary fields populated by GetTemplate (names only, not full data)
|
|
ChipSetName string `json:"chip_set_name,omitempty"`
|
|
BlindStructureName string `json:"blind_structure_name,omitempty"`
|
|
PayoutStructureName string `json:"payout_structure_name,omitempty"`
|
|
BuyinConfigName string `json:"buyin_config_name,omitempty"`
|
|
}
|
|
|
|
// ExpandedTemplate returns a template with ALL building block data populated.
|
|
type ExpandedTemplate struct {
|
|
TournamentTemplate
|
|
ChipSet *ChipSet `json:"chip_set"`
|
|
BlindStructure *blind.BlindStructure `json:"blind_structure"`
|
|
PayoutStructure *PayoutStructure `json:"payout_structure"`
|
|
BuyinConfig *BuyinConfig `json:"buyin_config"`
|
|
}
|
|
|
|
// TournamentTemplateService provides CRUD operations for tournament templates.
|
|
type TournamentTemplateService struct {
|
|
db *sql.DB
|
|
chips *ChipSetService
|
|
blinds *blind.StructureService
|
|
payouts *PayoutService
|
|
buyins *BuyinService
|
|
}
|
|
|
|
// NewTournamentTemplateService creates a new TournamentTemplateService.
|
|
func NewTournamentTemplateService(db *sql.DB) *TournamentTemplateService {
|
|
return &TournamentTemplateService{
|
|
db: db,
|
|
chips: NewChipSetService(db),
|
|
blinds: blind.NewStructureService(db),
|
|
payouts: NewPayoutService(db),
|
|
buyins: NewBuyinService(db),
|
|
}
|
|
}
|
|
|
|
// CreateTemplate creates a new tournament template, validating all FK references exist.
|
|
func (s *TournamentTemplateService) CreateTemplate(ctx context.Context, tmpl *TournamentTemplate) (*TournamentTemplate, error) {
|
|
if tmpl.Name == "" {
|
|
return nil, fmt.Errorf("template name is required")
|
|
}
|
|
|
|
// Validate FK references exist
|
|
if err := s.validateReferences(ctx, tmpl); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
now := time.Now().Unix()
|
|
isPKO := boolToInt(tmpl.IsPKO)
|
|
|
|
res, err := s.db.ExecContext(ctx,
|
|
`INSERT INTO tournament_templates (name, description, chip_set_id, blind_structure_id, payout_structure_id, buyin_config_id,
|
|
points_formula_id, min_players, max_players, early_signup_bonus_chips, early_signup_cutoff,
|
|
punctuality_bonus_chips, is_pko, is_builtin, created_at, updated_at)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`,
|
|
tmpl.Name, tmpl.Description, tmpl.ChipSetID, tmpl.BlindStructureID, tmpl.PayoutStructureID, tmpl.BuyinConfigID,
|
|
tmpl.PointsFormulaID, tmpl.MinPlayers, tmpl.MaxPlayers, tmpl.EarlySignupBonusChips, tmpl.EarlySignupCutoff,
|
|
tmpl.PunctualityBonusChips, isPKO, now, now,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("insert template: %w", err)
|
|
}
|
|
id, err := res.LastInsertId()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get template id: %w", err)
|
|
}
|
|
|
|
return s.GetTemplate(ctx, id)
|
|
}
|
|
|
|
// validateReferences checks that all FK references in a template point to existing entities.
|
|
func (s *TournamentTemplateService) validateReferences(ctx context.Context, tmpl *TournamentTemplate) error {
|
|
// Check chip set exists
|
|
var exists int
|
|
if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM chip_sets WHERE id = ?", tmpl.ChipSetID).Scan(&exists); err != nil || exists == 0 {
|
|
return fmt.Errorf("chip set %d does not exist", tmpl.ChipSetID)
|
|
}
|
|
if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM blind_structures WHERE id = ?", tmpl.BlindStructureID).Scan(&exists); err != nil || exists == 0 {
|
|
return fmt.Errorf("blind structure %d does not exist", tmpl.BlindStructureID)
|
|
}
|
|
if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM payout_structures WHERE id = ?", tmpl.PayoutStructureID).Scan(&exists); err != nil || exists == 0 {
|
|
return fmt.Errorf("payout structure %d does not exist", tmpl.PayoutStructureID)
|
|
}
|
|
if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM buyin_configs WHERE id = ?", tmpl.BuyinConfigID).Scan(&exists); err != nil || exists == 0 {
|
|
return fmt.Errorf("buyin config %d does not exist", tmpl.BuyinConfigID)
|
|
}
|
|
if tmpl.PointsFormulaID != nil {
|
|
if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM points_formulas WHERE id = ?", *tmpl.PointsFormulaID).Scan(&exists); err != nil || exists == 0 {
|
|
return fmt.Errorf("points formula %d does not exist", *tmpl.PointsFormulaID)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetTemplate retrieves a template by ID with building block summary names.
|
|
func (s *TournamentTemplateService) GetTemplate(ctx context.Context, id int64) (*TournamentTemplate, error) {
|
|
tmpl := &TournamentTemplate{}
|
|
var createdAt, updatedAt int64
|
|
var isPKO, isBuiltin int
|
|
|
|
err := s.db.QueryRowContext(ctx,
|
|
`SELECT t.id, t.name, t.description, t.chip_set_id, t.blind_structure_id, t.payout_structure_id, t.buyin_config_id,
|
|
t.points_formula_id, t.min_players, t.max_players, t.early_signup_bonus_chips, t.early_signup_cutoff,
|
|
t.punctuality_bonus_chips, t.is_pko, t.is_builtin, t.created_at, t.updated_at,
|
|
cs.name, bs.name, ps.name, bc.name
|
|
FROM tournament_templates t
|
|
JOIN chip_sets cs ON t.chip_set_id = cs.id
|
|
JOIN blind_structures bs ON t.blind_structure_id = bs.id
|
|
JOIN payout_structures ps ON t.payout_structure_id = ps.id
|
|
JOIN buyin_configs bc ON t.buyin_config_id = bc.id
|
|
WHERE t.id = ?`, id,
|
|
).Scan(
|
|
&tmpl.ID, &tmpl.Name, &tmpl.Description, &tmpl.ChipSetID, &tmpl.BlindStructureID, &tmpl.PayoutStructureID, &tmpl.BuyinConfigID,
|
|
&tmpl.PointsFormulaID, &tmpl.MinPlayers, &tmpl.MaxPlayers, &tmpl.EarlySignupBonusChips, &tmpl.EarlySignupCutoff,
|
|
&tmpl.PunctualityBonusChips, &isPKO, &isBuiltin, &createdAt, &updatedAt,
|
|
&tmpl.ChipSetName, &tmpl.BlindStructureName, &tmpl.PayoutStructureName, &tmpl.BuyinConfigName,
|
|
)
|
|
if err == sql.ErrNoRows {
|
|
return nil, fmt.Errorf("tournament template not found: %d", id)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get template: %w", err)
|
|
}
|
|
tmpl.IsPKO = isPKO != 0
|
|
tmpl.IsBuiltin = isBuiltin != 0
|
|
tmpl.CreatedAt = time.Unix(createdAt, 0)
|
|
tmpl.UpdatedAt = time.Unix(updatedAt, 0)
|
|
|
|
return tmpl, nil
|
|
}
|
|
|
|
// GetTemplateExpanded retrieves a template with ALL building block data populated.
|
|
func (s *TournamentTemplateService) GetTemplateExpanded(ctx context.Context, id int64) (*ExpandedTemplate, error) {
|
|
tmpl, err := s.GetTemplate(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
expanded := &ExpandedTemplate{TournamentTemplate: *tmpl}
|
|
|
|
expanded.ChipSet, err = s.chips.GetChipSet(ctx, tmpl.ChipSetID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("expand chip set: %w", err)
|
|
}
|
|
|
|
expanded.BlindStructure, err = s.blinds.GetStructure(ctx, tmpl.BlindStructureID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("expand blind structure: %w", err)
|
|
}
|
|
|
|
expanded.PayoutStructure, err = s.payouts.GetPayoutStructure(ctx, tmpl.PayoutStructureID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("expand payout structure: %w", err)
|
|
}
|
|
|
|
expanded.BuyinConfig, err = s.buyins.GetBuyinConfig(ctx, tmpl.BuyinConfigID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("expand buyin config: %w", err)
|
|
}
|
|
|
|
return expanded, nil
|
|
}
|
|
|
|
// ListTemplates returns all tournament templates with building block summary names.
|
|
func (s *TournamentTemplateService) ListTemplates(ctx context.Context) ([]TournamentTemplate, error) {
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT t.id, t.name, t.description, t.chip_set_id, t.blind_structure_id, t.payout_structure_id, t.buyin_config_id,
|
|
t.points_formula_id, t.min_players, t.max_players, t.early_signup_bonus_chips, t.early_signup_cutoff,
|
|
t.punctuality_bonus_chips, t.is_pko, t.is_builtin, t.created_at, t.updated_at,
|
|
cs.name, bs.name, ps.name, bc.name
|
|
FROM tournament_templates t
|
|
JOIN chip_sets cs ON t.chip_set_id = cs.id
|
|
JOIN blind_structures bs ON t.blind_structure_id = bs.id
|
|
JOIN payout_structures ps ON t.payout_structure_id = ps.id
|
|
JOIN buyin_configs bc ON t.buyin_config_id = bc.id
|
|
ORDER BY t.is_builtin DESC, t.name`,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("list templates: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var templates []TournamentTemplate
|
|
for rows.Next() {
|
|
var tmpl TournamentTemplate
|
|
var createdAt, updatedAt int64
|
|
var isPKO, isBuiltin int
|
|
if err := rows.Scan(
|
|
&tmpl.ID, &tmpl.Name, &tmpl.Description, &tmpl.ChipSetID, &tmpl.BlindStructureID, &tmpl.PayoutStructureID, &tmpl.BuyinConfigID,
|
|
&tmpl.PointsFormulaID, &tmpl.MinPlayers, &tmpl.MaxPlayers, &tmpl.EarlySignupBonusChips, &tmpl.EarlySignupCutoff,
|
|
&tmpl.PunctualityBonusChips, &isPKO, &isBuiltin, &createdAt, &updatedAt,
|
|
&tmpl.ChipSetName, &tmpl.BlindStructureName, &tmpl.PayoutStructureName, &tmpl.BuyinConfigName,
|
|
); err != nil {
|
|
return nil, fmt.Errorf("scan template: %w", err)
|
|
}
|
|
tmpl.IsPKO = isPKO != 0
|
|
tmpl.IsBuiltin = isBuiltin != 0
|
|
tmpl.CreatedAt = time.Unix(createdAt, 0)
|
|
tmpl.UpdatedAt = time.Unix(updatedAt, 0)
|
|
templates = append(templates, tmpl)
|
|
}
|
|
return templates, rows.Err()
|
|
}
|
|
|
|
// UpdateTemplate updates a tournament template.
|
|
func (s *TournamentTemplateService) UpdateTemplate(ctx context.Context, tmpl *TournamentTemplate) error {
|
|
if tmpl.Name == "" {
|
|
return fmt.Errorf("template name is required")
|
|
}
|
|
if err := s.validateReferences(ctx, tmpl); err != nil {
|
|
return err
|
|
}
|
|
|
|
now := time.Now().Unix()
|
|
isPKO := boolToInt(tmpl.IsPKO)
|
|
|
|
res, err := s.db.ExecContext(ctx,
|
|
`UPDATE tournament_templates SET name = ?, description = ?, chip_set_id = ?, blind_structure_id = ?,
|
|
payout_structure_id = ?, buyin_config_id = ?, points_formula_id = ?, min_players = ?, max_players = ?,
|
|
early_signup_bonus_chips = ?, early_signup_cutoff = ?, punctuality_bonus_chips = ?, is_pko = ?,
|
|
updated_at = ?
|
|
WHERE id = ?`,
|
|
tmpl.Name, tmpl.Description, tmpl.ChipSetID, tmpl.BlindStructureID,
|
|
tmpl.PayoutStructureID, tmpl.BuyinConfigID, tmpl.PointsFormulaID, tmpl.MinPlayers, tmpl.MaxPlayers,
|
|
tmpl.EarlySignupBonusChips, tmpl.EarlySignupCutoff, tmpl.PunctualityBonusChips, isPKO,
|
|
now, tmpl.ID,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("update template: %w", err)
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("tournament template not found: %d", tmpl.ID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DeleteTemplate deletes a tournament template. Built-in templates cannot be deleted.
|
|
func (s *TournamentTemplateService) DeleteTemplate(ctx context.Context, id int64) error {
|
|
var isBuiltin int
|
|
err := s.db.QueryRowContext(ctx, "SELECT is_builtin FROM tournament_templates WHERE id = ?", id).Scan(&isBuiltin)
|
|
if err == sql.ErrNoRows {
|
|
return fmt.Errorf("tournament template not found: %d", id)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("check template: %w", err)
|
|
}
|
|
if isBuiltin != 0 {
|
|
return fmt.Errorf("cannot delete built-in tournament template")
|
|
}
|
|
|
|
res, err := s.db.ExecContext(ctx, "DELETE FROM tournament_templates WHERE id = ?", id)
|
|
if err != nil {
|
|
return fmt.Errorf("delete template: %w", err)
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
if n == 0 {
|
|
return fmt.Errorf("tournament template not found: %d", id)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DuplicateTemplate creates an independent copy of a tournament template.
|
|
func (s *TournamentTemplateService) DuplicateTemplate(ctx context.Context, id int64, newName string) (*TournamentTemplate, error) {
|
|
original, err := s.GetTemplate(ctx, id)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get original: %w", err)
|
|
}
|
|
|
|
if newName == "" {
|
|
newName = original.Name + " (Copy)"
|
|
}
|
|
|
|
copy := *original
|
|
copy.ID = 0
|
|
copy.Name = newName
|
|
copy.IsBuiltin = false
|
|
return s.CreateTemplate(ctx, ©)
|
|
}
|
|
|
|
// SaveAsTemplate creates a new template from a tournament's current config.
|
|
func (s *TournamentTemplateService) SaveAsTemplate(ctx context.Context, tournamentID string, name string) (*TournamentTemplate, error) {
|
|
if name == "" {
|
|
return nil, fmt.Errorf("template name is required")
|
|
}
|
|
|
|
var tmpl TournamentTemplate
|
|
var isPKO int
|
|
|
|
err := s.db.QueryRowContext(ctx,
|
|
`SELECT chip_set_id, blind_structure_id, payout_structure_id, buyin_config_id,
|
|
points_formula_id, min_players, max_players, early_signup_bonus_chips, early_signup_cutoff,
|
|
punctuality_bonus_chips, is_pko
|
|
FROM tournaments WHERE id = ?`, tournamentID,
|
|
).Scan(
|
|
&tmpl.ChipSetID, &tmpl.BlindStructureID, &tmpl.PayoutStructureID, &tmpl.BuyinConfigID,
|
|
&tmpl.PointsFormulaID, &tmpl.MinPlayers, &tmpl.MaxPlayers, &tmpl.EarlySignupBonusChips, &tmpl.EarlySignupCutoff,
|
|
&tmpl.PunctualityBonusChips, &isPKO,
|
|
)
|
|
if err == sql.ErrNoRows {
|
|
return nil, fmt.Errorf("tournament not found: %s", tournamentID)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("get tournament config: %w", err)
|
|
}
|
|
|
|
tmpl.Name = name
|
|
tmpl.IsPKO = isPKO != 0
|
|
|
|
return s.CreateTemplate(ctx, &tmpl)
|
|
}
|