felt/internal/template/tournament.go
Mikkel Georgsen 99545bd128 feat(01-05): implement building block CRUD and API routes
- 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>
2026-03-01 03:55:47 +01:00

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, &copy)
}
// 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)
}