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

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

366 lines
13 KiB
Go

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
}