felt/internal/seating/blueprint.go
Mikkel Georgsen e947ab1c47 feat(01-08): implement table management, auto-seating, blueprints, and hand-for-hand
- TableService with CRUD, AssignSeat, AutoAssignSeat (fills evenly), MoveSeat, SwapSeats
- Dealer button tracking with SetDealerButton and AdvanceDealerButton (skips empty seats)
- Hand-for-hand mode with per-table completion tracking and clock integration
- BlueprintService with CRUD, SaveBlueprintFromTournament, CreateTablesFromBlueprint
- Migration 006 adds hand_for_hand_hand_number and hand_completed columns

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

227 lines
6.3 KiB
Go

package seating
import (
"context"
"database/sql"
"encoding/json"
"fmt"
)
// Blueprint is a venue-level saved table layout configuration.
type Blueprint struct {
ID int `json:"id"`
Name string `json:"name"`
TableConfigs []BlueprintTableConfig `json:"table_configs"`
}
// BlueprintTableConfig is a single table spec within a blueprint.
type BlueprintTableConfig struct {
Name string `json:"name"`
SeatCount int `json:"seat_count"`
}
// BlueprintService provides CRUD operations for table blueprints.
type BlueprintService struct {
db *sql.DB
}
// NewBlueprintService creates a new BlueprintService.
func NewBlueprintService(db *sql.DB) *BlueprintService {
return &BlueprintService{db: db}
}
// CreateBlueprint creates a new blueprint.
func (s *BlueprintService) CreateBlueprint(ctx context.Context, name string, configs []BlueprintTableConfig) (*Blueprint, error) {
if name == "" {
return nil, fmt.Errorf("blueprint name is required")
}
if len(configs) == 0 {
return nil, fmt.Errorf("at least one table config is required")
}
for i, c := range configs {
if c.SeatCount < 6 || c.SeatCount > 10 {
return nil, fmt.Errorf("table config %d: seat count must be between 6 and 10", i+1)
}
if c.Name == "" {
return nil, fmt.Errorf("table config %d: name is required", i+1)
}
}
configsJSON, err := json.Marshal(configs)
if err != nil {
return nil, fmt.Errorf("marshal configs: %w", err)
}
result, err := s.db.ExecContext(ctx,
`INSERT INTO table_blueprints (name, table_configs) VALUES (?, ?)`,
name, string(configsJSON),
)
if err != nil {
return nil, fmt.Errorf("create blueprint: %w", err)
}
id, _ := result.LastInsertId()
return &Blueprint{
ID: int(id),
Name: name,
TableConfigs: configs,
}, nil
}
// GetBlueprint returns a blueprint by ID.
func (s *BlueprintService) GetBlueprint(ctx context.Context, id int) (*Blueprint, error) {
var bp Blueprint
var configsJSON string
err := s.db.QueryRowContext(ctx,
`SELECT id, name, table_configs FROM table_blueprints WHERE id = ?`, id,
).Scan(&bp.ID, &bp.Name, &configsJSON)
if err == sql.ErrNoRows {
return nil, fmt.Errorf("blueprint not found")
}
if err != nil {
return nil, fmt.Errorf("get blueprint: %w", err)
}
if err := json.Unmarshal([]byte(configsJSON), &bp.TableConfigs); err != nil {
return nil, fmt.Errorf("unmarshal configs: %w", err)
}
return &bp, nil
}
// ListBlueprints returns all blueprints.
func (s *BlueprintService) ListBlueprints(ctx context.Context) ([]Blueprint, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, name, table_configs FROM table_blueprints ORDER BY name`,
)
if err != nil {
return nil, fmt.Errorf("list blueprints: %w", err)
}
defer rows.Close()
var blueprints []Blueprint
for rows.Next() {
var bp Blueprint
var configsJSON string
if err := rows.Scan(&bp.ID, &bp.Name, &configsJSON); err != nil {
return nil, fmt.Errorf("scan blueprint: %w", err)
}
if err := json.Unmarshal([]byte(configsJSON), &bp.TableConfigs); err != nil {
return nil, fmt.Errorf("unmarshal configs: %w", err)
}
blueprints = append(blueprints, bp)
}
return blueprints, rows.Err()
}
// UpdateBlueprint updates a blueprint.
func (s *BlueprintService) UpdateBlueprint(ctx context.Context, bp Blueprint) error {
if bp.Name == "" {
return fmt.Errorf("blueprint name is required")
}
for i, c := range bp.TableConfigs {
if c.SeatCount < 6 || c.SeatCount > 10 {
return fmt.Errorf("table config %d: seat count must be between 6 and 10", i+1)
}
if c.Name == "" {
return fmt.Errorf("table config %d: name is required", i+1)
}
}
configsJSON, err := json.Marshal(bp.TableConfigs)
if err != nil {
return fmt.Errorf("marshal configs: %w", err)
}
result, err := s.db.ExecContext(ctx,
`UPDATE table_blueprints SET name = ?, table_configs = ?, updated_at = unixepoch()
WHERE id = ?`,
bp.Name, string(configsJSON), bp.ID,
)
if err != nil {
return fmt.Errorf("update blueprint: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("blueprint not found")
}
return nil
}
// DeleteBlueprint deletes a blueprint.
func (s *BlueprintService) DeleteBlueprint(ctx context.Context, id int) error {
result, err := s.db.ExecContext(ctx,
`DELETE FROM table_blueprints WHERE id = ?`, id,
)
if err != nil {
return fmt.Errorf("delete blueprint: %w", err)
}
n, _ := result.RowsAffected()
if n == 0 {
return fmt.Errorf("blueprint not found")
}
return nil
}
// SaveBlueprintFromTournament creates a blueprint from the current tables in a tournament.
func (s *BlueprintService) SaveBlueprintFromTournament(ctx context.Context, db *sql.DB, tournamentID, name string) (*Blueprint, error) {
if name == "" {
return nil, fmt.Errorf("blueprint name is required")
}
rows, err := db.QueryContext(ctx,
`SELECT name, seat_count FROM tables
WHERE tournament_id = ? AND is_active = 1
ORDER BY name`,
tournamentID,
)
if err != nil {
return nil, fmt.Errorf("get tournament tables: %w", err)
}
defer rows.Close()
var configs []BlueprintTableConfig
for rows.Next() {
var c BlueprintTableConfig
if err := rows.Scan(&c.Name, &c.SeatCount); err != nil {
return nil, fmt.Errorf("scan table: %w", err)
}
configs = append(configs, c)
}
if err := rows.Err(); err != nil {
return nil, err
}
if len(configs) == 0 {
return nil, fmt.Errorf("no active tables in tournament")
}
return s.CreateBlueprint(ctx, name, configs)
}
// CreateTablesFromBlueprint creates tables in a tournament from a blueprint.
func (s *BlueprintService) CreateTablesFromBlueprint(ctx context.Context, db *sql.DB, tournamentID string, blueprintID int) ([]Table, error) {
bp, err := s.GetBlueprint(ctx, blueprintID)
if err != nil {
return nil, err
}
var tables []Table
for _, cfg := range bp.TableConfigs {
result, err := db.ExecContext(ctx,
`INSERT INTO tables (tournament_id, name, seat_count, is_active)
VALUES (?, ?, ?, 1)`,
tournamentID, cfg.Name, cfg.SeatCount,
)
if err != nil {
return nil, fmt.Errorf("create table from blueprint: %w", err)
}
id, _ := result.LastInsertId()
tables = append(tables, Table{
ID: int(id),
TournamentID: tournamentID,
Name: cfg.Name,
SeatCount: cfg.SeatCount,
IsActive: true,
})
}
return tables, nil
}