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:
Mikkel Georgsen 2026-03-01 03:55:47 +01:00
parent 47e1f19edd
commit 99545bd128
8 changed files with 2581 additions and 9 deletions

View file

@ -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)
}

View file

@ -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
}

View 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)
}

View file

@ -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"))
}) })

View file

@ -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, &copy)
}
// boolToInt converts a Go bool to SQLite integer.
func boolToInt(b bool) int {
if b {
return 1
}
return 0
}

View file

@ -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)
}

View file

@ -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)
}

View file

@ -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, &copy)
}
// SaveAsTemplate creates a new template from a tournament's current config.
func (s *TournamentTemplateService) SaveAsTemplate(ctx context.Context, tournamentID string, name string) (*TournamentTemplate, error) {
if name == "" {
return nil, fmt.Errorf("template name is required")
}
var tmpl TournamentTemplate
var isPKO int
err := s.db.QueryRowContext(ctx,
`SELECT chip_set_id, blind_structure_id, payout_structure_id, buyin_config_id,
points_formula_id, min_players, max_players, early_signup_bonus_chips, early_signup_cutoff,
punctuality_bonus_chips, is_pko
FROM tournaments WHERE id = ?`, tournamentID,
).Scan(
&tmpl.ChipSetID, &tmpl.BlindStructureID, &tmpl.PayoutStructureID, &tmpl.BuyinConfigID,
&tmpl.PointsFormulaID, &tmpl.MinPlayers, &tmpl.MaxPlayers, &tmpl.EarlySignupBonusChips, &tmpl.EarlySignupCutoff,
&tmpl.PunctualityBonusChips, &isPKO,
)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("tournament not found: %s", tournamentID)
}
if err != nil {
return nil, fmt.Errorf("get tournament config: %w", err)
}
tmpl.Name = name
tmpl.IsPKO = isPKO != 0
return s.CreateTemplate(ctx, &tmpl)
}