From af13732b2b1c458f82853f638f596b7264361cda Mon Sep 17 00:00:00 2001 From: Mikkel Georgsen Date: Sun, 1 Mar 2026 03:34:44 +0100 Subject: [PATCH] feat(01-01): initialize Go module, dependency tree, and project scaffold - Go module at github.com/felt-app/felt with go-libsql pinned to commit hash - Full directory structure per research recommendations (cmd/leaf, internal/*, frontend/) - Makefile with build, run, run-dev, test, frontend, all, clean targets - LibSQL database with WAL mode, foreign keys, and embedded migration runner - SvelteKit SPA stub served via go:embed - Package stubs for all internal packages (server, nats, store, auth, clock, etc.) - go build and go vet pass cleanly Co-Authored-By: Claude Opus 4.6 --- .gitignore | 28 ++++ Makefile | 31 +++++ cmd/leaf/main.go | 51 +++++++ frontend/build/index.html | 38 +++++ frontend/embed.go | 38 +++++ go.mod | 11 ++ go.sum | 16 +++ internal/audit/trail.go | 1 + internal/audit/undo.go | 1 + internal/auth/jwt.go | 1 + internal/auth/pin.go | 1 + internal/blind/structure.go | 1 + internal/blind/templates.go | 1 + internal/blind/wizard.go | 1 + internal/clock/engine.go | 1 + internal/clock/ticker.go | 1 + internal/clock/warnings.go | 1 + internal/financial/chop.go | 1 + internal/financial/engine.go | 1 + internal/financial/icm.go | 1 + internal/financial/payout.go | 1 + internal/financial/receipt.go | 1 + internal/nats/embedded.go | 1 + internal/nats/publisher.go | 1 + internal/player/player.go | 1 + internal/player/qrcode.go | 1 + internal/player/ranking.go | 1 + internal/seating/balance.go | 1 + internal/seating/blueprint.go | 1 + internal/seating/breaktable.go | 1 + internal/seating/table.go | 1 + internal/server/middleware/auth.go | 1 + internal/server/middleware/role.go | 1 + internal/server/server.go | 1 + internal/server/ws/hub.go | 1 + internal/store/db.go | 130 ++++++++++++++++++ internal/store/migrate.go | 121 ++++++++++++++++ internal/store/migrations/002_fts_indexes.sql | 28 ++++ internal/store/migrations/003_seed_data.sql | 27 ++++ internal/store/migrations/004_dev_seed.sql | 12 ++ internal/template/buyin.go | 1 + internal/template/chipset.go | 1 + internal/template/payout.go | 1 + internal/template/tournament.go | 1 + internal/tournament/multi.go | 1 + internal/tournament/state.go | 1 + internal/tournament/tournament.go | 1 + 47 files changed, 566 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 cmd/leaf/main.go create mode 100644 frontend/build/index.html create mode 100644 frontend/embed.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/audit/trail.go create mode 100644 internal/audit/undo.go create mode 100644 internal/auth/jwt.go create mode 100644 internal/auth/pin.go create mode 100644 internal/blind/structure.go create mode 100644 internal/blind/templates.go create mode 100644 internal/blind/wizard.go create mode 100644 internal/clock/engine.go create mode 100644 internal/clock/ticker.go create mode 100644 internal/clock/warnings.go create mode 100644 internal/financial/chop.go create mode 100644 internal/financial/engine.go create mode 100644 internal/financial/icm.go create mode 100644 internal/financial/payout.go create mode 100644 internal/financial/receipt.go create mode 100644 internal/nats/embedded.go create mode 100644 internal/nats/publisher.go create mode 100644 internal/player/player.go create mode 100644 internal/player/qrcode.go create mode 100644 internal/player/ranking.go create mode 100644 internal/seating/balance.go create mode 100644 internal/seating/blueprint.go create mode 100644 internal/seating/breaktable.go create mode 100644 internal/seating/table.go create mode 100644 internal/server/middleware/auth.go create mode 100644 internal/server/middleware/role.go create mode 100644 internal/server/server.go create mode 100644 internal/server/ws/hub.go create mode 100644 internal/store/db.go create mode 100644 internal/store/migrate.go create mode 100644 internal/store/migrations/002_fts_indexes.sql create mode 100644 internal/store/migrations/003_seed_data.sql create mode 100644 internal/store/migrations/004_dev_seed.sql create mode 100644 internal/template/buyin.go create mode 100644 internal/template/chipset.go create mode 100644 internal/template/payout.go create mode 100644 internal/template/tournament.go create mode 100644 internal/tournament/multi.go create mode 100644 internal/tournament/state.go create mode 100644 internal/tournament/tournament.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..59bda18 --- /dev/null +++ b/.gitignore @@ -0,0 +1,28 @@ +# Binary +cmd/leaf/leaf + +# Data directory +data/ + +# Go +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out + +# Frontend (SvelteKit build output managed separately) +frontend/node_modules/ +frontend/.svelte-kit/ + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d7dfb10 --- /dev/null +++ b/Makefile @@ -0,0 +1,31 @@ +.PHONY: build run test frontend all clean + +# Default data directory +DATA_DIR ?= ./data +ADDR ?= :8080 +BINARY := cmd/leaf/leaf + +build: + CGO_ENABLED=1 go build -o $(BINARY) ./cmd/leaf/ + +run: build + ./$(BINARY) --data-dir $(DATA_DIR) --addr $(ADDR) + +run-dev: build + ./$(BINARY) --data-dir $(DATA_DIR) --addr $(ADDR) --dev + +test: + CGO_ENABLED=1 go test ./... + +frontend: + @mkdir -p frontend/build + @if [ ! -f frontend/build/index.html ]; then \ + echo 'Felt

Felt

Loading...

' > frontend/build/index.html; \ + fi + @echo "Frontend build complete (stub)" + +all: frontend build + +clean: + rm -f $(BINARY) + rm -rf data/ diff --git a/cmd/leaf/main.go b/cmd/leaf/main.go new file mode 100644 index 0000000..e95d469 --- /dev/null +++ b/cmd/leaf/main.go @@ -0,0 +1,51 @@ +package main + +import ( + "flag" + "fmt" + "log" + "os" + + "github.com/felt-app/felt/internal/store" +) + +func main() { + dataDir := flag.String("data-dir", "./data", "Data directory for database and files") + addr := flag.String("addr", ":8080", "HTTP listen address") + devMode := flag.Bool("dev", false, "Enable development mode (applies dev seed data)") + flag.Parse() + + log.SetFlags(log.LstdFlags | log.Lmsgprefix) + log.SetPrefix("felt: ") + + log.Printf("starting (data-dir=%s, addr=%s, dev=%v)", *dataDir, *addr, *devMode) + + // Open database (runs migrations automatically) + db, err := store.Open(*dataDir, *devMode) + if err != nil { + log.Fatalf("failed to open database: %v", err) + } + defer db.Close() + + // Check first-run setup status + needsSetup, err := db.NeedsSetup() + if err != nil { + log.Fatalf("failed to check setup status: %v", err) + } + if needsSetup { + log.Printf("no operators found - first-run setup required") + log.Printf("POST /api/v1/setup to create first admin operator") + } + + // Verify database is working + var one int + if err := db.QueryRow("SELECT 1").Scan(&one); err != nil { + log.Fatalf("database health check failed: %v", err) + } + + log.Printf("database ready") + + // Placeholder for HTTP server (will be implemented in Plan A) + _ = addr + fmt.Fprintf(os.Stderr, "felt: ready (database operational, HTTP server not yet implemented)\n") +} diff --git a/frontend/build/index.html b/frontend/build/index.html new file mode 100644 index 0000000..ff8a382 --- /dev/null +++ b/frontend/build/index.html @@ -0,0 +1,38 @@ + + + + + + Felt + + + +
+

Felt

+

Loading...

+
+ + diff --git a/frontend/embed.go b/frontend/embed.go new file mode 100644 index 0000000..04cc345 --- /dev/null +++ b/frontend/embed.go @@ -0,0 +1,38 @@ +package frontend + +import ( + "embed" + "io/fs" + "net/http" +) + +//go:embed all:build +var files embed.FS + +// Handler returns an http.Handler that serves the embedded SvelteKit SPA. +// Unknown paths fall back to index.html for client-side routing. +func Handler() http.Handler { + fsys, _ := fs.Sub(files, "build") + fileServer := http.FileServer(http.FS(fsys)) + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Try to serve static file first + path := r.URL.Path + if path == "/" { + path = "/index.html" + } + + // Check if the file exists in the embedded filesystem + cleanPath := path[1:] // Remove leading slash + if cleanPath == "" { + cleanPath = "index.html" + } + + if _, err := fs.Stat(fsys, cleanPath); err != nil { + // SPA fallback: serve index.html for client-side routing + r.URL.Path = "/" + } + + fileServer.ServeHTTP(w, r) + }) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b5dad57 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/felt-app/felt + +go 1.24.0 + +require github.com/tursodatabase/go-libsql v0.0.0-20251219133454-43644db490ff // no tagged releases — pinned to commit 43644db490ff + +require ( + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect + github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 // indirect + golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ede74d5 --- /dev/null +++ b/go.sum @@ -0,0 +1,16 @@ +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06 h1:JLvn7D+wXjH9g4Jsjo+VqmzTUpl/LX7vfr6VOfSWTdM= +github.com/libsql/sqlite-antlr4-parser v0.0.0-20240327125255-dbf53b6cbf06/go.mod h1:FUkZ5OHjlGPjnM2UyGJz9TypXQFgYqw6AFNO1UiROTM= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/tursodatabase/go-libsql v0.0.0-20251219133454-43644db490ff h1:Hvxz9W8fWpSg9xkiq8/q+3cVJo+MmLMfkjdS/u4nWFY= +github.com/tursodatabase/go-libsql v0.0.0-20251219133454-43644db490ff/go.mod h1:TjsB2miB8RW2Sse8sdxzVTdeGlx74GloD5zJYUC38d8= +golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc h1:mCRnTeVUjcrhlRmO0VK8a6k6Rrf6TF9htwo2pJVSjIU= +golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo= +gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= diff --git a/internal/audit/trail.go b/internal/audit/trail.go new file mode 100644 index 0000000..6fd56fb --- /dev/null +++ b/internal/audit/trail.go @@ -0,0 +1 @@ +package audit diff --git a/internal/audit/undo.go b/internal/audit/undo.go new file mode 100644 index 0000000..6fd56fb --- /dev/null +++ b/internal/audit/undo.go @@ -0,0 +1 @@ +package audit diff --git a/internal/auth/jwt.go b/internal/auth/jwt.go new file mode 100644 index 0000000..8832b06 --- /dev/null +++ b/internal/auth/jwt.go @@ -0,0 +1 @@ +package auth diff --git a/internal/auth/pin.go b/internal/auth/pin.go new file mode 100644 index 0000000..8832b06 --- /dev/null +++ b/internal/auth/pin.go @@ -0,0 +1 @@ +package auth diff --git a/internal/blind/structure.go b/internal/blind/structure.go new file mode 100644 index 0000000..d223211 --- /dev/null +++ b/internal/blind/structure.go @@ -0,0 +1 @@ +package blind diff --git a/internal/blind/templates.go b/internal/blind/templates.go new file mode 100644 index 0000000..d223211 --- /dev/null +++ b/internal/blind/templates.go @@ -0,0 +1 @@ +package blind diff --git a/internal/blind/wizard.go b/internal/blind/wizard.go new file mode 100644 index 0000000..d223211 --- /dev/null +++ b/internal/blind/wizard.go @@ -0,0 +1 @@ +package blind diff --git a/internal/clock/engine.go b/internal/clock/engine.go new file mode 100644 index 0000000..fa04f4c --- /dev/null +++ b/internal/clock/engine.go @@ -0,0 +1 @@ +package clock diff --git a/internal/clock/ticker.go b/internal/clock/ticker.go new file mode 100644 index 0000000..fa04f4c --- /dev/null +++ b/internal/clock/ticker.go @@ -0,0 +1 @@ +package clock diff --git a/internal/clock/warnings.go b/internal/clock/warnings.go new file mode 100644 index 0000000..fa04f4c --- /dev/null +++ b/internal/clock/warnings.go @@ -0,0 +1 @@ +package clock diff --git a/internal/financial/chop.go b/internal/financial/chop.go new file mode 100644 index 0000000..6ced8cb --- /dev/null +++ b/internal/financial/chop.go @@ -0,0 +1 @@ +package financial diff --git a/internal/financial/engine.go b/internal/financial/engine.go new file mode 100644 index 0000000..6ced8cb --- /dev/null +++ b/internal/financial/engine.go @@ -0,0 +1 @@ +package financial diff --git a/internal/financial/icm.go b/internal/financial/icm.go new file mode 100644 index 0000000..6ced8cb --- /dev/null +++ b/internal/financial/icm.go @@ -0,0 +1 @@ +package financial diff --git a/internal/financial/payout.go b/internal/financial/payout.go new file mode 100644 index 0000000..6ced8cb --- /dev/null +++ b/internal/financial/payout.go @@ -0,0 +1 @@ +package financial diff --git a/internal/financial/receipt.go b/internal/financial/receipt.go new file mode 100644 index 0000000..6ced8cb --- /dev/null +++ b/internal/financial/receipt.go @@ -0,0 +1 @@ +package financial diff --git a/internal/nats/embedded.go b/internal/nats/embedded.go new file mode 100644 index 0000000..40b4928 --- /dev/null +++ b/internal/nats/embedded.go @@ -0,0 +1 @@ +package nats diff --git a/internal/nats/publisher.go b/internal/nats/publisher.go new file mode 100644 index 0000000..40b4928 --- /dev/null +++ b/internal/nats/publisher.go @@ -0,0 +1 @@ +package nats diff --git a/internal/player/player.go b/internal/player/player.go new file mode 100644 index 0000000..5d9ce2c --- /dev/null +++ b/internal/player/player.go @@ -0,0 +1 @@ +package player diff --git a/internal/player/qrcode.go b/internal/player/qrcode.go new file mode 100644 index 0000000..5d9ce2c --- /dev/null +++ b/internal/player/qrcode.go @@ -0,0 +1 @@ +package player diff --git a/internal/player/ranking.go b/internal/player/ranking.go new file mode 100644 index 0000000..5d9ce2c --- /dev/null +++ b/internal/player/ranking.go @@ -0,0 +1 @@ +package player diff --git a/internal/seating/balance.go b/internal/seating/balance.go new file mode 100644 index 0000000..63b7c6c --- /dev/null +++ b/internal/seating/balance.go @@ -0,0 +1 @@ +package seating diff --git a/internal/seating/blueprint.go b/internal/seating/blueprint.go new file mode 100644 index 0000000..63b7c6c --- /dev/null +++ b/internal/seating/blueprint.go @@ -0,0 +1 @@ +package seating diff --git a/internal/seating/breaktable.go b/internal/seating/breaktable.go new file mode 100644 index 0000000..63b7c6c --- /dev/null +++ b/internal/seating/breaktable.go @@ -0,0 +1 @@ +package seating diff --git a/internal/seating/table.go b/internal/seating/table.go new file mode 100644 index 0000000..63b7c6c --- /dev/null +++ b/internal/seating/table.go @@ -0,0 +1 @@ +package seating diff --git a/internal/server/middleware/auth.go b/internal/server/middleware/auth.go new file mode 100644 index 0000000..c870d7c --- /dev/null +++ b/internal/server/middleware/auth.go @@ -0,0 +1 @@ +package middleware diff --git a/internal/server/middleware/role.go b/internal/server/middleware/role.go new file mode 100644 index 0000000..c870d7c --- /dev/null +++ b/internal/server/middleware/role.go @@ -0,0 +1 @@ +package middleware diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..abb4e43 --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1 @@ +package server diff --git a/internal/server/ws/hub.go b/internal/server/ws/hub.go new file mode 100644 index 0000000..9859295 --- /dev/null +++ b/internal/server/ws/hub.go @@ -0,0 +1 @@ +package ws diff --git a/internal/store/db.go b/internal/store/db.go new file mode 100644 index 0000000..fc632bb --- /dev/null +++ b/internal/store/db.go @@ -0,0 +1,130 @@ +// Package store provides database access for the Felt tournament engine. +// +// It manages the LibSQL (SQLite-compatible) connection, runs embedded migrations +// on startup, and provides the database handle for use by other packages. +package store + +import ( + "database/sql" + "fmt" + "log" + "os" + "path/filepath" + + _ "github.com/tursodatabase/go-libsql" +) + +// DB wraps the underlying sql.DB connection with Felt-specific configuration. +type DB struct { + *sql.DB + dataDir string +} + +// Open opens (or creates) a LibSQL database in the given data directory. +// It enables WAL mode and foreign key enforcement, then runs any pending +// migrations automatically. +func Open(dataDir string, devMode bool) (*DB, error) { + // Ensure data directory exists + if err := os.MkdirAll(dataDir, 0750); err != nil { + return nil, fmt.Errorf("store: create data dir: %w", err) + } + + dbPath := filepath.Join(dataDir, "felt.db") + sqlDB, err := sql.Open("libsql", "file:"+dbPath) + if err != nil { + return nil, fmt.Errorf("store: open database: %w", err) + } + + // Verify connection + if err := sqlDB.Ping(); err != nil { + sqlDB.Close() + return nil, fmt.Errorf("store: ping database: %w", err) + } + + // Set PRAGMAs for performance and correctness. + // go-libsql: journal_mode returns a row, use QueryRow. + // foreign_keys and busy_timeout are set then verified with separate queries. + var walMode string + if err := sqlDB.QueryRow("PRAGMA journal_mode=WAL").Scan(&walMode); err != nil { + sqlDB.Close() + return nil, fmt.Errorf("store: set journal_mode: %w", err) + } + log.Printf("store: journal_mode=%s", walMode) + + // These PRAGMAs are setters that don't return rows in go-libsql. + // We set them, then verify with a separate getter query. + if _, err := execPragma(sqlDB, "PRAGMA foreign_keys=ON"); err != nil { + sqlDB.Close() + return nil, fmt.Errorf("store: set foreign_keys: %w", err) + } + if _, err := execPragma(sqlDB, "PRAGMA busy_timeout=5000"); err != nil { + sqlDB.Close() + return nil, fmt.Errorf("store: set busy_timeout: %w", err) + } + + // Verify settings + var fk, bt string + if err := sqlDB.QueryRow("PRAGMA foreign_keys").Scan(&fk); err == nil { + log.Printf("store: foreign_keys=%s", fk) + } + if err := sqlDB.QueryRow("PRAGMA busy_timeout").Scan(&bt); err == nil { + log.Printf("store: busy_timeout=%s", bt) + } + + log.Printf("store: opened database at %s", dbPath) + + db := &DB{ + DB: sqlDB, + dataDir: dataDir, + } + + // Run migrations + if err := RunMigrations(sqlDB, devMode); err != nil { + sqlDB.Close() + return nil, fmt.Errorf("store: run migrations: %w", err) + } + + return db, nil +} + +// execPragma tries Exec first; if that fails with "Execute returned rows", +// it falls back to QueryRow. go-libsql is inconsistent about which PRAGMAs +// return rows vs which don't. +func execPragma(db *sql.DB, pragma string) (string, error) { + // Try Exec first (works for pure setters) + _, err := db.Exec(pragma) + if err == nil { + return "", nil + } + // If Exec fails because it returns rows, use QueryRow + var result string + if qErr := db.QueryRow(pragma).Scan(&result); qErr != nil { + // Return the original error if QueryRow also fails + return "", err + } + return result, nil +} + +// DataDir returns the data directory path. +func (db *DB) DataDir() string { + return db.dataDir +} + +// OperatorCount returns the number of operators in the database. +// Used by the first-run setup check. +func (db *DB) OperatorCount() (int, error) { + var count int + err := db.QueryRow("SELECT COUNT(*) FROM operators").Scan(&count) + return count, err +} + +// NeedsSetup returns true if there are zero operators and the system +// needs initial admin setup. In dev mode, this is handled by the dev +// seed migration, so it always returns false. +func (db *DB) NeedsSetup() (bool, error) { + count, err := db.OperatorCount() + if err != nil { + return false, err + } + return count == 0, nil +} diff --git a/internal/store/migrate.go b/internal/store/migrate.go new file mode 100644 index 0000000..087b222 --- /dev/null +++ b/internal/store/migrate.go @@ -0,0 +1,121 @@ +package store + +import ( + "database/sql" + "embed" + "fmt" + "log" + "sort" + "strings" + "time" +) + +//go:embed migrations/*.sql +var migrationsFS embed.FS + +// devOnlyMigrations lists migration filenames that should only be applied +// in development mode (--dev flag). +var devOnlyMigrations = map[string]bool{ + "004_dev_seed.sql": true, +} + +// RunMigrations applies all pending SQL migrations embedded in the binary. +// Migrations are sorted by filename (numeric prefix ensures order) and +// executed within individual transactions. Each successful migration is +// recorded in the _migrations table to prevent re-application. +// +// If devMode is false, migrations listed in devOnlyMigrations are skipped. +func RunMigrations(db *sql.DB, devMode bool) error { + // Create migrations tracking table + _, err := db.Exec(` + CREATE TABLE IF NOT EXISTS _migrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + applied_at INTEGER NOT NULL + ) + `) + if err != nil { + return fmt.Errorf("create _migrations table: %w", err) + } + + // Read all migration files from embedded filesystem + entries, err := migrationsFS.ReadDir("migrations") + if err != nil { + return fmt.Errorf("read migrations dir: %w", err) + } + + // Sort by filename to ensure order + sort.Slice(entries, func(i, j int) bool { + return entries[i].Name() < entries[j].Name() + }) + + applied := 0 + skipped := 0 + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") { + continue + } + + name := entry.Name() + + // Skip dev-only migrations unless in dev mode + if devOnlyMigrations[name] && !devMode { + log.Printf("store: skipping dev-only migration %s (not in dev mode)", name) + continue + } + + // Check if already applied + var count int + err := db.QueryRow("SELECT COUNT(*) FROM _migrations WHERE name = ?", name).Scan(&count) + if err != nil { + return fmt.Errorf("check migration %s: %w", name, err) + } + if count > 0 { + skipped++ + continue + } + + // Read migration SQL + sqlBytes, err := migrationsFS.ReadFile("migrations/" + name) + if err != nil { + return fmt.Errorf("read migration %s: %w", name, err) + } + + // Execute migration within a transaction + tx, err := db.Begin() + if err != nil { + return fmt.Errorf("begin transaction for %s: %w", name, err) + } + + if _, err := tx.Exec(string(sqlBytes)); err != nil { + tx.Rollback() + return fmt.Errorf("execute migration %s: %w", name, err) + } + + // Record migration as applied + now := time.Now().Unix() + if _, err := tx.Exec( + "INSERT INTO _migrations (name, applied_at) VALUES (?, ?)", + name, now, + ); err != nil { + tx.Rollback() + return fmt.Errorf("record migration %s: %w", name, err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit migration %s: %w", name, err) + } + + applied++ + log.Printf("store: applied migration %s", name) + } + + if applied > 0 { + log.Printf("store: %d migration(s) applied, %d already up-to-date", applied, skipped) + } else if skipped > 0 { + log.Printf("store: all %d migration(s) already applied", skipped) + } + + return nil +} diff --git a/internal/store/migrations/002_fts_indexes.sql b/internal/store/migrations/002_fts_indexes.sql new file mode 100644 index 0000000..b37d2ec --- /dev/null +++ b/internal/store/migrations/002_fts_indexes.sql @@ -0,0 +1,28 @@ +-- 002_fts_indexes.sql +-- FTS5 virtual table for player typeahead search +-- Synced with players table via triggers + +CREATE VIRTUAL TABLE IF NOT EXISTS players_fts USING fts5( + name, nickname, email, + content='players', + content_rowid='rowid' +); + +-- Sync triggers: keep FTS index up to date with players table + +CREATE TRIGGER IF NOT EXISTS players_ai AFTER INSERT ON players BEGIN + INSERT INTO players_fts(rowid, name, nickname, email) + VALUES (new.rowid, new.name, new.nickname, new.email); +END; + +CREATE TRIGGER IF NOT EXISTS players_ad AFTER DELETE ON players BEGIN + INSERT INTO players_fts(players_fts, rowid, name, nickname, email) + VALUES ('delete', old.rowid, old.name, old.nickname, old.email); +END; + +CREATE TRIGGER IF NOT EXISTS players_au AFTER UPDATE ON players BEGIN + INSERT INTO players_fts(players_fts, rowid, name, nickname, email) + VALUES ('delete', old.rowid, old.name, old.nickname, old.email); + INSERT INTO players_fts(rowid, name, nickname, email) + VALUES (new.rowid, new.name, new.nickname, new.email); +END; diff --git a/internal/store/migrations/003_seed_data.sql b/internal/store/migrations/003_seed_data.sql new file mode 100644 index 0000000..16bb01b --- /dev/null +++ b/internal/store/migrations/003_seed_data.sql @@ -0,0 +1,27 @@ +-- 003_seed_data.sql +-- Default venue settings and built-in chip sets +-- Applied on every fresh database + +-- Default venue settings (DKK, Denmark) +INSERT OR IGNORE INTO venue_settings (id, venue_name, currency_code, currency_symbol, rounding_denomination, receipt_mode, timezone) +VALUES (1, '', 'DKK', 'kr', 5000, 'digital', 'Europe/Copenhagen'); + +-- Standard chip set (universal denominations) +INSERT OR IGNORE INTO chip_sets (id, name, is_builtin) VALUES (1, 'Standard', 1); + +INSERT OR IGNORE INTO chip_denominations (chip_set_id, value, color_hex, label, sort_order) VALUES + (1, 25, '#FFFFFF', '25', 1), + (1, 100, '#FF0000', '100', 2), + (1, 500, '#00AA00', '500', 3), + (1, 1000, '#000000', '1000', 4), + (1, 5000, '#0000FF', '5000', 5); + +-- Copenhagen chip set (DKK-friendly denominations) +INSERT OR IGNORE INTO chip_sets (id, name, is_builtin) VALUES (2, 'Copenhagen', 1); + +INSERT OR IGNORE INTO chip_denominations (chip_set_id, value, color_hex, label, sort_order) VALUES + (2, 100, '#FFFFFF', '100', 1), + (2, 500, '#FF0000', '500', 2), + (2, 1000, '#00AA00', '1000', 3), + (2, 5000, '#000000', '5000', 4), + (2, 10000, '#0000FF', '10000', 5); diff --git a/internal/store/migrations/004_dev_seed.sql b/internal/store/migrations/004_dev_seed.sql new file mode 100644 index 0000000..361f610 --- /dev/null +++ b/internal/store/migrations/004_dev_seed.sql @@ -0,0 +1,12 @@ +-- 004_dev_seed.sql +-- Development-only seed data: default admin operator +-- Only applied when --dev flag is set +-- PIN: 1234 (bcrypt hash below) + +INSERT OR IGNORE INTO operators (id, name, pin_hash, role) +VALUES ( + '00000000-0000-0000-0000-000000000001', + 'Admin', + '$2a$10$RD/9mCpMunyvJ0Ax1gHvHOrVmu0DL0/ah0NppuVN4rKgtpfuAEvdK', + 'admin' +); diff --git a/internal/template/buyin.go b/internal/template/buyin.go new file mode 100644 index 0000000..38cdfe4 --- /dev/null +++ b/internal/template/buyin.go @@ -0,0 +1 @@ +package template diff --git a/internal/template/chipset.go b/internal/template/chipset.go new file mode 100644 index 0000000..38cdfe4 --- /dev/null +++ b/internal/template/chipset.go @@ -0,0 +1 @@ +package template diff --git a/internal/template/payout.go b/internal/template/payout.go new file mode 100644 index 0000000..38cdfe4 --- /dev/null +++ b/internal/template/payout.go @@ -0,0 +1 @@ +package template diff --git a/internal/template/tournament.go b/internal/template/tournament.go new file mode 100644 index 0000000..38cdfe4 --- /dev/null +++ b/internal/template/tournament.go @@ -0,0 +1 @@ +package template diff --git a/internal/tournament/multi.go b/internal/tournament/multi.go new file mode 100644 index 0000000..f4947cb --- /dev/null +++ b/internal/tournament/multi.go @@ -0,0 +1 @@ +package tournament diff --git a/internal/tournament/state.go b/internal/tournament/state.go new file mode 100644 index 0000000..f4947cb --- /dev/null +++ b/internal/tournament/state.go @@ -0,0 +1 @@ +package tournament diff --git a/internal/tournament/tournament.go b/internal/tournament/tournament.go new file mode 100644 index 0000000..f4947cb --- /dev/null +++ b/internal/tournament/tournament.go @@ -0,0 +1 @@ +package tournament