felt/internal/server/routes/templates.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

644 lines
19 KiB
Go

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