felt/internal/template/chipset.go
Mikkel Georgsen 99545bd128 feat(01-05): implement building block CRUD and API routes
- ChipSetService with full CRUD, duplication, builtin protection
- BlindStructure service with level validation and CRUD
- PayoutStructure service with bracket/tier nesting and 100% sum validation
- BuyinConfig service with rake split validation and all rebuy/addon fields
- TournamentTemplate service with FK validation and expanded view
- WizardService generates blind structures from high-level inputs
- API routes: /chip-sets, /blind-structures, /payout-structures, /buyin-configs, /tournament-templates
- All mutations require admin role, reads require floor+
- Wired template routes into server protected group

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:55:47 +01:00

258 lines
7.7 KiB
Go

// Package template provides CRUD operations for tournament building blocks:
// chip sets, payout structures, buy-in configs, and tournament templates.
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)
}