- 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>
366 lines
13 KiB
Go
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, ©)
|
|
}
|
|
|
|
// boolToInt converts a Go bool to SQLite integer.
|
|
func boolToInt(b bool) int {
|
|
if b {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|