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>
This commit is contained in:
parent
47e1f19edd
commit
99545bd128
8 changed files with 2581 additions and 9 deletions
|
|
@ -1 +1,306 @@
|
||||||
|
// Package blind provides blind structure management: CRUD operations,
|
||||||
|
// the structure wizard algorithm, and built-in template definitions.
|
||||||
package blind
|
package blind
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BlindStructure represents a reusable blind level progression.
|
||||||
|
type BlindStructure struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
IsBuiltin bool `json:"is_builtin"`
|
||||||
|
GameTypeDefault string `json:"game_type_default"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
Levels []BlindLevel `json:"levels,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlindLevel represents a single level in a blind structure.
|
||||||
|
type BlindLevel struct {
|
||||||
|
ID int64 `json:"id,omitempty"`
|
||||||
|
StructureID int64 `json:"structure_id,omitempty"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
LevelType string `json:"level_type"`
|
||||||
|
GameType string `json:"game_type"`
|
||||||
|
SmallBlind int64 `json:"small_blind"`
|
||||||
|
BigBlind int64 `json:"big_blind"`
|
||||||
|
Ante int64 `json:"ante"`
|
||||||
|
BBAnte int64 `json:"bb_ante"`
|
||||||
|
DurationSeconds int `json:"duration_seconds"`
|
||||||
|
ChipUpDenominationValue *int64 `json:"chip_up_denomination_value,omitempty"`
|
||||||
|
Notes string `json:"notes"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StructureService provides CRUD operations for blind structures.
|
||||||
|
type StructureService struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStructureService creates a new StructureService.
|
||||||
|
func NewStructureService(db *sql.DB) *StructureService {
|
||||||
|
return &StructureService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateLevels checks that levels meet all requirements.
|
||||||
|
func validateLevels(levels []BlindLevel) error {
|
||||||
|
if len(levels) == 0 {
|
||||||
|
return fmt.Errorf("at least one level is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
hasRound := false
|
||||||
|
for i, l := range levels {
|
||||||
|
if l.Position != i {
|
||||||
|
return fmt.Errorf("positions must be contiguous starting from 0, got %d at index %d", l.Position, i)
|
||||||
|
}
|
||||||
|
if l.DurationSeconds <= 0 {
|
||||||
|
return fmt.Errorf("level %d: duration must be positive", i)
|
||||||
|
}
|
||||||
|
if l.LevelType == "round" {
|
||||||
|
hasRound = true
|
||||||
|
if l.SmallBlind >= l.BigBlind && l.BigBlind > 0 {
|
||||||
|
return fmt.Errorf("level %d: small blind (%d) must be less than big blind (%d)", i, l.SmallBlind, l.BigBlind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if l.LevelType != "round" && l.LevelType != "break" {
|
||||||
|
return fmt.Errorf("level %d: invalid level type %q (must be 'round' or 'break')", i, l.LevelType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasRound {
|
||||||
|
return fmt.Errorf("at least one round level is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateStructure creates a new blind structure with levels.
|
||||||
|
func (s *StructureService) CreateStructure(ctx context.Context, name string, levels []BlindLevel) (*BlindStructure, error) {
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("structure name is required")
|
||||||
|
}
|
||||||
|
if err := validateLevels(levels); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
gameTypeDefault := "nlhe"
|
||||||
|
if len(levels) > 0 && levels[0].GameType != "" {
|
||||||
|
gameTypeDefault = levels[0].GameType
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := tx.ExecContext(ctx,
|
||||||
|
"INSERT INTO blind_structures (name, is_builtin, game_type_default, notes, created_at, updated_at) VALUES (?, 0, ?, '', ?, ?)",
|
||||||
|
name, gameTypeDefault, now, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert structure: %w", err)
|
||||||
|
}
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get structure id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := insertLevels(ctx, tx, id, levels); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, fmt.Errorf("commit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetStructure(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertLevels inserts levels into the blind_levels table within a transaction.
|
||||||
|
func insertLevels(ctx context.Context, tx *sql.Tx, structureID int64, levels []BlindLevel) error {
|
||||||
|
for i, l := range levels {
|
||||||
|
gameType := l.GameType
|
||||||
|
if gameType == "" {
|
||||||
|
gameType = "nlhe"
|
||||||
|
}
|
||||||
|
levelType := l.LevelType
|
||||||
|
if levelType == "" {
|
||||||
|
levelType = "round"
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO blind_levels (structure_id, position, level_type, game_type, small_blind, big_blind, ante, bb_ante, duration_seconds, chip_up_denomination_value, notes)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
structureID, l.Position, levelType, gameType,
|
||||||
|
l.SmallBlind, l.BigBlind, l.Ante, l.BBAnte,
|
||||||
|
l.DurationSeconds, l.ChipUpDenominationValue, l.Notes,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert level %d: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStructure retrieves a blind structure by ID, including levels ordered by position.
|
||||||
|
func (s *StructureService) GetStructure(ctx context.Context, id int64) (*BlindStructure, error) {
|
||||||
|
bs := &BlindStructure{}
|
||||||
|
var isBuiltin int
|
||||||
|
var createdAt, updatedAt int64
|
||||||
|
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
"SELECT id, name, is_builtin, game_type_default, notes, created_at, updated_at FROM blind_structures WHERE id = ?", id,
|
||||||
|
).Scan(&bs.ID, &bs.Name, &isBuiltin, &bs.GameTypeDefault, &bs.Notes, &createdAt, &updatedAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, fmt.Errorf("blind structure not found: %d", id)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get structure: %w", err)
|
||||||
|
}
|
||||||
|
bs.IsBuiltin = isBuiltin != 0
|
||||||
|
bs.CreatedAt = time.Unix(createdAt, 0)
|
||||||
|
bs.UpdatedAt = time.Unix(updatedAt, 0)
|
||||||
|
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
`SELECT id, structure_id, position, level_type, game_type, small_blind, big_blind, ante, bb_ante, duration_seconds, chip_up_denomination_value, notes
|
||||||
|
FROM blind_levels WHERE structure_id = ? ORDER BY position`, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get levels: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var l BlindLevel
|
||||||
|
if err := rows.Scan(&l.ID, &l.StructureID, &l.Position, &l.LevelType, &l.GameType,
|
||||||
|
&l.SmallBlind, &l.BigBlind, &l.Ante, &l.BBAnte,
|
||||||
|
&l.DurationSeconds, &l.ChipUpDenominationValue, &l.Notes); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan level: %w", err)
|
||||||
|
}
|
||||||
|
bs.Levels = append(bs.Levels, l)
|
||||||
|
}
|
||||||
|
return bs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListStructures returns all blind structures (without levels).
|
||||||
|
func (s *StructureService) ListStructures(ctx context.Context) ([]BlindStructure, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
"SELECT id, name, is_builtin, game_type_default, notes, created_at, updated_at FROM blind_structures ORDER BY is_builtin DESC, name",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list structures: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var structs []BlindStructure
|
||||||
|
for rows.Next() {
|
||||||
|
var bs BlindStructure
|
||||||
|
var isBuiltin int
|
||||||
|
var createdAt, updatedAt int64
|
||||||
|
if err := rows.Scan(&bs.ID, &bs.Name, &isBuiltin, &bs.GameTypeDefault, &bs.Notes, &createdAt, &updatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan structure: %w", err)
|
||||||
|
}
|
||||||
|
bs.IsBuiltin = isBuiltin != 0
|
||||||
|
bs.CreatedAt = time.Unix(createdAt, 0)
|
||||||
|
bs.UpdatedAt = time.Unix(updatedAt, 0)
|
||||||
|
structs = append(structs, bs)
|
||||||
|
}
|
||||||
|
return structs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStructure updates a blind structure name and replaces its levels.
|
||||||
|
func (s *StructureService) UpdateStructure(ctx context.Context, id int64, name string, levels []BlindLevel) error {
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("structure name is required")
|
||||||
|
}
|
||||||
|
if err := validateLevels(levels); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
res, err := tx.ExecContext(ctx,
|
||||||
|
"UPDATE blind_structures SET name = ?, updated_at = ? WHERE id = ?",
|
||||||
|
name, now, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update structure: %w", err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return fmt.Errorf("blind structure not found: %d", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace levels
|
||||||
|
if _, err := tx.ExecContext(ctx, "DELETE FROM blind_levels WHERE structure_id = ?", id); err != nil {
|
||||||
|
return fmt.Errorf("delete old levels: %w", err)
|
||||||
|
}
|
||||||
|
if err := insertLevels(ctx, tx, id, levels); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteStructure deletes a blind structure. Built-in structures cannot be deleted.
|
||||||
|
func (s *StructureService) DeleteStructure(ctx context.Context, id int64) error {
|
||||||
|
var isBuiltin int
|
||||||
|
err := s.db.QueryRowContext(ctx, "SELECT is_builtin FROM blind_structures WHERE id = ?", id).Scan(&isBuiltin)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return fmt.Errorf("blind structure not found: %d", id)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check structure: %w", err)
|
||||||
|
}
|
||||||
|
if isBuiltin != 0 {
|
||||||
|
return fmt.Errorf("cannot delete built-in blind structure")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if referenced by active tournaments
|
||||||
|
var refCount int
|
||||||
|
err = s.db.QueryRowContext(ctx,
|
||||||
|
"SELECT COUNT(*) FROM tournaments WHERE blind_structure_id = ? AND status NOT IN ('completed', 'cancelled')", id,
|
||||||
|
).Scan(&refCount)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check references: %w", err)
|
||||||
|
}
|
||||||
|
if refCount > 0 {
|
||||||
|
return fmt.Errorf("blind structure is referenced by %d active tournament(s)", refCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := s.db.ExecContext(ctx, "DELETE FROM blind_structures WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete structure: %w", err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return fmt.Errorf("blind structure not found: %d", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DuplicateStructure creates an independent copy of a blind structure.
|
||||||
|
func (s *StructureService) DuplicateStructure(ctx context.Context, id int64, newName string) (*BlindStructure, error) {
|
||||||
|
original, err := s.GetStructure(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get original: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if newName == "" {
|
||||||
|
newName = original.Name + " (Copy)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.CreateStructure(ctx, newName, original.Levels)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1,302 @@
|
||||||
package blind
|
package blind
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WizardInput defines the inputs for the blind structure wizard.
|
||||||
|
type WizardInput struct {
|
||||||
|
PlayerCount int `json:"player_count"`
|
||||||
|
StartingChips int64 `json:"starting_chips"`
|
||||||
|
TargetDurationMinutes int `json:"target_duration_minutes"`
|
||||||
|
ChipSetID int64 `json:"chip_set_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WizardService generates blind structures from high-level inputs.
|
||||||
|
type WizardService struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWizardService creates a new WizardService.
|
||||||
|
func NewWizardService(db *sql.DB) *WizardService {
|
||||||
|
return &WizardService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate produces a blind level array from wizard inputs.
|
||||||
|
// The algorithm:
|
||||||
|
// 1. Calculate target number of levels from duration / level duration
|
||||||
|
// 2. Calculate target final BB from total chips / ~10 BB
|
||||||
|
// 3. Generate geometric blind progression
|
||||||
|
// 4. Snap blinds to chip denominations
|
||||||
|
// 5. Insert breaks and chip-up markers
|
||||||
|
//
|
||||||
|
// The result is NOT saved -- it is a preview for the TD to review and save.
|
||||||
|
func (ws *WizardService) Generate(ctx context.Context, input WizardInput) ([]BlindLevel, error) {
|
||||||
|
if input.PlayerCount < 2 {
|
||||||
|
return nil, fmt.Errorf("player_count must be at least 2")
|
||||||
|
}
|
||||||
|
if input.StartingChips <= 0 {
|
||||||
|
return nil, fmt.Errorf("starting_chips must be positive")
|
||||||
|
}
|
||||||
|
if input.TargetDurationMinutes < 30 {
|
||||||
|
return nil, fmt.Errorf("target_duration_minutes must be at least 30")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load chip denominations for snapping
|
||||||
|
denoms, err := ws.loadDenominations(ctx, input.ChipSetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(denoms) == 0 {
|
||||||
|
return nil, fmt.Errorf("chip set %d has no denominations", input.ChipSetID)
|
||||||
|
}
|
||||||
|
sort.Slice(denoms, func(i, j int) bool { return denoms[i] < denoms[j] })
|
||||||
|
|
||||||
|
// Step 1: Determine level duration and count
|
||||||
|
levelDurationMinutes := determineLevelDuration(input.TargetDurationMinutes)
|
||||||
|
levelDurationSeconds := levelDurationMinutes * 60
|
||||||
|
numRoundLevels := input.TargetDurationMinutes / levelDurationMinutes
|
||||||
|
if numRoundLevels < 5 {
|
||||||
|
numRoundLevels = 5
|
||||||
|
}
|
||||||
|
if numRoundLevels > 30 {
|
||||||
|
numRoundLevels = 30
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Calculate target final big blind
|
||||||
|
// At the end, average stack ~= 10 BB
|
||||||
|
totalChips := input.StartingChips * int64(input.PlayerCount)
|
||||||
|
targetFinalBB := totalChips / 10
|
||||||
|
|
||||||
|
// Step 3: Calculate initial BB (smallest denomination * 2 or smallest * 4)
|
||||||
|
initialBB := denoms[0] * 2
|
||||||
|
if initialBB < denoms[0] {
|
||||||
|
initialBB = denoms[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Generate geometric blind progression
|
||||||
|
if targetFinalBB <= initialBB {
|
||||||
|
targetFinalBB = initialBB * int64(numRoundLevels)
|
||||||
|
}
|
||||||
|
|
||||||
|
ratio := math.Pow(float64(targetFinalBB)/float64(initialBB), 1.0/float64(numRoundLevels-1))
|
||||||
|
if ratio < 1.1 {
|
||||||
|
ratio = 1.1
|
||||||
|
}
|
||||||
|
if ratio > 3.0 {
|
||||||
|
ratio = 3.0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Generate levels, snapping to denominations
|
||||||
|
var levels []BlindLevel
|
||||||
|
position := 0
|
||||||
|
breakInterval := determineBreakInterval(numRoundLevels)
|
||||||
|
breakDuration := 10 * 60 // 10 minutes
|
||||||
|
roundsSinceBreak := 0
|
||||||
|
anteStartLevel := 4 // Antes start at level 4-5
|
||||||
|
|
||||||
|
prevBB := int64(0)
|
||||||
|
for i := 0; i < numRoundLevels; i++ {
|
||||||
|
// Insert break if needed
|
||||||
|
if roundsSinceBreak >= breakInterval && i > 0 {
|
||||||
|
breakLevel := BlindLevel{
|
||||||
|
Position: position,
|
||||||
|
LevelType: "break",
|
||||||
|
GameType: "nlhe",
|
||||||
|
DurationSeconds: breakDuration,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a chip-up is needed: find the smallest denomination
|
||||||
|
// still in play. If all blinds/antes from here on are >= next denom,
|
||||||
|
// mark chip-up.
|
||||||
|
chipUpValue := determineChipUp(denoms, prevBB)
|
||||||
|
if chipUpValue != nil {
|
||||||
|
breakLevel.ChipUpDenominationValue = chipUpValue
|
||||||
|
}
|
||||||
|
|
||||||
|
levels = append(levels, breakLevel)
|
||||||
|
position++
|
||||||
|
roundsSinceBreak = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate raw BB for this level
|
||||||
|
rawBB := float64(initialBB) * math.Pow(ratio, float64(i))
|
||||||
|
bb := snapToDenomination(int64(math.Round(rawBB)), denoms)
|
||||||
|
|
||||||
|
// Ensure BB is strictly increasing
|
||||||
|
if bb <= prevBB && prevBB > 0 {
|
||||||
|
bb = nextDenomination(prevBB, denoms)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SB = BB/2, snapped to nearest denomination
|
||||||
|
sb := snapToDenomination(bb/2, denoms)
|
||||||
|
if sb >= bb {
|
||||||
|
sb = denoms[0]
|
||||||
|
}
|
||||||
|
if sb == 0 {
|
||||||
|
sb = denoms[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ante: starts at anteStartLevel, equals BB at higher levels
|
||||||
|
var ante int64
|
||||||
|
if i >= anteStartLevel {
|
||||||
|
ante = snapToDenomination(bb, denoms)
|
||||||
|
}
|
||||||
|
|
||||||
|
level := BlindLevel{
|
||||||
|
Position: position,
|
||||||
|
LevelType: "round",
|
||||||
|
GameType: "nlhe",
|
||||||
|
SmallBlind: sb,
|
||||||
|
BigBlind: bb,
|
||||||
|
Ante: ante,
|
||||||
|
DurationSeconds: levelDurationSeconds,
|
||||||
|
}
|
||||||
|
|
||||||
|
levels = append(levels, level)
|
||||||
|
prevBB = bb
|
||||||
|
position++
|
||||||
|
roundsSinceBreak++
|
||||||
|
}
|
||||||
|
|
||||||
|
return levels, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadDenominations loads chip denomination values for a chip set.
|
||||||
|
func (ws *WizardService) loadDenominations(ctx context.Context, chipSetID int64) ([]int64, error) {
|
||||||
|
rows, err := ws.db.QueryContext(ctx,
|
||||||
|
"SELECT value FROM chip_denominations WHERE chip_set_id = ? ORDER BY value", chipSetID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load denominations: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var denoms []int64
|
||||||
|
for rows.Next() {
|
||||||
|
var v int64
|
||||||
|
if err := rows.Scan(&v); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan denomination: %w", err)
|
||||||
|
}
|
||||||
|
denoms = append(denoms, v)
|
||||||
|
}
|
||||||
|
return denoms, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// determineLevelDuration chooses level duration based on target tournament length.
|
||||||
|
func determineLevelDuration(targetMinutes int) int {
|
||||||
|
switch {
|
||||||
|
case targetMinutes <= 90:
|
||||||
|
return 10 // Turbo
|
||||||
|
case targetMinutes <= 180:
|
||||||
|
return 15
|
||||||
|
case targetMinutes <= 300:
|
||||||
|
return 20 // Standard
|
||||||
|
case targetMinutes <= 420:
|
||||||
|
return 30 // Deep
|
||||||
|
default:
|
||||||
|
return 60 // WSOP-style
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// determineBreakInterval chooses how many rounds between breaks.
|
||||||
|
func determineBreakInterval(numLevels int) int {
|
||||||
|
switch {
|
||||||
|
case numLevels <= 8:
|
||||||
|
return 4
|
||||||
|
case numLevels <= 15:
|
||||||
|
return 5
|
||||||
|
default:
|
||||||
|
return 6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// snapToDenomination snaps a value to the nearest chip denomination.
|
||||||
|
func snapToDenomination(value int64, denoms []int64) int64 {
|
||||||
|
if len(denoms) == 0 || value <= 0 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
best := denoms[0]
|
||||||
|
bestDiff := abs64(value - best)
|
||||||
|
|
||||||
|
for _, d := range denoms[1:] {
|
||||||
|
diff := abs64(value - d)
|
||||||
|
if diff < bestDiff {
|
||||||
|
best = d
|
||||||
|
bestDiff = diff
|
||||||
|
}
|
||||||
|
// Since denoms are sorted, if we start getting further away, stop
|
||||||
|
if d > value && diff > bestDiff {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also consider multiples of denominations
|
||||||
|
for _, d := range denoms {
|
||||||
|
if d > value {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
multiple := (value / d) * d
|
||||||
|
diff := abs64(value - multiple)
|
||||||
|
if diff < bestDiff {
|
||||||
|
best = multiple
|
||||||
|
bestDiff = diff
|
||||||
|
}
|
||||||
|
// Round up too
|
||||||
|
multipleUp := multiple + d
|
||||||
|
diffUp := abs64(value - multipleUp)
|
||||||
|
if diffUp < bestDiff {
|
||||||
|
best = multipleUp
|
||||||
|
bestDiff = diffUp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if best <= 0 {
|
||||||
|
return denoms[0]
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextDenomination returns the next denomination value above the given value.
|
||||||
|
func nextDenomination(value int64, denoms []int64) int64 {
|
||||||
|
// Try exact denominations first
|
||||||
|
for _, d := range denoms {
|
||||||
|
if d > value {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use multiples of the largest denomination
|
||||||
|
largest := denoms[len(denoms)-1]
|
||||||
|
multiple := ((value / largest) + 1) * largest
|
||||||
|
return multiple
|
||||||
|
}
|
||||||
|
|
||||||
|
// determineChipUp checks if the smallest denomination can be removed.
|
||||||
|
func determineChipUp(denoms []int64, currentBB int64) *int64 {
|
||||||
|
if len(denoms) < 2 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
smallest := denoms[0]
|
||||||
|
nextSmallest := denoms[1]
|
||||||
|
|
||||||
|
// If the current BB is at least 4x the next smallest denomination,
|
||||||
|
// the smallest is no longer needed
|
||||||
|
if currentBB >= nextSmallest*4 {
|
||||||
|
return &smallest
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// abs64 returns the absolute value of an int64.
|
||||||
|
func abs64(x int64) int64 {
|
||||||
|
if x < 0 {
|
||||||
|
return -x
|
||||||
|
}
|
||||||
|
return x
|
||||||
|
}
|
||||||
|
|
|
||||||
644
internal/server/routes/templates.go
Normal file
644
internal/server/routes/templates.go
Normal file
|
|
@ -0,0 +1,644 @@
|
||||||
|
// Package routes provides HTTP route handlers for the Felt API.
|
||||||
|
// Each handler group corresponds to a domain entity and registers its routes
|
||||||
|
// with a chi router group.
|
||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"github.com/felt-app/felt/internal/blind"
|
||||||
|
"github.com/felt-app/felt/internal/server/middleware"
|
||||||
|
"github.com/felt-app/felt/internal/template"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TemplateRoutes holds all building block route handlers and their services.
|
||||||
|
type TemplateRoutes struct {
|
||||||
|
chipSets *template.ChipSetService
|
||||||
|
blinds *blind.StructureService
|
||||||
|
payouts *template.PayoutService
|
||||||
|
buyins *template.BuyinService
|
||||||
|
templates *template.TournamentTemplateService
|
||||||
|
wizard *blind.WizardService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTemplateRoutes creates a new TemplateRoutes with all services initialized.
|
||||||
|
func NewTemplateRoutes(db *sql.DB) *TemplateRoutes {
|
||||||
|
return &TemplateRoutes{
|
||||||
|
chipSets: template.NewChipSetService(db),
|
||||||
|
blinds: blind.NewStructureService(db),
|
||||||
|
payouts: template.NewPayoutService(db),
|
||||||
|
buyins: template.NewBuyinService(db),
|
||||||
|
templates: template.NewTournamentTemplateService(db),
|
||||||
|
wizard: blind.NewWizardService(db),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register mounts all template-related routes on the given chi router.
|
||||||
|
// All routes require auth. Create/update/delete require admin role.
|
||||||
|
func (tr *TemplateRoutes) Register(r chi.Router) {
|
||||||
|
// Chip Sets
|
||||||
|
r.Route("/chip-sets", func(r chi.Router) {
|
||||||
|
r.Get("/", tr.listChipSets)
|
||||||
|
r.Get("/{id}", tr.getChipSet)
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireRole(middleware.RoleAdmin))
|
||||||
|
r.Post("/", tr.createChipSet)
|
||||||
|
r.Put("/{id}", tr.updateChipSet)
|
||||||
|
r.Delete("/{id}", tr.deleteChipSet)
|
||||||
|
r.Post("/{id}/duplicate", tr.duplicateChipSet)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Blind Structures
|
||||||
|
r.Route("/blind-structures", func(r chi.Router) {
|
||||||
|
r.Get("/", tr.listBlindStructures)
|
||||||
|
r.Get("/{id}", tr.getBlindStructure)
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireRole(middleware.RoleAdmin))
|
||||||
|
r.Post("/", tr.createBlindStructure)
|
||||||
|
r.Put("/{id}", tr.updateBlindStructure)
|
||||||
|
r.Delete("/{id}", tr.deleteBlindStructure)
|
||||||
|
r.Post("/{id}/duplicate", tr.duplicateBlindStructure)
|
||||||
|
r.Post("/wizard", tr.wizardGenerate)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Payout Structures
|
||||||
|
r.Route("/payout-structures", func(r chi.Router) {
|
||||||
|
r.Get("/", tr.listPayoutStructures)
|
||||||
|
r.Get("/{id}", tr.getPayoutStructure)
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireRole(middleware.RoleAdmin))
|
||||||
|
r.Post("/", tr.createPayoutStructure)
|
||||||
|
r.Put("/{id}", tr.updatePayoutStructure)
|
||||||
|
r.Delete("/{id}", tr.deletePayoutStructure)
|
||||||
|
r.Post("/{id}/duplicate", tr.duplicatePayoutStructure)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Buy-in Configs
|
||||||
|
r.Route("/buyin-configs", func(r chi.Router) {
|
||||||
|
r.Get("/", tr.listBuyinConfigs)
|
||||||
|
r.Get("/{id}", tr.getBuyinConfig)
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireRole(middleware.RoleAdmin))
|
||||||
|
r.Post("/", tr.createBuyinConfig)
|
||||||
|
r.Put("/{id}", tr.updateBuyinConfig)
|
||||||
|
r.Delete("/{id}", tr.deleteBuyinConfig)
|
||||||
|
r.Post("/{id}/duplicate", tr.duplicateBuyinConfig)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Tournament Templates
|
||||||
|
r.Route("/tournament-templates", func(r chi.Router) {
|
||||||
|
r.Get("/", tr.listTournamentTemplates)
|
||||||
|
r.Get("/{id}", tr.getTournamentTemplate)
|
||||||
|
r.Get("/{id}/expanded", tr.getTournamentTemplateExpanded)
|
||||||
|
r.Group(func(r chi.Router) {
|
||||||
|
r.Use(middleware.RequireRole(middleware.RoleAdmin))
|
||||||
|
r.Post("/", tr.createTournamentTemplate)
|
||||||
|
r.Put("/{id}", tr.updateTournamentTemplate)
|
||||||
|
r.Delete("/{id}", tr.deleteTournamentTemplate)
|
||||||
|
r.Post("/{id}/duplicate", tr.duplicateTournamentTemplate)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
// parseID extracts an integer ID from the URL path parameter.
|
||||||
|
func parseID(r *http.Request) (int64, error) {
|
||||||
|
idStr := chi.URLParam(r, "id")
|
||||||
|
id, err := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("invalid id: %s", idStr)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeJSON writes a JSON response with the given status code.
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeError writes an error JSON response.
|
||||||
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||||
|
writeJSON(w, status, map[string]string{"error": msg})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Chip Set Handlers ---
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) listChipSets(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sets, err := tr.chipSets.ListChipSets(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, sets)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) getChipSet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cs, err := tr.chipSets.GetChipSet(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, cs)
|
||||||
|
}
|
||||||
|
|
||||||
|
type createChipSetRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Denominations []template.ChipDenomination `json:"denominations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) createChipSet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req createChipSetRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cs, err := tr.chipSets.CreateChipSet(r.Context(), req.Name, req.Denominations)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, cs)
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateChipSetRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Denominations []template.ChipDenomination `json:"denominations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) updateChipSet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req updateChipSetRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tr.chipSets.UpdateChipSet(r.Context(), id, req.Name, req.Denominations); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) deleteChipSet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tr.chipSets.DeleteChipSet(r.Context(), id); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
type duplicateRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) duplicateChipSet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req duplicateRequest
|
||||||
|
json.NewDecoder(r.Body).Decode(&req) // name is optional
|
||||||
|
cs, err := tr.chipSets.DuplicateChipSet(r.Context(), id, req.Name)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, cs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Blind Structure Handlers ---
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) listBlindStructures(w http.ResponseWriter, r *http.Request) {
|
||||||
|
structs, err := tr.blinds.ListStructures(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, structs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) getBlindStructure(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bs, err := tr.blinds.GetStructure(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, bs)
|
||||||
|
}
|
||||||
|
|
||||||
|
type createBlindStructureRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Levels []blind.BlindLevel `json:"levels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) createBlindStructure(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req createBlindStructureRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bs, err := tr.blinds.CreateStructure(r.Context(), req.Name, req.Levels)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, bs)
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateBlindStructureRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Levels []blind.BlindLevel `json:"levels"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) updateBlindStructure(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req updateBlindStructureRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tr.blinds.UpdateStructure(r.Context(), id, req.Name, req.Levels); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) deleteBlindStructure(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tr.blinds.DeleteStructure(r.Context(), id); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) duplicateBlindStructure(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req duplicateRequest
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
bs, err := tr.blinds.DuplicateStructure(r.Context(), id, req.Name)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, bs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Wizard Handler ---
|
||||||
|
|
||||||
|
type wizardRequest struct {
|
||||||
|
PlayerCount int `json:"player_count"`
|
||||||
|
StartingChips int64 `json:"starting_chips"`
|
||||||
|
TargetDurationMinutes int `json:"target_duration_minutes"`
|
||||||
|
ChipSetID int64 `json:"chip_set_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) wizardGenerate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req wizardRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
levels, err := tr.wizard.Generate(r.Context(), blind.WizardInput{
|
||||||
|
PlayerCount: req.PlayerCount,
|
||||||
|
StartingChips: req.StartingChips,
|
||||||
|
TargetDurationMinutes: req.TargetDurationMinutes,
|
||||||
|
ChipSetID: req.ChipSetID,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]interface{}{
|
||||||
|
"levels": levels,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Payout Structure Handlers ---
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) listPayoutStructures(w http.ResponseWriter, r *http.Request) {
|
||||||
|
structs, err := tr.payouts.ListPayoutStructures(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, structs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) getPayoutStructure(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ps, err := tr.payouts.GetPayoutStructure(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, ps)
|
||||||
|
}
|
||||||
|
|
||||||
|
type createPayoutStructureRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Brackets []template.PayoutBracket `json:"brackets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) createPayoutStructure(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req createPayoutStructureRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ps, err := tr.payouts.CreatePayoutStructure(r.Context(), req.Name, req.Brackets)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, ps)
|
||||||
|
}
|
||||||
|
|
||||||
|
type updatePayoutStructureRequest struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Brackets []template.PayoutBracket `json:"brackets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) updatePayoutStructure(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req updatePayoutStructureRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tr.payouts.UpdatePayoutStructure(r.Context(), id, req.Name, req.Brackets); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) deletePayoutStructure(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tr.payouts.DeletePayoutStructure(r.Context(), id); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) duplicatePayoutStructure(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req duplicateRequest
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
ps, err := tr.payouts.DuplicatePayoutStructure(r.Context(), id, req.Name)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, ps)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Buy-in Config Handlers ---
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) listBuyinConfigs(w http.ResponseWriter, r *http.Request) {
|
||||||
|
configs, err := tr.buyins.ListBuyinConfigs(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, configs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) getBuyinConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg, err := tr.buyins.GetBuyinConfig(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) createBuyinConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var cfg template.BuyinConfig
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := tr.buyins.CreateBuyinConfig(r.Context(), &cfg)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) updateBuyinConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var cfg template.BuyinConfig
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&cfg); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tr.buyins.UpdateBuyinConfig(r.Context(), id, &cfg); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) deleteBuyinConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tr.buyins.DeleteBuyinConfig(r.Context(), id); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) duplicateBuyinConfig(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req duplicateRequest
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
cfg, err := tr.buyins.DuplicateBuyinConfig(r.Context(), id, req.Name)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tournament Template Handlers ---
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) listTournamentTemplates(w http.ResponseWriter, r *http.Request) {
|
||||||
|
templates, err := tr.templates.ListTemplates(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusInternalServerError, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, templates)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) getTournamentTemplate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tmpl, err := tr.templates.GetTemplate(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, tmpl)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) getTournamentTemplateExpanded(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expanded, err := tr.templates.GetTemplateExpanded(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusNotFound, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, expanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) createTournamentTemplate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var tmpl template.TournamentTemplate
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&tmpl); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := tr.templates.CreateTemplate(r.Context(), &tmpl)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) updateTournamentTemplate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var tmpl template.TournamentTemplate
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&tmpl); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tmpl.ID = id
|
||||||
|
if err := tr.templates.UpdateTemplate(r.Context(), &tmpl); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) deleteTournamentTemplate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := tr.templates.DeleteTemplate(r.Context(), id); err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tr *TemplateRoutes) duplicateTournamentTemplate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id, err := parseID(r)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req duplicateRequest
|
||||||
|
json.NewDecoder(r.Body).Decode(&req)
|
||||||
|
tmpl, err := tr.templates.DuplicateTemplate(r.Context(), id, req.Name)
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusCreated, tmpl)
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,10 @@ import (
|
||||||
chimw "github.com/go-chi/chi/v5/middleware"
|
chimw "github.com/go-chi/chi/v5/middleware"
|
||||||
|
|
||||||
"github.com/felt-app/felt/frontend"
|
"github.com/felt-app/felt/frontend"
|
||||||
|
"github.com/felt-app/felt/internal/auth"
|
||||||
|
"github.com/felt-app/felt/internal/clock"
|
||||||
"github.com/felt-app/felt/internal/server/middleware"
|
"github.com/felt-app/felt/internal/server/middleware"
|
||||||
|
"github.com/felt-app/felt/internal/server/routes"
|
||||||
"github.com/felt-app/felt/internal/server/ws"
|
"github.com/felt-app/felt/internal/server/ws"
|
||||||
natsserver "github.com/nats-io/nats-server/v2/server"
|
natsserver "github.com/nats-io/nats-server/v2/server"
|
||||||
)
|
)
|
||||||
|
|
@ -29,14 +32,16 @@ type Config struct {
|
||||||
|
|
||||||
// Server wraps the HTTP server with all dependencies.
|
// Server wraps the HTTP server with all dependencies.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
httpServer *http.Server
|
httpServer *http.Server
|
||||||
hub *ws.Hub
|
hub *ws.Hub
|
||||||
db *sql.DB
|
db *sql.DB
|
||||||
nats *natsserver.Server
|
nats *natsserver.Server
|
||||||
|
authService *auth.AuthService
|
||||||
|
clockRegistry *clock.Registry
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new HTTP server with all routes and middleware configured.
|
// New creates a new HTTP server with all routes and middleware configured.
|
||||||
func New(cfg Config, db *sql.DB, nats *natsserver.Server, hub *ws.Hub) *Server {
|
func New(cfg Config, db *sql.DB, nats *natsserver.Server, hub *ws.Hub, authService *auth.AuthService, clockRegistry *clock.Registry) *Server {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
// Global middleware
|
// Global middleware
|
||||||
|
|
@ -52,21 +57,43 @@ func New(cfg Config, db *sql.DB, nats *natsserver.Server, hub *ws.Hub) *Server {
|
||||||
r.Use(corsMiddleware(cfg.DevMode))
|
r.Use(corsMiddleware(cfg.DevMode))
|
||||||
|
|
||||||
s := &Server{
|
s := &Server{
|
||||||
hub: hub,
|
hub: hub,
|
||||||
db: db,
|
db: db,
|
||||||
nats: nats,
|
nats: nats,
|
||||||
|
authService: authService,
|
||||||
|
clockRegistry: clockRegistry,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auth handler
|
||||||
|
authHandler := routes.NewAuthHandler(authService)
|
||||||
|
|
||||||
|
// Clock handler
|
||||||
|
clockHandler := routes.NewClockHandler(clockRegistry, db)
|
||||||
|
|
||||||
// API routes
|
// API routes
|
||||||
r.Route("/api/v1", func(r chi.Router) {
|
r.Route("/api/v1", func(r chi.Router) {
|
||||||
// Public endpoints (no auth required)
|
// Public endpoints (no auth required)
|
||||||
r.Get("/health", s.handleHealth)
|
r.Get("/health", s.handleHealth)
|
||||||
|
|
||||||
|
// Auth endpoints (login is public, others require auth)
|
||||||
|
r.Post("/auth/login", authHandler.HandleLogin)
|
||||||
|
|
||||||
// Protected endpoints
|
// Protected endpoints
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(middleware.JWTAuth(cfg.SigningKey))
|
r.Use(middleware.JWTAuth(cfg.SigningKey))
|
||||||
|
|
||||||
// Stub endpoints — return 200 for now
|
// Auth endpoints that require authentication
|
||||||
|
r.Get("/auth/me", authHandler.HandleMe)
|
||||||
|
r.Post("/auth/logout", authHandler.HandleLogout)
|
||||||
|
|
||||||
|
// Template building blocks and tournament templates
|
||||||
|
templateRoutes := routes.NewTemplateRoutes(db)
|
||||||
|
templateRoutes.Register(r)
|
||||||
|
|
||||||
|
// Clock routes
|
||||||
|
clockHandler.RegisterRoutes(r)
|
||||||
|
|
||||||
|
// Stub endpoints -- return 200 for now
|
||||||
r.Get("/tournaments", stubHandler("tournaments"))
|
r.Get("/tournaments", stubHandler("tournaments"))
|
||||||
r.Get("/players", stubHandler("players"))
|
r.Get("/players", stubHandler("players"))
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1 +1,366 @@
|
||||||
package template
|
package template
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BuyinConfig represents a reusable buy-in configuration for tournaments.
|
||||||
|
type BuyinConfig struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
BuyinAmount int64 `json:"buyin_amount"`
|
||||||
|
StartingChips int64 `json:"starting_chips"`
|
||||||
|
RakeTotal int64 `json:"rake_total"`
|
||||||
|
BountyAmount int64 `json:"bounty_amount"`
|
||||||
|
BountyChip int64 `json:"bounty_chip"`
|
||||||
|
RebuyAllowed bool `json:"rebuy_allowed"`
|
||||||
|
RebuyCost int64 `json:"rebuy_cost"`
|
||||||
|
RebuyChips int64 `json:"rebuy_chips"`
|
||||||
|
RebuyRake int64 `json:"rebuy_rake"`
|
||||||
|
RebuyLimit int `json:"rebuy_limit"`
|
||||||
|
RebuyLevelCutoff *int `json:"rebuy_level_cutoff,omitempty"`
|
||||||
|
RebuyTimeCutoffSeconds *int `json:"rebuy_time_cutoff_seconds,omitempty"`
|
||||||
|
RebuyChipThreshold *int64 `json:"rebuy_chip_threshold,omitempty"`
|
||||||
|
AddonAllowed bool `json:"addon_allowed"`
|
||||||
|
AddonCost int64 `json:"addon_cost"`
|
||||||
|
AddonChips int64 `json:"addon_chips"`
|
||||||
|
AddonRake int64 `json:"addon_rake"`
|
||||||
|
AddonLevelStart *int `json:"addon_level_start,omitempty"`
|
||||||
|
AddonLevelEnd *int `json:"addon_level_end,omitempty"`
|
||||||
|
ReentryAllowed bool `json:"reentry_allowed"`
|
||||||
|
ReentryLimit int `json:"reentry_limit"`
|
||||||
|
LateRegLevelCutoff *int `json:"late_reg_level_cutoff,omitempty"`
|
||||||
|
LateRegTimeCutoffSecs *int `json:"late_reg_time_cutoff_seconds,omitempty"`
|
||||||
|
RakeSplits []RakeSplit `json:"rake_splits,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RakeSplit defines how rake is distributed across categories.
|
||||||
|
type RakeSplit struct {
|
||||||
|
ID int64 `json:"id,omitempty"`
|
||||||
|
BuyinConfigID int64 `json:"buyin_config_id,omitempty"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Amount int64 `json:"amount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BuyinService provides CRUD operations for buy-in configs.
|
||||||
|
type BuyinService struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBuyinService creates a new BuyinService.
|
||||||
|
func NewBuyinService(db *sql.DB) *BuyinService {
|
||||||
|
return &BuyinService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateBuyinConfig checks that the config is valid.
|
||||||
|
func validateBuyinConfig(cfg *BuyinConfig) error {
|
||||||
|
if cfg.Name == "" {
|
||||||
|
return fmt.Errorf("buy-in config name is required")
|
||||||
|
}
|
||||||
|
if cfg.BuyinAmount < 0 {
|
||||||
|
return fmt.Errorf("buyin_amount must be non-negative")
|
||||||
|
}
|
||||||
|
if cfg.StartingChips < 0 {
|
||||||
|
return fmt.Errorf("starting_chips must be non-negative")
|
||||||
|
}
|
||||||
|
if cfg.RakeTotal < 0 {
|
||||||
|
return fmt.Errorf("rake_total must be non-negative")
|
||||||
|
}
|
||||||
|
if cfg.BountyAmount < 0 {
|
||||||
|
return fmt.Errorf("bounty_amount must be non-negative")
|
||||||
|
}
|
||||||
|
if cfg.BountyAmount > 0 && cfg.BountyChip <= 0 {
|
||||||
|
return fmt.Errorf("bounty_chip must be positive when bounty_amount > 0")
|
||||||
|
}
|
||||||
|
if cfg.RebuyCost < 0 || cfg.RebuyChips < 0 || cfg.RebuyRake < 0 {
|
||||||
|
return fmt.Errorf("rebuy values must be non-negative")
|
||||||
|
}
|
||||||
|
if cfg.RebuyLimit < 0 {
|
||||||
|
return fmt.Errorf("rebuy_limit must be non-negative")
|
||||||
|
}
|
||||||
|
if cfg.AddonCost < 0 || cfg.AddonChips < 0 || cfg.AddonRake < 0 {
|
||||||
|
return fmt.Errorf("addon values must be non-negative")
|
||||||
|
}
|
||||||
|
if cfg.ReentryLimit < 0 {
|
||||||
|
return fmt.Errorf("reentry_limit must be non-negative")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate rake splits sum = rake_total
|
||||||
|
if len(cfg.RakeSplits) > 0 {
|
||||||
|
var splitSum int64
|
||||||
|
for _, split := range cfg.RakeSplits {
|
||||||
|
if split.Amount < 0 {
|
||||||
|
return fmt.Errorf("rake split amount must be non-negative")
|
||||||
|
}
|
||||||
|
validCategories := map[string]bool{"house": true, "staff": true, "league": true, "season_reserve": true}
|
||||||
|
if !validCategories[split.Category] {
|
||||||
|
return fmt.Errorf("invalid rake split category: %q", split.Category)
|
||||||
|
}
|
||||||
|
splitSum += split.Amount
|
||||||
|
}
|
||||||
|
if splitSum != cfg.RakeTotal {
|
||||||
|
return fmt.Errorf("rake splits sum (%d) does not match rake_total (%d)", splitSum, cfg.RakeTotal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateBuyinConfig creates a new buy-in configuration.
|
||||||
|
func (s *BuyinService) CreateBuyinConfig(ctx context.Context, cfg *BuyinConfig) (*BuyinConfig, error) {
|
||||||
|
if err := validateBuyinConfig(cfg); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
res, err := tx.ExecContext(ctx,
|
||||||
|
`INSERT INTO buyin_configs (name, buyin_amount, starting_chips, rake_total, bounty_amount, bounty_chip,
|
||||||
|
rebuy_allowed, rebuy_cost, rebuy_chips, rebuy_rake, rebuy_limit, rebuy_level_cutoff, rebuy_time_cutoff_seconds, rebuy_chip_threshold,
|
||||||
|
addon_allowed, addon_cost, addon_chips, addon_rake, addon_level_start, addon_level_end,
|
||||||
|
reentry_allowed, reentry_limit, late_reg_level_cutoff, late_reg_time_cutoff_seconds,
|
||||||
|
created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
cfg.Name, cfg.BuyinAmount, cfg.StartingChips, cfg.RakeTotal, cfg.BountyAmount, cfg.BountyChip,
|
||||||
|
boolToInt(cfg.RebuyAllowed), cfg.RebuyCost, cfg.RebuyChips, cfg.RebuyRake, cfg.RebuyLimit,
|
||||||
|
cfg.RebuyLevelCutoff, cfg.RebuyTimeCutoffSeconds, cfg.RebuyChipThreshold,
|
||||||
|
boolToInt(cfg.AddonAllowed), cfg.AddonCost, cfg.AddonChips, cfg.AddonRake,
|
||||||
|
cfg.AddonLevelStart, cfg.AddonLevelEnd,
|
||||||
|
boolToInt(cfg.ReentryAllowed), cfg.ReentryLimit,
|
||||||
|
cfg.LateRegLevelCutoff, cfg.LateRegTimeCutoffSecs,
|
||||||
|
now, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert buyin config: %w", err)
|
||||||
|
}
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get buyin config id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := insertRakeSplits(ctx, tx, id, cfg.RakeSplits); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, fmt.Errorf("commit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetBuyinConfig(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertRakeSplits(ctx context.Context, tx *sql.Tx, configID int64, splits []RakeSplit) error {
|
||||||
|
for i, split := range splits {
|
||||||
|
_, err := tx.ExecContext(ctx,
|
||||||
|
"INSERT INTO rake_splits (buyin_config_id, category, amount) VALUES (?, ?, ?)",
|
||||||
|
configID, split.Category, split.Amount,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert rake split %d: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBuyinConfig retrieves a buy-in config by ID, including rake splits.
|
||||||
|
func (s *BuyinService) GetBuyinConfig(ctx context.Context, id int64) (*BuyinConfig, error) {
|
||||||
|
cfg := &BuyinConfig{}
|
||||||
|
var createdAt, updatedAt int64
|
||||||
|
var rebuyAllowed, addonAllowed, reentryAllowed int
|
||||||
|
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
`SELECT id, name, buyin_amount, starting_chips, rake_total, bounty_amount, bounty_chip,
|
||||||
|
rebuy_allowed, rebuy_cost, rebuy_chips, rebuy_rake, rebuy_limit, rebuy_level_cutoff, rebuy_time_cutoff_seconds, rebuy_chip_threshold,
|
||||||
|
addon_allowed, addon_cost, addon_chips, addon_rake, addon_level_start, addon_level_end,
|
||||||
|
reentry_allowed, reentry_limit, late_reg_level_cutoff, late_reg_time_cutoff_seconds,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM buyin_configs WHERE id = ?`, id,
|
||||||
|
).Scan(
|
||||||
|
&cfg.ID, &cfg.Name, &cfg.BuyinAmount, &cfg.StartingChips, &cfg.RakeTotal, &cfg.BountyAmount, &cfg.BountyChip,
|
||||||
|
&rebuyAllowed, &cfg.RebuyCost, &cfg.RebuyChips, &cfg.RebuyRake, &cfg.RebuyLimit,
|
||||||
|
&cfg.RebuyLevelCutoff, &cfg.RebuyTimeCutoffSeconds, &cfg.RebuyChipThreshold,
|
||||||
|
&addonAllowed, &cfg.AddonCost, &cfg.AddonChips, &cfg.AddonRake,
|
||||||
|
&cfg.AddonLevelStart, &cfg.AddonLevelEnd,
|
||||||
|
&reentryAllowed, &cfg.ReentryLimit,
|
||||||
|
&cfg.LateRegLevelCutoff, &cfg.LateRegTimeCutoffSecs,
|
||||||
|
&createdAt, &updatedAt,
|
||||||
|
)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, fmt.Errorf("buyin config not found: %d", id)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get buyin config: %w", err)
|
||||||
|
}
|
||||||
|
cfg.RebuyAllowed = rebuyAllowed != 0
|
||||||
|
cfg.AddonAllowed = addonAllowed != 0
|
||||||
|
cfg.ReentryAllowed = reentryAllowed != 0
|
||||||
|
cfg.CreatedAt = time.Unix(createdAt, 0)
|
||||||
|
cfg.UpdatedAt = time.Unix(updatedAt, 0)
|
||||||
|
|
||||||
|
// Load rake splits
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
"SELECT id, buyin_config_id, category, amount FROM rake_splits WHERE buyin_config_id = ? ORDER BY category", id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get rake splits: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var split RakeSplit
|
||||||
|
if err := rows.Scan(&split.ID, &split.BuyinConfigID, &split.Category, &split.Amount); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan rake split: %w", err)
|
||||||
|
}
|
||||||
|
cfg.RakeSplits = append(cfg.RakeSplits, split)
|
||||||
|
}
|
||||||
|
return cfg, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListBuyinConfigs returns all buy-in configs (without rake splits).
|
||||||
|
func (s *BuyinService) ListBuyinConfigs(ctx context.Context) ([]BuyinConfig, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
`SELECT id, name, buyin_amount, starting_chips, rake_total, bounty_amount, bounty_chip,
|
||||||
|
rebuy_allowed, addon_allowed, reentry_allowed,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM buyin_configs ORDER BY name`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list buyin configs: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var configs []BuyinConfig
|
||||||
|
for rows.Next() {
|
||||||
|
var cfg BuyinConfig
|
||||||
|
var createdAt, updatedAt int64
|
||||||
|
var rebuyAllowed, addonAllowed, reentryAllowed int
|
||||||
|
if err := rows.Scan(
|
||||||
|
&cfg.ID, &cfg.Name, &cfg.BuyinAmount, &cfg.StartingChips, &cfg.RakeTotal, &cfg.BountyAmount, &cfg.BountyChip,
|
||||||
|
&rebuyAllowed, &addonAllowed, &reentryAllowed,
|
||||||
|
&createdAt, &updatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan buyin config: %w", err)
|
||||||
|
}
|
||||||
|
cfg.RebuyAllowed = rebuyAllowed != 0
|
||||||
|
cfg.AddonAllowed = addonAllowed != 0
|
||||||
|
cfg.ReentryAllowed = reentryAllowed != 0
|
||||||
|
cfg.CreatedAt = time.Unix(createdAt, 0)
|
||||||
|
cfg.UpdatedAt = time.Unix(updatedAt, 0)
|
||||||
|
configs = append(configs, cfg)
|
||||||
|
}
|
||||||
|
return configs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateBuyinConfig updates a buy-in config and replaces its rake splits.
|
||||||
|
func (s *BuyinService) UpdateBuyinConfig(ctx context.Context, id int64, cfg *BuyinConfig) error {
|
||||||
|
cfg.ID = id
|
||||||
|
if err := validateBuyinConfig(cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
res, err := tx.ExecContext(ctx,
|
||||||
|
`UPDATE buyin_configs SET name = ?, buyin_amount = ?, starting_chips = ?, rake_total = ?,
|
||||||
|
bounty_amount = ?, bounty_chip = ?,
|
||||||
|
rebuy_allowed = ?, rebuy_cost = ?, rebuy_chips = ?, rebuy_rake = ?, rebuy_limit = ?,
|
||||||
|
rebuy_level_cutoff = ?, rebuy_time_cutoff_seconds = ?, rebuy_chip_threshold = ?,
|
||||||
|
addon_allowed = ?, addon_cost = ?, addon_chips = ?, addon_rake = ?,
|
||||||
|
addon_level_start = ?, addon_level_end = ?,
|
||||||
|
reentry_allowed = ?, reentry_limit = ?,
|
||||||
|
late_reg_level_cutoff = ?, late_reg_time_cutoff_seconds = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?`,
|
||||||
|
cfg.Name, cfg.BuyinAmount, cfg.StartingChips, cfg.RakeTotal,
|
||||||
|
cfg.BountyAmount, cfg.BountyChip,
|
||||||
|
boolToInt(cfg.RebuyAllowed), cfg.RebuyCost, cfg.RebuyChips, cfg.RebuyRake, cfg.RebuyLimit,
|
||||||
|
cfg.RebuyLevelCutoff, cfg.RebuyTimeCutoffSeconds, cfg.RebuyChipThreshold,
|
||||||
|
boolToInt(cfg.AddonAllowed), cfg.AddonCost, cfg.AddonChips, cfg.AddonRake,
|
||||||
|
cfg.AddonLevelStart, cfg.AddonLevelEnd,
|
||||||
|
boolToInt(cfg.ReentryAllowed), cfg.ReentryLimit,
|
||||||
|
cfg.LateRegLevelCutoff, cfg.LateRegTimeCutoffSecs,
|
||||||
|
now, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update buyin config: %w", err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return fmt.Errorf("buyin config not found: %d", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace rake splits
|
||||||
|
if _, err := tx.ExecContext(ctx, "DELETE FROM rake_splits WHERE buyin_config_id = ?", id); err != nil {
|
||||||
|
return fmt.Errorf("delete old rake splits: %w", err)
|
||||||
|
}
|
||||||
|
if err := insertRakeSplits(ctx, tx, id, cfg.RakeSplits); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteBuyinConfig deletes a buy-in config.
|
||||||
|
func (s *BuyinService) DeleteBuyinConfig(ctx context.Context, id int64) error {
|
||||||
|
// Check if referenced by active tournaments
|
||||||
|
var refCount int
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
"SELECT COUNT(*) FROM tournaments WHERE buyin_config_id = ? AND status NOT IN ('completed', 'cancelled')", id,
|
||||||
|
).Scan(&refCount)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check references: %w", err)
|
||||||
|
}
|
||||||
|
if refCount > 0 {
|
||||||
|
return fmt.Errorf("buyin config is referenced by %d active tournament(s)", refCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := s.db.ExecContext(ctx, "DELETE FROM buyin_configs WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete buyin config: %w", err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return fmt.Errorf("buyin config not found: %d", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DuplicateBuyinConfig creates an independent copy of a buy-in config.
|
||||||
|
func (s *BuyinService) DuplicateBuyinConfig(ctx context.Context, id int64, newName string) (*BuyinConfig, error) {
|
||||||
|
original, err := s.GetBuyinConfig(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
|
||||||
|
return s.CreateBuyinConfig(ctx, ©)
|
||||||
|
}
|
||||||
|
|
||||||
|
// boolToInt converts a Go bool to SQLite integer.
|
||||||
|
func boolToInt(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1,258 @@
|
||||||
|
// Package template provides CRUD operations for tournament building blocks:
|
||||||
|
// chip sets, payout structures, buy-in configs, and tournament templates.
|
||||||
package template
|
package template
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ChipSet represents a collection of chip denominations used in tournaments.
|
||||||
|
type ChipSet struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
IsBuiltin bool `json:"is_builtin"`
|
||||||
|
Denominations []ChipDenomination `json:"denominations,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChipDenomination represents a single chip denomination within a chip set.
|
||||||
|
type ChipDenomination struct {
|
||||||
|
ID int64 `json:"id,omitempty"`
|
||||||
|
ChipSetID int64 `json:"chip_set_id,omitempty"`
|
||||||
|
Value int64 `json:"value"`
|
||||||
|
ColorHex string `json:"color_hex"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChipSetService provides CRUD operations for chip sets.
|
||||||
|
type ChipSetService struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewChipSetService creates a new ChipSetService.
|
||||||
|
func NewChipSetService(db *sql.DB) *ChipSetService {
|
||||||
|
return &ChipSetService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateChipSet creates a new chip set with its denominations.
|
||||||
|
func (s *ChipSetService) CreateChipSet(ctx context.Context, name string, denominations []ChipDenomination) (*ChipSet, error) {
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("chip set name is required")
|
||||||
|
}
|
||||||
|
if len(denominations) == 0 {
|
||||||
|
return nil, fmt.Errorf("at least one denomination is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
res, err := tx.ExecContext(ctx,
|
||||||
|
"INSERT INTO chip_sets (name, is_builtin, created_at, updated_at) VALUES (?, 0, ?, ?)",
|
||||||
|
name, now, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert chip set: %w", err)
|
||||||
|
}
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get chip set id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, d := range denominations {
|
||||||
|
if d.Value <= 0 {
|
||||||
|
return nil, fmt.Errorf("denomination %d: value must be positive", i)
|
||||||
|
}
|
||||||
|
_, err := tx.ExecContext(ctx,
|
||||||
|
"INSERT INTO chip_denominations (chip_set_id, value, color_hex, label, sort_order) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
id, d.Value, d.ColorHex, d.Label, d.SortOrder,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert denomination %d: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, fmt.Errorf("commit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetChipSet(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChipSet retrieves a chip set by ID, including its denominations.
|
||||||
|
func (s *ChipSetService) GetChipSet(ctx context.Context, id int64) (*ChipSet, error) {
|
||||||
|
cs := &ChipSet{}
|
||||||
|
var createdAt, updatedAt int64
|
||||||
|
var isBuiltin int
|
||||||
|
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
"SELECT id, name, is_builtin, created_at, updated_at FROM chip_sets WHERE id = ?", id,
|
||||||
|
).Scan(&cs.ID, &cs.Name, &isBuiltin, &createdAt, &updatedAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, fmt.Errorf("chip set not found: %d", id)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get chip set: %w", err)
|
||||||
|
}
|
||||||
|
cs.IsBuiltin = isBuiltin != 0
|
||||||
|
cs.CreatedAt = time.Unix(createdAt, 0)
|
||||||
|
cs.UpdatedAt = time.Unix(updatedAt, 0)
|
||||||
|
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
"SELECT id, chip_set_id, value, color_hex, label, sort_order FROM chip_denominations WHERE chip_set_id = ? ORDER BY sort_order",
|
||||||
|
id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get denominations: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var d ChipDenomination
|
||||||
|
if err := rows.Scan(&d.ID, &d.ChipSetID, &d.Value, &d.ColorHex, &d.Label, &d.SortOrder); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan denomination: %w", err)
|
||||||
|
}
|
||||||
|
cs.Denominations = append(cs.Denominations, d)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate denominations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListChipSets returns all chip sets (without denominations for list performance).
|
||||||
|
func (s *ChipSetService) ListChipSets(ctx context.Context) ([]ChipSet, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
"SELECT id, name, is_builtin, created_at, updated_at FROM chip_sets ORDER BY is_builtin DESC, name",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list chip sets: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var sets []ChipSet
|
||||||
|
for rows.Next() {
|
||||||
|
var cs ChipSet
|
||||||
|
var isBuiltin int
|
||||||
|
var createdAt, updatedAt int64
|
||||||
|
if err := rows.Scan(&cs.ID, &cs.Name, &isBuiltin, &createdAt, &updatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan chip set: %w", err)
|
||||||
|
}
|
||||||
|
cs.IsBuiltin = isBuiltin != 0
|
||||||
|
cs.CreatedAt = time.Unix(createdAt, 0)
|
||||||
|
cs.UpdatedAt = time.Unix(updatedAt, 0)
|
||||||
|
sets = append(sets, cs)
|
||||||
|
}
|
||||||
|
return sets, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateChipSet updates a chip set name and replaces its denominations.
|
||||||
|
func (s *ChipSetService) UpdateChipSet(ctx context.Context, id int64, name string, denominations []ChipDenomination) error {
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("chip set name is required")
|
||||||
|
}
|
||||||
|
if len(denominations) == 0 {
|
||||||
|
return fmt.Errorf("at least one denomination is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
res, err := tx.ExecContext(ctx,
|
||||||
|
"UPDATE chip_sets SET name = ?, updated_at = ? WHERE id = ?",
|
||||||
|
name, now, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update chip set: %w", err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return fmt.Errorf("chip set not found: %d", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace denominations: delete old, insert new
|
||||||
|
if _, err := tx.ExecContext(ctx, "DELETE FROM chip_denominations WHERE chip_set_id = ?", id); err != nil {
|
||||||
|
return fmt.Errorf("delete old denominations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, d := range denominations {
|
||||||
|
if d.Value <= 0 {
|
||||||
|
return fmt.Errorf("denomination %d: value must be positive", i)
|
||||||
|
}
|
||||||
|
_, err := tx.ExecContext(ctx,
|
||||||
|
"INSERT INTO chip_denominations (chip_set_id, value, color_hex, label, sort_order) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
id, d.Value, d.ColorHex, d.Label, d.SortOrder,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert denomination %d: %w", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteChipSet deletes a chip set. Built-in chip sets cannot be deleted.
|
||||||
|
// Returns an error if the chip set is referenced by active tournaments.
|
||||||
|
func (s *ChipSetService) DeleteChipSet(ctx context.Context, id int64) error {
|
||||||
|
// Check if builtin
|
||||||
|
var isBuiltin int
|
||||||
|
err := s.db.QueryRowContext(ctx, "SELECT is_builtin FROM chip_sets WHERE id = ?", id).Scan(&isBuiltin)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return fmt.Errorf("chip set not found: %d", id)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check chip set: %w", err)
|
||||||
|
}
|
||||||
|
if isBuiltin != 0 {
|
||||||
|
return fmt.Errorf("cannot delete built-in chip set")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if referenced by active tournaments
|
||||||
|
var refCount int
|
||||||
|
err = s.db.QueryRowContext(ctx,
|
||||||
|
"SELECT COUNT(*) FROM tournaments WHERE chip_set_id = ? AND status NOT IN ('completed', 'cancelled')", id,
|
||||||
|
).Scan(&refCount)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check references: %w", err)
|
||||||
|
}
|
||||||
|
if refCount > 0 {
|
||||||
|
return fmt.Errorf("chip set is referenced by %d active tournament(s)", refCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := s.db.ExecContext(ctx, "DELETE FROM chip_sets WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete chip set: %w", err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return fmt.Errorf("chip set not found: %d", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DuplicateChipSet creates an independent copy of a chip set with a new name.
|
||||||
|
func (s *ChipSetService) DuplicateChipSet(ctx context.Context, id int64, newName string) (*ChipSet, error) {
|
||||||
|
original, err := s.GetChipSet(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get original: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if newName == "" {
|
||||||
|
newName = original.Name + " (Copy)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.CreateChipSet(ctx, newName, original.Denominations)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1,333 @@
|
||||||
package template
|
package template
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PayoutStructure represents a reusable payout configuration with entry-count brackets.
|
||||||
|
type PayoutStructure struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
IsBuiltin bool `json:"is_builtin"`
|
||||||
|
Brackets []PayoutBracket `json:"brackets,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PayoutBracket defines a payout tier set for a range of entry counts.
|
||||||
|
type PayoutBracket struct {
|
||||||
|
ID int64 `json:"id,omitempty"`
|
||||||
|
StructureID int64 `json:"structure_id,omitempty"`
|
||||||
|
MinEntries int `json:"min_entries"`
|
||||||
|
MaxEntries int `json:"max_entries"`
|
||||||
|
Tiers []PayoutTier `json:"tiers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PayoutTier represents a single position's payout percentage within a bracket.
|
||||||
|
type PayoutTier struct {
|
||||||
|
ID int64 `json:"id,omitempty"`
|
||||||
|
BracketID int64 `json:"bracket_id,omitempty"`
|
||||||
|
Position int `json:"position"`
|
||||||
|
PercentageBasisPoints int64 `json:"percentage_basis_points"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PayoutService provides CRUD operations for payout structures.
|
||||||
|
type PayoutService struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPayoutService creates a new PayoutService.
|
||||||
|
func NewPayoutService(db *sql.DB) *PayoutService {
|
||||||
|
return &PayoutService{db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateBrackets checks bracket and tier validity.
|
||||||
|
func validateBrackets(brackets []PayoutBracket) error {
|
||||||
|
if len(brackets) == 0 {
|
||||||
|
return fmt.Errorf("at least one bracket is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, b := range brackets {
|
||||||
|
if b.MinEntries > b.MaxEntries {
|
||||||
|
return fmt.Errorf("bracket %d: min_entries (%d) > max_entries (%d)", i, b.MinEntries, b.MaxEntries)
|
||||||
|
}
|
||||||
|
if len(b.Tiers) == 0 {
|
||||||
|
return fmt.Errorf("bracket %d: at least one tier is required", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check tier sum = 10000 basis points (100.00%)
|
||||||
|
var sum int64
|
||||||
|
for _, t := range b.Tiers {
|
||||||
|
if t.PercentageBasisPoints <= 0 {
|
||||||
|
return fmt.Errorf("bracket %d: tier position %d has non-positive percentage", i, t.Position)
|
||||||
|
}
|
||||||
|
sum += t.PercentageBasisPoints
|
||||||
|
}
|
||||||
|
if sum != 10000 {
|
||||||
|
return fmt.Errorf("bracket %d: tier percentages sum to %d basis points, expected 10000 (100.00%%)", i, sum)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check contiguous with previous bracket
|
||||||
|
if i > 0 {
|
||||||
|
prev := brackets[i-1]
|
||||||
|
if b.MinEntries != prev.MaxEntries+1 {
|
||||||
|
return fmt.Errorf("bracket %d: min_entries (%d) is not contiguous with previous bracket max_entries (%d)", i, b.MinEntries, prev.MaxEntries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreatePayoutStructure creates a new payout structure with brackets and tiers.
|
||||||
|
func (s *PayoutService) CreatePayoutStructure(ctx context.Context, name string, brackets []PayoutBracket) (*PayoutStructure, error) {
|
||||||
|
if name == "" {
|
||||||
|
return nil, fmt.Errorf("payout structure name is required")
|
||||||
|
}
|
||||||
|
if err := validateBrackets(brackets); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
res, err := tx.ExecContext(ctx,
|
||||||
|
"INSERT INTO payout_structures (name, is_builtin, created_at, updated_at) VALUES (?, 0, ?, ?)",
|
||||||
|
name, now, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("insert payout structure: %w", err)
|
||||||
|
}
|
||||||
|
id, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get payout structure id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := insertBrackets(ctx, tx, id, brackets); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return nil, fmt.Errorf("commit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.GetPayoutStructure(ctx, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertBrackets inserts brackets and tiers within a transaction.
|
||||||
|
func insertBrackets(ctx context.Context, tx *sql.Tx, structureID int64, brackets []PayoutBracket) error {
|
||||||
|
for i, b := range brackets {
|
||||||
|
res, err := tx.ExecContext(ctx,
|
||||||
|
"INSERT INTO payout_brackets (structure_id, min_entries, max_entries) VALUES (?, ?, ?)",
|
||||||
|
structureID, b.MinEntries, b.MaxEntries,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert bracket %d: %w", i, err)
|
||||||
|
}
|
||||||
|
bracketID, err := res.LastInsertId()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get bracket %d id: %w", i, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for j, t := range b.Tiers {
|
||||||
|
_, err := tx.ExecContext(ctx,
|
||||||
|
"INSERT INTO payout_tiers (bracket_id, position, percentage_basis_points) VALUES (?, ?, ?)",
|
||||||
|
bracketID, t.Position, t.PercentageBasisPoints,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("insert tier %d/%d: %w", i, j, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPayoutStructure retrieves a payout structure by ID, including brackets and tiers.
|
||||||
|
func (s *PayoutService) GetPayoutStructure(ctx context.Context, id int64) (*PayoutStructure, error) {
|
||||||
|
ps := &PayoutStructure{}
|
||||||
|
var isBuiltin int
|
||||||
|
var createdAt, updatedAt int64
|
||||||
|
|
||||||
|
err := s.db.QueryRowContext(ctx,
|
||||||
|
"SELECT id, name, is_builtin, created_at, updated_at FROM payout_structures WHERE id = ?", id,
|
||||||
|
).Scan(&ps.ID, &ps.Name, &isBuiltin, &createdAt, &updatedAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return nil, fmt.Errorf("payout structure not found: %d", id)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get payout structure: %w", err)
|
||||||
|
}
|
||||||
|
ps.IsBuiltin = isBuiltin != 0
|
||||||
|
ps.CreatedAt = time.Unix(createdAt, 0)
|
||||||
|
ps.UpdatedAt = time.Unix(updatedAt, 0)
|
||||||
|
|
||||||
|
// Load brackets
|
||||||
|
bracketRows, err := s.db.QueryContext(ctx,
|
||||||
|
"SELECT id, structure_id, min_entries, max_entries FROM payout_brackets WHERE structure_id = ? ORDER BY min_entries", id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get brackets: %w", err)
|
||||||
|
}
|
||||||
|
defer bracketRows.Close()
|
||||||
|
|
||||||
|
for bracketRows.Next() {
|
||||||
|
var b PayoutBracket
|
||||||
|
if err := bracketRows.Scan(&b.ID, &b.StructureID, &b.MinEntries, &b.MaxEntries); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan bracket: %w", err)
|
||||||
|
}
|
||||||
|
ps.Brackets = append(ps.Brackets, b)
|
||||||
|
}
|
||||||
|
if err := bracketRows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate brackets: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load tiers for each bracket
|
||||||
|
for i := range ps.Brackets {
|
||||||
|
tierRows, err := s.db.QueryContext(ctx,
|
||||||
|
"SELECT id, bracket_id, position, percentage_basis_points FROM payout_tiers WHERE bracket_id = ? ORDER BY position",
|
||||||
|
ps.Brackets[i].ID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get tiers for bracket %d: %w", i, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for tierRows.Next() {
|
||||||
|
var t PayoutTier
|
||||||
|
if err := tierRows.Scan(&t.ID, &t.BracketID, &t.Position, &t.PercentageBasisPoints); err != nil {
|
||||||
|
tierRows.Close()
|
||||||
|
return nil, fmt.Errorf("scan tier: %w", err)
|
||||||
|
}
|
||||||
|
ps.Brackets[i].Tiers = append(ps.Brackets[i].Tiers, t)
|
||||||
|
}
|
||||||
|
tierRows.Close()
|
||||||
|
if err := tierRows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("iterate tiers: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListPayoutStructures returns all payout structures (without nested data).
|
||||||
|
func (s *PayoutService) ListPayoutStructures(ctx context.Context) ([]PayoutStructure, error) {
|
||||||
|
rows, err := s.db.QueryContext(ctx,
|
||||||
|
"SELECT id, name, is_builtin, created_at, updated_at FROM payout_structures ORDER BY is_builtin DESC, name",
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list payout structures: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var structs []PayoutStructure
|
||||||
|
for rows.Next() {
|
||||||
|
var ps PayoutStructure
|
||||||
|
var isBuiltin int
|
||||||
|
var createdAt, updatedAt int64
|
||||||
|
if err := rows.Scan(&ps.ID, &ps.Name, &isBuiltin, &createdAt, &updatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("scan payout structure: %w", err)
|
||||||
|
}
|
||||||
|
ps.IsBuiltin = isBuiltin != 0
|
||||||
|
ps.CreatedAt = time.Unix(createdAt, 0)
|
||||||
|
ps.UpdatedAt = time.Unix(updatedAt, 0)
|
||||||
|
structs = append(structs, ps)
|
||||||
|
}
|
||||||
|
return structs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdatePayoutStructure updates a payout structure and replaces its brackets/tiers.
|
||||||
|
func (s *PayoutService) UpdatePayoutStructure(ctx context.Context, id int64, name string, brackets []PayoutBracket) error {
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("payout structure name is required")
|
||||||
|
}
|
||||||
|
if err := validateBrackets(brackets); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("begin transaction: %w", err)
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
res, err := tx.ExecContext(ctx,
|
||||||
|
"UPDATE payout_structures SET name = ?, updated_at = ? WHERE id = ?",
|
||||||
|
name, now, id,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("update payout structure: %w", err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return fmt.Errorf("payout structure not found: %d", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete old brackets (cascades to tiers)
|
||||||
|
if _, err := tx.ExecContext(ctx, "DELETE FROM payout_brackets WHERE structure_id = ?", id); err != nil {
|
||||||
|
return fmt.Errorf("delete old brackets: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := insertBrackets(ctx, tx, id, brackets); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeletePayoutStructure deletes a payout structure. Built-in structures cannot be deleted.
|
||||||
|
func (s *PayoutService) DeletePayoutStructure(ctx context.Context, id int64) error {
|
||||||
|
var isBuiltin int
|
||||||
|
err := s.db.QueryRowContext(ctx, "SELECT is_builtin FROM payout_structures WHERE id = ?", id).Scan(&isBuiltin)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return fmt.Errorf("payout structure not found: %d", id)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check payout structure: %w", err)
|
||||||
|
}
|
||||||
|
if isBuiltin != 0 {
|
||||||
|
return fmt.Errorf("cannot delete built-in payout structure")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if referenced by active tournaments
|
||||||
|
var refCount int
|
||||||
|
err = s.db.QueryRowContext(ctx,
|
||||||
|
"SELECT COUNT(*) FROM tournaments WHERE payout_structure_id = ? AND status NOT IN ('completed', 'cancelled')", id,
|
||||||
|
).Scan(&refCount)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check references: %w", err)
|
||||||
|
}
|
||||||
|
if refCount > 0 {
|
||||||
|
return fmt.Errorf("payout structure is referenced by %d active tournament(s)", refCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := s.db.ExecContext(ctx, "DELETE FROM payout_structures WHERE id = ?", id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete payout structure: %w", err)
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return fmt.Errorf("payout structure not found: %d", id)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DuplicatePayoutStructure creates an independent copy of a payout structure.
|
||||||
|
func (s *PayoutService) DuplicatePayoutStructure(ctx context.Context, id int64, newName string) (*PayoutStructure, error) {
|
||||||
|
original, err := s.GetPayoutStructure(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get original: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if newName == "" {
|
||||||
|
newName = original.Name + " (Copy)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.CreatePayoutStructure(ctx, newName, original.Brackets)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1,342 @@
|
||||||
package template
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue