From 99545bd1285ef9b65240ed0266db903e1a59a161 Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Sun, 1 Mar 2026 03:55:47 +0100 Subject: [PATCH] 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 --- internal/blind/structure.go | 305 +++++++++++++ internal/blind/wizard.go | 301 +++++++++++++ internal/server/routes/templates.go | 644 ++++++++++++++++++++++++++++ internal/server/server.go | 45 +- internal/template/buyin.go | 365 ++++++++++++++++ internal/template/chipset.go | 257 +++++++++++ internal/template/payout.go | 332 ++++++++++++++ internal/template/tournament.go | 341 +++++++++++++++ 8 files changed, 2581 insertions(+), 9 deletions(-) create mode 100644 internal/server/routes/templates.go diff --git a/internal/blind/structure.go b/internal/blind/structure.go index d223211..09c2f08 100644 --- a/internal/blind/structure.go +++ b/internal/blind/structure.go @@ -1 +1,306 @@ +// Package blind provides blind structure management: CRUD operations, +// the structure wizard algorithm, and built-in template definitions. 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) +} diff --git a/internal/blind/wizard.go b/internal/blind/wizard.go index d223211..8db37a5 100644 --- a/internal/blind/wizard.go +++ b/internal/blind/wizard.go @@ -1 +1,302 @@ 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 +} diff --git a/internal/server/routes/templates.go b/internal/server/routes/templates.go new file mode 100644 index 0000000..0d70a8d --- /dev/null +++ b/internal/server/routes/templates.go @@ -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) +} diff --git a/internal/server/server.go b/internal/server/server.go index 19fa6f6..6a267e0 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -15,7 +15,10 @@ import ( chimw "github.com/go-chi/chi/v5/middleware" "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/routes" "github.com/felt-app/felt/internal/server/ws" natsserver "github.com/nats-io/nats-server/v2/server" ) @@ -29,14 +32,16 @@ type Config struct { // Server wraps the HTTP server with all dependencies. type Server struct { - httpServer *http.Server - hub *ws.Hub - db *sql.DB - nats *natsserver.Server + httpServer *http.Server + hub *ws.Hub + db *sql.DB + nats *natsserver.Server + authService *auth.AuthService + clockRegistry *clock.Registry } // 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() // 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)) s := &Server{ - hub: hub, - db: db, - nats: nats, + hub: hub, + db: db, + nats: nats, + authService: authService, + clockRegistry: clockRegistry, } + // Auth handler + authHandler := routes.NewAuthHandler(authService) + + // Clock handler + clockHandler := routes.NewClockHandler(clockRegistry, db) + // API routes r.Route("/api/v1", func(r chi.Router) { // Public endpoints (no auth required) r.Get("/health", s.handleHealth) + // Auth endpoints (login is public, others require auth) + r.Post("/auth/login", authHandler.HandleLogin) + // Protected endpoints r.Group(func(r chi.Router) { 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("/players", stubHandler("players")) }) diff --git a/internal/template/buyin.go b/internal/template/buyin.go index 38cdfe4..2fac1ee 100644 --- a/internal/template/buyin.go +++ b/internal/template/buyin.go @@ -1 +1,366 @@ package template + +import ( + "context" + "database/sql" + "fmt" + "time" +) + +// BuyinConfig represents a reusable buy-in configuration for tournaments. +type BuyinConfig struct { + ID int64 `json:"id"` + Name string `json:"name"` + BuyinAmount int64 `json:"buyin_amount"` + StartingChips int64 `json:"starting_chips"` + RakeTotal int64 `json:"rake_total"` + BountyAmount int64 `json:"bounty_amount"` + BountyChip int64 `json:"bounty_chip"` + RebuyAllowed bool `json:"rebuy_allowed"` + RebuyCost int64 `json:"rebuy_cost"` + RebuyChips int64 `json:"rebuy_chips"` + RebuyRake int64 `json:"rebuy_rake"` + RebuyLimit int `json:"rebuy_limit"` + RebuyLevelCutoff *int `json:"rebuy_level_cutoff,omitempty"` + RebuyTimeCutoffSeconds *int `json:"rebuy_time_cutoff_seconds,omitempty"` + RebuyChipThreshold *int64 `json:"rebuy_chip_threshold,omitempty"` + AddonAllowed bool `json:"addon_allowed"` + AddonCost int64 `json:"addon_cost"` + AddonChips int64 `json:"addon_chips"` + AddonRake int64 `json:"addon_rake"` + AddonLevelStart *int `json:"addon_level_start,omitempty"` + AddonLevelEnd *int `json:"addon_level_end,omitempty"` + ReentryAllowed bool `json:"reentry_allowed"` + ReentryLimit int `json:"reentry_limit"` + LateRegLevelCutoff *int `json:"late_reg_level_cutoff,omitempty"` + LateRegTimeCutoffSecs *int `json:"late_reg_time_cutoff_seconds,omitempty"` + RakeSplits []RakeSplit `json:"rake_splits,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// RakeSplit defines how rake is distributed across categories. +type RakeSplit struct { + ID int64 `json:"id,omitempty"` + BuyinConfigID int64 `json:"buyin_config_id,omitempty"` + Category string `json:"category"` + Amount int64 `json:"amount"` +} + +// BuyinService provides CRUD operations for buy-in configs. +type BuyinService struct { + db *sql.DB +} + +// NewBuyinService creates a new BuyinService. +func NewBuyinService(db *sql.DB) *BuyinService { + return &BuyinService{db: db} +} + +// validateBuyinConfig checks that the config is valid. +func validateBuyinConfig(cfg *BuyinConfig) error { + if cfg.Name == "" { + return fmt.Errorf("buy-in config name is required") + } + if cfg.BuyinAmount < 0 { + return fmt.Errorf("buyin_amount must be non-negative") + } + if cfg.StartingChips < 0 { + return fmt.Errorf("starting_chips must be non-negative") + } + if cfg.RakeTotal < 0 { + return fmt.Errorf("rake_total must be non-negative") + } + if cfg.BountyAmount < 0 { + return fmt.Errorf("bounty_amount must be non-negative") + } + if cfg.BountyAmount > 0 && cfg.BountyChip <= 0 { + return fmt.Errorf("bounty_chip must be positive when bounty_amount > 0") + } + if cfg.RebuyCost < 0 || cfg.RebuyChips < 0 || cfg.RebuyRake < 0 { + return fmt.Errorf("rebuy values must be non-negative") + } + if cfg.RebuyLimit < 0 { + return fmt.Errorf("rebuy_limit must be non-negative") + } + if cfg.AddonCost < 0 || cfg.AddonChips < 0 || cfg.AddonRake < 0 { + return fmt.Errorf("addon values must be non-negative") + } + if cfg.ReentryLimit < 0 { + return fmt.Errorf("reentry_limit must be non-negative") + } + + // Validate rake splits sum = rake_total + if len(cfg.RakeSplits) > 0 { + var splitSum int64 + for _, split := range cfg.RakeSplits { + if split.Amount < 0 { + return fmt.Errorf("rake split amount must be non-negative") + } + validCategories := map[string]bool{"house": true, "staff": true, "league": true, "season_reserve": true} + if !validCategories[split.Category] { + return fmt.Errorf("invalid rake split category: %q", split.Category) + } + splitSum += split.Amount + } + if splitSum != cfg.RakeTotal { + return fmt.Errorf("rake splits sum (%d) does not match rake_total (%d)", splitSum, cfg.RakeTotal) + } + } + + return nil +} + +// CreateBuyinConfig creates a new buy-in configuration. +func (s *BuyinService) CreateBuyinConfig(ctx context.Context, cfg *BuyinConfig) (*BuyinConfig, error) { + if err := validateBuyinConfig(cfg); err != nil { + return nil, err + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin transaction: %w", err) + } + defer tx.Rollback() + + now := time.Now().Unix() + res, err := tx.ExecContext(ctx, + `INSERT INTO buyin_configs (name, buyin_amount, starting_chips, rake_total, bounty_amount, bounty_chip, + rebuy_allowed, rebuy_cost, rebuy_chips, rebuy_rake, rebuy_limit, rebuy_level_cutoff, rebuy_time_cutoff_seconds, rebuy_chip_threshold, + addon_allowed, addon_cost, addon_chips, addon_rake, addon_level_start, addon_level_end, + reentry_allowed, reentry_limit, late_reg_level_cutoff, late_reg_time_cutoff_seconds, + created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + cfg.Name, cfg.BuyinAmount, cfg.StartingChips, cfg.RakeTotal, cfg.BountyAmount, cfg.BountyChip, + boolToInt(cfg.RebuyAllowed), cfg.RebuyCost, cfg.RebuyChips, cfg.RebuyRake, cfg.RebuyLimit, + cfg.RebuyLevelCutoff, cfg.RebuyTimeCutoffSeconds, cfg.RebuyChipThreshold, + boolToInt(cfg.AddonAllowed), cfg.AddonCost, cfg.AddonChips, cfg.AddonRake, + cfg.AddonLevelStart, cfg.AddonLevelEnd, + boolToInt(cfg.ReentryAllowed), cfg.ReentryLimit, + cfg.LateRegLevelCutoff, cfg.LateRegTimeCutoffSecs, + now, now, + ) + if err != nil { + return nil, fmt.Errorf("insert buyin config: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return nil, fmt.Errorf("get buyin config id: %w", err) + } + + if err := insertRakeSplits(ctx, tx, id, cfg.RakeSplits); err != nil { + return nil, err + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit: %w", err) + } + + return s.GetBuyinConfig(ctx, id) +} + +func insertRakeSplits(ctx context.Context, tx *sql.Tx, configID int64, splits []RakeSplit) error { + for i, split := range splits { + _, err := tx.ExecContext(ctx, + "INSERT INTO rake_splits (buyin_config_id, category, amount) VALUES (?, ?, ?)", + configID, split.Category, split.Amount, + ) + if err != nil { + return fmt.Errorf("insert rake split %d: %w", i, err) + } + } + return nil +} + +// GetBuyinConfig retrieves a buy-in config by ID, including rake splits. +func (s *BuyinService) GetBuyinConfig(ctx context.Context, id int64) (*BuyinConfig, error) { + cfg := &BuyinConfig{} + var createdAt, updatedAt int64 + var rebuyAllowed, addonAllowed, reentryAllowed int + + err := s.db.QueryRowContext(ctx, + `SELECT id, name, buyin_amount, starting_chips, rake_total, bounty_amount, bounty_chip, + rebuy_allowed, rebuy_cost, rebuy_chips, rebuy_rake, rebuy_limit, rebuy_level_cutoff, rebuy_time_cutoff_seconds, rebuy_chip_threshold, + addon_allowed, addon_cost, addon_chips, addon_rake, addon_level_start, addon_level_end, + reentry_allowed, reentry_limit, late_reg_level_cutoff, late_reg_time_cutoff_seconds, + created_at, updated_at + FROM buyin_configs WHERE id = ?`, id, + ).Scan( + &cfg.ID, &cfg.Name, &cfg.BuyinAmount, &cfg.StartingChips, &cfg.RakeTotal, &cfg.BountyAmount, &cfg.BountyChip, + &rebuyAllowed, &cfg.RebuyCost, &cfg.RebuyChips, &cfg.RebuyRake, &cfg.RebuyLimit, + &cfg.RebuyLevelCutoff, &cfg.RebuyTimeCutoffSeconds, &cfg.RebuyChipThreshold, + &addonAllowed, &cfg.AddonCost, &cfg.AddonChips, &cfg.AddonRake, + &cfg.AddonLevelStart, &cfg.AddonLevelEnd, + &reentryAllowed, &cfg.ReentryLimit, + &cfg.LateRegLevelCutoff, &cfg.LateRegTimeCutoffSecs, + &createdAt, &updatedAt, + ) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("buyin config not found: %d", id) + } + if err != nil { + return nil, fmt.Errorf("get buyin config: %w", err) + } + cfg.RebuyAllowed = rebuyAllowed != 0 + cfg.AddonAllowed = addonAllowed != 0 + cfg.ReentryAllowed = reentryAllowed != 0 + cfg.CreatedAt = time.Unix(createdAt, 0) + cfg.UpdatedAt = time.Unix(updatedAt, 0) + + // Load rake splits + rows, err := s.db.QueryContext(ctx, + "SELECT id, buyin_config_id, category, amount FROM rake_splits WHERE buyin_config_id = ? ORDER BY category", id, + ) + if err != nil { + return nil, fmt.Errorf("get rake splits: %w", err) + } + defer rows.Close() + + for rows.Next() { + var split RakeSplit + if err := rows.Scan(&split.ID, &split.BuyinConfigID, &split.Category, &split.Amount); err != nil { + return nil, fmt.Errorf("scan rake split: %w", err) + } + cfg.RakeSplits = append(cfg.RakeSplits, split) + } + return cfg, rows.Err() +} + +// ListBuyinConfigs returns all buy-in configs (without rake splits). +func (s *BuyinService) ListBuyinConfigs(ctx context.Context) ([]BuyinConfig, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, name, buyin_amount, starting_chips, rake_total, bounty_amount, bounty_chip, + rebuy_allowed, addon_allowed, reentry_allowed, + created_at, updated_at + FROM buyin_configs ORDER BY name`, + ) + if err != nil { + return nil, fmt.Errorf("list buyin configs: %w", err) + } + defer rows.Close() + + var configs []BuyinConfig + for rows.Next() { + var cfg BuyinConfig + var createdAt, updatedAt int64 + var rebuyAllowed, addonAllowed, reentryAllowed int + if err := rows.Scan( + &cfg.ID, &cfg.Name, &cfg.BuyinAmount, &cfg.StartingChips, &cfg.RakeTotal, &cfg.BountyAmount, &cfg.BountyChip, + &rebuyAllowed, &addonAllowed, &reentryAllowed, + &createdAt, &updatedAt, + ); err != nil { + return nil, fmt.Errorf("scan buyin config: %w", err) + } + cfg.RebuyAllowed = rebuyAllowed != 0 + cfg.AddonAllowed = addonAllowed != 0 + cfg.ReentryAllowed = reentryAllowed != 0 + cfg.CreatedAt = time.Unix(createdAt, 0) + cfg.UpdatedAt = time.Unix(updatedAt, 0) + configs = append(configs, cfg) + } + return configs, rows.Err() +} + +// UpdateBuyinConfig updates a buy-in config and replaces its rake splits. +func (s *BuyinService) UpdateBuyinConfig(ctx context.Context, id int64, cfg *BuyinConfig) error { + cfg.ID = id + if err := validateBuyinConfig(cfg); err != nil { + return err + } + + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin transaction: %w", err) + } + defer tx.Rollback() + + now := time.Now().Unix() + res, err := tx.ExecContext(ctx, + `UPDATE buyin_configs SET name = ?, buyin_amount = ?, starting_chips = ?, rake_total = ?, + bounty_amount = ?, bounty_chip = ?, + rebuy_allowed = ?, rebuy_cost = ?, rebuy_chips = ?, rebuy_rake = ?, rebuy_limit = ?, + rebuy_level_cutoff = ?, rebuy_time_cutoff_seconds = ?, rebuy_chip_threshold = ?, + addon_allowed = ?, addon_cost = ?, addon_chips = ?, addon_rake = ?, + addon_level_start = ?, addon_level_end = ?, + reentry_allowed = ?, reentry_limit = ?, + late_reg_level_cutoff = ?, late_reg_time_cutoff_seconds = ?, + updated_at = ? + WHERE id = ?`, + cfg.Name, cfg.BuyinAmount, cfg.StartingChips, cfg.RakeTotal, + cfg.BountyAmount, cfg.BountyChip, + boolToInt(cfg.RebuyAllowed), cfg.RebuyCost, cfg.RebuyChips, cfg.RebuyRake, cfg.RebuyLimit, + cfg.RebuyLevelCutoff, cfg.RebuyTimeCutoffSeconds, cfg.RebuyChipThreshold, + boolToInt(cfg.AddonAllowed), cfg.AddonCost, cfg.AddonChips, cfg.AddonRake, + cfg.AddonLevelStart, cfg.AddonLevelEnd, + boolToInt(cfg.ReentryAllowed), cfg.ReentryLimit, + cfg.LateRegLevelCutoff, cfg.LateRegTimeCutoffSecs, + now, id, + ) + if err != nil { + return fmt.Errorf("update buyin config: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("buyin config not found: %d", id) + } + + // Replace rake splits + if _, err := tx.ExecContext(ctx, "DELETE FROM rake_splits WHERE buyin_config_id = ?", id); err != nil { + return fmt.Errorf("delete old rake splits: %w", err) + } + if err := insertRakeSplits(ctx, tx, id, cfg.RakeSplits); err != nil { + return err + } + + return tx.Commit() +} + +// DeleteBuyinConfig deletes a buy-in config. +func (s *BuyinService) DeleteBuyinConfig(ctx context.Context, id int64) error { + // Check if referenced by active tournaments + var refCount int + err := s.db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM tournaments WHERE buyin_config_id = ? AND status NOT IN ('completed', 'cancelled')", id, + ).Scan(&refCount) + if err != nil { + return fmt.Errorf("check references: %w", err) + } + if refCount > 0 { + return fmt.Errorf("buyin config is referenced by %d active tournament(s)", refCount) + } + + res, err := s.db.ExecContext(ctx, "DELETE FROM buyin_configs WHERE id = ?", id) + if err != nil { + return fmt.Errorf("delete buyin config: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("buyin config not found: %d", id) + } + return nil +} + +// DuplicateBuyinConfig creates an independent copy of a buy-in config. +func (s *BuyinService) DuplicateBuyinConfig(ctx context.Context, id int64, newName string) (*BuyinConfig, error) { + original, err := s.GetBuyinConfig(ctx, id) + if err != nil { + return nil, fmt.Errorf("get original: %w", err) + } + + if newName == "" { + newName = original.Name + " (Copy)" + } + + copy := *original + copy.ID = 0 + copy.Name = newName + return s.CreateBuyinConfig(ctx, ©) +} + +// boolToInt converts a Go bool to SQLite integer. +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} diff --git a/internal/template/chipset.go b/internal/template/chipset.go index 38cdfe4..608ca3b 100644 --- a/internal/template/chipset.go +++ b/internal/template/chipset.go @@ -1 +1,258 @@ +// 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) +} diff --git a/internal/template/payout.go b/internal/template/payout.go index 38cdfe4..8112074 100644 --- a/internal/template/payout.go +++ b/internal/template/payout.go @@ -1 +1,333 @@ 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) +} diff --git a/internal/template/tournament.go b/internal/template/tournament.go index 38cdfe4..fd090b8 100644 --- a/internal/template/tournament.go +++ b/internal/template/tournament.go @@ -1 +1,342 @@ package template + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/felt-app/felt/internal/blind" +) + +// TournamentTemplate composes building blocks into a reusable tournament configuration. +type TournamentTemplate struct { + ID int64 `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + ChipSetID int64 `json:"chip_set_id"` + BlindStructureID int64 `json:"blind_structure_id"` + PayoutStructureID int64 `json:"payout_structure_id"` + BuyinConfigID int64 `json:"buyin_config_id"` + PointsFormulaID *int64 `json:"points_formula_id,omitempty"` + MinPlayers int `json:"min_players"` + MaxPlayers *int `json:"max_players,omitempty"` + EarlySignupBonusChips int64 `json:"early_signup_bonus_chips"` + EarlySignupCutoff *string `json:"early_signup_cutoff,omitempty"` + PunctualityBonusChips int64 `json:"punctuality_bonus_chips"` + IsPKO bool `json:"is_pko"` + IsBuiltin bool `json:"is_builtin"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + + // Summary fields populated by GetTemplate (names only, not full data) + ChipSetName string `json:"chip_set_name,omitempty"` + BlindStructureName string `json:"blind_structure_name,omitempty"` + PayoutStructureName string `json:"payout_structure_name,omitempty"` + BuyinConfigName string `json:"buyin_config_name,omitempty"` +} + +// ExpandedTemplate returns a template with ALL building block data populated. +type ExpandedTemplate struct { + TournamentTemplate + ChipSet *ChipSet `json:"chip_set"` + BlindStructure *blind.BlindStructure `json:"blind_structure"` + PayoutStructure *PayoutStructure `json:"payout_structure"` + BuyinConfig *BuyinConfig `json:"buyin_config"` +} + +// TournamentTemplateService provides CRUD operations for tournament templates. +type TournamentTemplateService struct { + db *sql.DB + chips *ChipSetService + blinds *blind.StructureService + payouts *PayoutService + buyins *BuyinService +} + +// NewTournamentTemplateService creates a new TournamentTemplateService. +func NewTournamentTemplateService(db *sql.DB) *TournamentTemplateService { + return &TournamentTemplateService{ + db: db, + chips: NewChipSetService(db), + blinds: blind.NewStructureService(db), + payouts: NewPayoutService(db), + buyins: NewBuyinService(db), + } +} + +// CreateTemplate creates a new tournament template, validating all FK references exist. +func (s *TournamentTemplateService) CreateTemplate(ctx context.Context, tmpl *TournamentTemplate) (*TournamentTemplate, error) { + if tmpl.Name == "" { + return nil, fmt.Errorf("template name is required") + } + + // Validate FK references exist + if err := s.validateReferences(ctx, tmpl); err != nil { + return nil, err + } + + now := time.Now().Unix() + isPKO := boolToInt(tmpl.IsPKO) + + res, err := s.db.ExecContext(ctx, + `INSERT INTO tournament_templates (name, description, chip_set_id, blind_structure_id, payout_structure_id, buyin_config_id, + points_formula_id, min_players, max_players, early_signup_bonus_chips, early_signup_cutoff, + punctuality_bonus_chips, is_pko, is_builtin, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?)`, + tmpl.Name, tmpl.Description, tmpl.ChipSetID, tmpl.BlindStructureID, tmpl.PayoutStructureID, tmpl.BuyinConfigID, + tmpl.PointsFormulaID, tmpl.MinPlayers, tmpl.MaxPlayers, tmpl.EarlySignupBonusChips, tmpl.EarlySignupCutoff, + tmpl.PunctualityBonusChips, isPKO, now, now, + ) + if err != nil { + return nil, fmt.Errorf("insert template: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return nil, fmt.Errorf("get template id: %w", err) + } + + return s.GetTemplate(ctx, id) +} + +// validateReferences checks that all FK references in a template point to existing entities. +func (s *TournamentTemplateService) validateReferences(ctx context.Context, tmpl *TournamentTemplate) error { + // Check chip set exists + var exists int + if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM chip_sets WHERE id = ?", tmpl.ChipSetID).Scan(&exists); err != nil || exists == 0 { + return fmt.Errorf("chip set %d does not exist", tmpl.ChipSetID) + } + if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM blind_structures WHERE id = ?", tmpl.BlindStructureID).Scan(&exists); err != nil || exists == 0 { + return fmt.Errorf("blind structure %d does not exist", tmpl.BlindStructureID) + } + if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM payout_structures WHERE id = ?", tmpl.PayoutStructureID).Scan(&exists); err != nil || exists == 0 { + return fmt.Errorf("payout structure %d does not exist", tmpl.PayoutStructureID) + } + if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM buyin_configs WHERE id = ?", tmpl.BuyinConfigID).Scan(&exists); err != nil || exists == 0 { + return fmt.Errorf("buyin config %d does not exist", tmpl.BuyinConfigID) + } + if tmpl.PointsFormulaID != nil { + if err := s.db.QueryRowContext(ctx, "SELECT COUNT(*) FROM points_formulas WHERE id = ?", *tmpl.PointsFormulaID).Scan(&exists); err != nil || exists == 0 { + return fmt.Errorf("points formula %d does not exist", *tmpl.PointsFormulaID) + } + } + return nil +} + +// GetTemplate retrieves a template by ID with building block summary names. +func (s *TournamentTemplateService) GetTemplate(ctx context.Context, id int64) (*TournamentTemplate, error) { + tmpl := &TournamentTemplate{} + var createdAt, updatedAt int64 + var isPKO, isBuiltin int + + err := s.db.QueryRowContext(ctx, + `SELECT t.id, t.name, t.description, t.chip_set_id, t.blind_structure_id, t.payout_structure_id, t.buyin_config_id, + t.points_formula_id, t.min_players, t.max_players, t.early_signup_bonus_chips, t.early_signup_cutoff, + t.punctuality_bonus_chips, t.is_pko, t.is_builtin, t.created_at, t.updated_at, + cs.name, bs.name, ps.name, bc.name + FROM tournament_templates t + JOIN chip_sets cs ON t.chip_set_id = cs.id + JOIN blind_structures bs ON t.blind_structure_id = bs.id + JOIN payout_structures ps ON t.payout_structure_id = ps.id + JOIN buyin_configs bc ON t.buyin_config_id = bc.id + WHERE t.id = ?`, id, + ).Scan( + &tmpl.ID, &tmpl.Name, &tmpl.Description, &tmpl.ChipSetID, &tmpl.BlindStructureID, &tmpl.PayoutStructureID, &tmpl.BuyinConfigID, + &tmpl.PointsFormulaID, &tmpl.MinPlayers, &tmpl.MaxPlayers, &tmpl.EarlySignupBonusChips, &tmpl.EarlySignupCutoff, + &tmpl.PunctualityBonusChips, &isPKO, &isBuiltin, &createdAt, &updatedAt, + &tmpl.ChipSetName, &tmpl.BlindStructureName, &tmpl.PayoutStructureName, &tmpl.BuyinConfigName, + ) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("tournament template not found: %d", id) + } + if err != nil { + return nil, fmt.Errorf("get template: %w", err) + } + tmpl.IsPKO = isPKO != 0 + tmpl.IsBuiltin = isBuiltin != 0 + tmpl.CreatedAt = time.Unix(createdAt, 0) + tmpl.UpdatedAt = time.Unix(updatedAt, 0) + + return tmpl, nil +} + +// GetTemplateExpanded retrieves a template with ALL building block data populated. +func (s *TournamentTemplateService) GetTemplateExpanded(ctx context.Context, id int64) (*ExpandedTemplate, error) { + tmpl, err := s.GetTemplate(ctx, id) + if err != nil { + return nil, err + } + + expanded := &ExpandedTemplate{TournamentTemplate: *tmpl} + + expanded.ChipSet, err = s.chips.GetChipSet(ctx, tmpl.ChipSetID) + if err != nil { + return nil, fmt.Errorf("expand chip set: %w", err) + } + + expanded.BlindStructure, err = s.blinds.GetStructure(ctx, tmpl.BlindStructureID) + if err != nil { + return nil, fmt.Errorf("expand blind structure: %w", err) + } + + expanded.PayoutStructure, err = s.payouts.GetPayoutStructure(ctx, tmpl.PayoutStructureID) + if err != nil { + return nil, fmt.Errorf("expand payout structure: %w", err) + } + + expanded.BuyinConfig, err = s.buyins.GetBuyinConfig(ctx, tmpl.BuyinConfigID) + if err != nil { + return nil, fmt.Errorf("expand buyin config: %w", err) + } + + return expanded, nil +} + +// ListTemplates returns all tournament templates with building block summary names. +func (s *TournamentTemplateService) ListTemplates(ctx context.Context) ([]TournamentTemplate, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT t.id, t.name, t.description, t.chip_set_id, t.blind_structure_id, t.payout_structure_id, t.buyin_config_id, + t.points_formula_id, t.min_players, t.max_players, t.early_signup_bonus_chips, t.early_signup_cutoff, + t.punctuality_bonus_chips, t.is_pko, t.is_builtin, t.created_at, t.updated_at, + cs.name, bs.name, ps.name, bc.name + FROM tournament_templates t + JOIN chip_sets cs ON t.chip_set_id = cs.id + JOIN blind_structures bs ON t.blind_structure_id = bs.id + JOIN payout_structures ps ON t.payout_structure_id = ps.id + JOIN buyin_configs bc ON t.buyin_config_id = bc.id + ORDER BY t.is_builtin DESC, t.name`, + ) + if err != nil { + return nil, fmt.Errorf("list templates: %w", err) + } + defer rows.Close() + + var templates []TournamentTemplate + for rows.Next() { + var tmpl TournamentTemplate + var createdAt, updatedAt int64 + var isPKO, isBuiltin int + if err := rows.Scan( + &tmpl.ID, &tmpl.Name, &tmpl.Description, &tmpl.ChipSetID, &tmpl.BlindStructureID, &tmpl.PayoutStructureID, &tmpl.BuyinConfigID, + &tmpl.PointsFormulaID, &tmpl.MinPlayers, &tmpl.MaxPlayers, &tmpl.EarlySignupBonusChips, &tmpl.EarlySignupCutoff, + &tmpl.PunctualityBonusChips, &isPKO, &isBuiltin, &createdAt, &updatedAt, + &tmpl.ChipSetName, &tmpl.BlindStructureName, &tmpl.PayoutStructureName, &tmpl.BuyinConfigName, + ); err != nil { + return nil, fmt.Errorf("scan template: %w", err) + } + tmpl.IsPKO = isPKO != 0 + tmpl.IsBuiltin = isBuiltin != 0 + tmpl.CreatedAt = time.Unix(createdAt, 0) + tmpl.UpdatedAt = time.Unix(updatedAt, 0) + templates = append(templates, tmpl) + } + return templates, rows.Err() +} + +// UpdateTemplate updates a tournament template. +func (s *TournamentTemplateService) UpdateTemplate(ctx context.Context, tmpl *TournamentTemplate) error { + if tmpl.Name == "" { + return fmt.Errorf("template name is required") + } + if err := s.validateReferences(ctx, tmpl); err != nil { + return err + } + + now := time.Now().Unix() + isPKO := boolToInt(tmpl.IsPKO) + + res, err := s.db.ExecContext(ctx, + `UPDATE tournament_templates SET name = ?, description = ?, chip_set_id = ?, blind_structure_id = ?, + payout_structure_id = ?, buyin_config_id = ?, points_formula_id = ?, min_players = ?, max_players = ?, + early_signup_bonus_chips = ?, early_signup_cutoff = ?, punctuality_bonus_chips = ?, is_pko = ?, + updated_at = ? + WHERE id = ?`, + tmpl.Name, tmpl.Description, tmpl.ChipSetID, tmpl.BlindStructureID, + tmpl.PayoutStructureID, tmpl.BuyinConfigID, tmpl.PointsFormulaID, tmpl.MinPlayers, tmpl.MaxPlayers, + tmpl.EarlySignupBonusChips, tmpl.EarlySignupCutoff, tmpl.PunctualityBonusChips, isPKO, + now, tmpl.ID, + ) + if err != nil { + return fmt.Errorf("update template: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("tournament template not found: %d", tmpl.ID) + } + return nil +} + +// DeleteTemplate deletes a tournament template. Built-in templates cannot be deleted. +func (s *TournamentTemplateService) DeleteTemplate(ctx context.Context, id int64) error { + var isBuiltin int + err := s.db.QueryRowContext(ctx, "SELECT is_builtin FROM tournament_templates WHERE id = ?", id).Scan(&isBuiltin) + if err == sql.ErrNoRows { + return fmt.Errorf("tournament template not found: %d", id) + } + if err != nil { + return fmt.Errorf("check template: %w", err) + } + if isBuiltin != 0 { + return fmt.Errorf("cannot delete built-in tournament template") + } + + res, err := s.db.ExecContext(ctx, "DELETE FROM tournament_templates WHERE id = ?", id) + if err != nil { + return fmt.Errorf("delete template: %w", err) + } + n, _ := res.RowsAffected() + if n == 0 { + return fmt.Errorf("tournament template not found: %d", id) + } + return nil +} + +// DuplicateTemplate creates an independent copy of a tournament template. +func (s *TournamentTemplateService) DuplicateTemplate(ctx context.Context, id int64, newName string) (*TournamentTemplate, error) { + original, err := s.GetTemplate(ctx, id) + if err != nil { + return nil, fmt.Errorf("get original: %w", err) + } + + if newName == "" { + newName = original.Name + " (Copy)" + } + + copy := *original + copy.ID = 0 + copy.Name = newName + copy.IsBuiltin = false + return s.CreateTemplate(ctx, ©) +} + +// SaveAsTemplate creates a new template from a tournament's current config. +func (s *TournamentTemplateService) SaveAsTemplate(ctx context.Context, tournamentID string, name string) (*TournamentTemplate, error) { + if name == "" { + return nil, fmt.Errorf("template name is required") + } + + var tmpl TournamentTemplate + var isPKO int + + err := s.db.QueryRowContext(ctx, + `SELECT chip_set_id, blind_structure_id, payout_structure_id, buyin_config_id, + points_formula_id, min_players, max_players, early_signup_bonus_chips, early_signup_cutoff, + punctuality_bonus_chips, is_pko + FROM tournaments WHERE id = ?`, tournamentID, + ).Scan( + &tmpl.ChipSetID, &tmpl.BlindStructureID, &tmpl.PayoutStructureID, &tmpl.BuyinConfigID, + &tmpl.PointsFormulaID, &tmpl.MinPlayers, &tmpl.MaxPlayers, &tmpl.EarlySignupBonusChips, &tmpl.EarlySignupCutoff, + &tmpl.PunctualityBonusChips, &isPKO, + ) + if err == sql.ErrNoRows { + return nil, fmt.Errorf("tournament not found: %s", tournamentID) + } + if err != nil { + return nil, fmt.Errorf("get tournament config: %w", err) + } + + tmpl.Name = name + tmpl.IsPKO = isPKO != 0 + + return s.CreateTemplate(ctx, &tmpl) +}