- 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>
333 lines
10 KiB
Go
333 lines
10 KiB
Go
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)
|
|
}
|