Initial release: Disc Agenda frisbee tournament platform
Some checks failed
Build and Push / build (push) Failing after 8s
Some checks failed
Build and Push / build (push) Failing after 8s
Full-stack tournament management app with real-time scoring: - Go 1.26 backend with REST API and WebSocket live scoring - React 19 + Vite 8 frontend with mobile-first design - File-based JSON storage with JSONL audit logs - Multi-stage Docker build with Gitea CI/CD pipeline - Post-tournament questionnaire with spirit voting - Technical documentation and project description Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
97
backend/cmd/server/main.go
Normal file
97
backend/cmd/server/main.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/rs/cors"
|
||||
|
||||
"github.com/frisbee-tournament/backend/internal/handlers"
|
||||
"github.com/frisbee-tournament/backend/internal/storage"
|
||||
ws "github.com/frisbee-tournament/backend/internal/websocket"
|
||||
)
|
||||
|
||||
func main() {
|
||||
port := flag.String("port", "8080", "server port")
|
||||
dataDir := flag.String("data", "./data", "data directory")
|
||||
staticDir := flag.String("static", "./static", "frontend static files")
|
||||
flag.Parse()
|
||||
|
||||
if envPort := os.Getenv("PORT"); envPort != "" {
|
||||
*port = envPort
|
||||
}
|
||||
if envData := os.Getenv("DATA_DIR"); envData != "" {
|
||||
*dataDir = envData
|
||||
}
|
||||
|
||||
store := storage.New(*dataDir)
|
||||
hubMgr := ws.NewHubManager(store)
|
||||
h := handlers.New(store, hubMgr)
|
||||
|
||||
r := mux.NewRouter()
|
||||
|
||||
// API routes
|
||||
api := r.PathPrefix("/api").Subrouter()
|
||||
api.HandleFunc("/tournaments", h.ListTournaments).Methods("GET")
|
||||
api.HandleFunc("/tournaments/{id}", h.GetTournament).Methods("GET")
|
||||
api.HandleFunc("/tournaments/{id}/schedule", h.GetSchedule).Methods("GET")
|
||||
api.HandleFunc("/tournaments/{id}/games/{gid}/score", h.GetScore).Methods("GET")
|
||||
api.HandleFunc("/tournaments/{id}/games/{gid}/score", h.UpdateScore).Methods("POST")
|
||||
api.HandleFunc("/tournaments/{id}/games/{gid}/audit", h.GetAuditLog).Methods("GET")
|
||||
api.HandleFunc("/tournaments/{id}/questionnaire", h.GetQuestionnaire).Methods("GET")
|
||||
api.HandleFunc("/tournaments/{id}/questionnaire", h.SubmitQuestionnaire).Methods("POST")
|
||||
api.HandleFunc("/tournaments/{id}/questionnaire/results", h.GetQuestionnaireResults).Methods("GET")
|
||||
api.HandleFunc("/tournaments/{id}/results", h.GetResults).Methods("GET")
|
||||
|
||||
// WebSocket
|
||||
r.HandleFunc("/ws/game/{id}/{gid}", h.WebSocketHandler)
|
||||
r.HandleFunc("/ws/tournament/{id}", h.TournamentWebSocketHandler)
|
||||
|
||||
// Serve frontend (SPA fallback)
|
||||
spa := spaHandler{staticPath: *staticDir, indexPath: "index.html"}
|
||||
r.PathPrefix("/").Handler(spa)
|
||||
|
||||
c := cors.New(cors.Options{
|
||||
AllowedOrigins: []string{"*"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowedHeaders: []string{"*"},
|
||||
AllowCredentials: true,
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":" + *port,
|
||||
Handler: c.Handler(r),
|
||||
}
|
||||
|
||||
log.Printf("🥏 Frisbee Tournament server starting on :%s", *port)
|
||||
log.Printf(" Data dir: %s", *dataDir)
|
||||
log.Printf(" Static dir: %s", *staticDir)
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// spaHandler serves an SPA with history-mode fallback
|
||||
type spaHandler struct {
|
||||
staticPath string
|
||||
indexPath string
|
||||
}
|
||||
|
||||
func (h spaHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
path := h.staticPath + r.URL.Path
|
||||
|
||||
_, err := os.Stat(path)
|
||||
if os.IsNotExist(err) {
|
||||
// SPA fallback: serve index.html
|
||||
http.ServeFile(w, r, h.staticPath+"/"+h.indexPath)
|
||||
return
|
||||
} else if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
http.FileServer(http.Dir(h.staticPath)).ServeHTTP(w, r)
|
||||
}
|
||||
9
backend/go.mod
Normal file
9
backend/go.mod
Normal file
@@ -0,0 +1,9 @@
|
||||
module github.com/frisbee-tournament/backend
|
||||
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/gorilla/mux v1.8.1
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/rs/cors v1.11.0
|
||||
)
|
||||
6
backend/go.sum
Normal file
6
backend/go.sum
Normal file
@@ -0,0 +1,6 @@
|
||||
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
|
||||
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/rs/cors v1.11.0 h1:0B9GE/r9Bc2UxRMMtymBkHTenPkHDv0CW4Y98GBY+po=
|
||||
github.com/rs/cors v1.11.0/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
|
||||
216
backend/internal/handlers/handlers.go
Normal file
216
backend/internal/handlers/handlers.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
gorillaWs "github.com/gorilla/websocket"
|
||||
|
||||
"github.com/frisbee-tournament/backend/internal/models"
|
||||
"github.com/frisbee-tournament/backend/internal/storage"
|
||||
ws "github.com/frisbee-tournament/backend/internal/websocket"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
store *storage.Store
|
||||
hubMgr *ws.HubManager
|
||||
}
|
||||
|
||||
func New(store *storage.Store, hubMgr *ws.HubManager) *Handler {
|
||||
return &Handler{store: store, hubMgr: hubMgr}
|
||||
}
|
||||
|
||||
var upgrader = gorillaWs.Upgrader{
|
||||
ReadBufferSize: 1024,
|
||||
WriteBufferSize: 1024,
|
||||
CheckOrigin: func(r *http.Request) bool {
|
||||
return true // trust-based, no auth
|
||||
},
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// --- Tournament endpoints ---
|
||||
|
||||
func (h *Handler) ListTournaments(w http.ResponseWriter, r *http.Request) {
|
||||
ts, err := h.store.GetTournaments()
|
||||
if err != nil {
|
||||
writeError(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, ts)
|
||||
}
|
||||
|
||||
func (h *Handler) GetTournament(w http.ResponseWriter, r *http.Request) {
|
||||
id := mux.Vars(r)["id"]
|
||||
t, err := h.store.GetTournament(id)
|
||||
if err != nil {
|
||||
writeError(w, 404, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, t)
|
||||
}
|
||||
|
||||
// --- Schedule ---
|
||||
|
||||
func (h *Handler) GetSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
id := mux.Vars(r)["id"]
|
||||
sched, err := h.store.GetSchedule(id)
|
||||
if err != nil {
|
||||
writeError(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
// Merge persisted scores into schedule games
|
||||
for i := range sched.Games {
|
||||
g := &sched.Games[i]
|
||||
state, err := h.store.GetScore(id, g.ID)
|
||||
if err == nil && state.Status != "" {
|
||||
g.HomeScore = state.HomeScore
|
||||
g.AwayScore = state.AwayScore
|
||||
g.Status = state.Status
|
||||
}
|
||||
}
|
||||
writeJSON(w, 200, sched)
|
||||
}
|
||||
|
||||
// --- Scoring ---
|
||||
|
||||
func (h *Handler) GetScore(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
state, err := h.store.GetScore(vars["id"], vars["gid"])
|
||||
if err != nil {
|
||||
writeError(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, state)
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateScore(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
var update models.ScoreUpdate
|
||||
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
|
||||
writeError(w, 400, "invalid request body")
|
||||
return
|
||||
}
|
||||
hub := h.hubMgr.GetOrCreateHub(vars["id"], vars["gid"])
|
||||
state, err := hub.HandleScoreUpdate(update)
|
||||
if err != nil {
|
||||
writeError(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, state)
|
||||
}
|
||||
|
||||
func (h *Handler) GetAuditLog(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
entries, err := h.store.GetAuditLog(vars["id"], vars["gid"])
|
||||
if err != nil {
|
||||
writeError(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if entries == nil {
|
||||
entries = []models.AuditEntry{}
|
||||
}
|
||||
writeJSON(w, 200, entries)
|
||||
}
|
||||
|
||||
// --- WebSocket ---
|
||||
|
||||
func (h *Handler) WebSocketHandler(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("ws upgrade error: %v", err)
|
||||
return
|
||||
}
|
||||
userID := r.URL.Query().Get("user_id")
|
||||
if userID == "" {
|
||||
userID = "anonymous"
|
||||
}
|
||||
hub := h.hubMgr.GetOrCreateHub(vars["id"], vars["gid"])
|
||||
client := hub.RegisterClient(conn, userID)
|
||||
go client.WritePump()
|
||||
go client.ReadPump()
|
||||
}
|
||||
|
||||
func (h *Handler) TournamentWebSocketHandler(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("ws upgrade error: %v", err)
|
||||
return
|
||||
}
|
||||
th := h.hubMgr.GetOrCreateTournamentHub(vars["id"])
|
||||
client := th.RegisterClient(conn)
|
||||
go client.WritePump()
|
||||
go client.ReadPumpTournament(th)
|
||||
}
|
||||
|
||||
// --- Questionnaire ---
|
||||
|
||||
func (h *Handler) GetQuestionnaire(w http.ResponseWriter, r *http.Request) {
|
||||
id := mux.Vars(r)["id"]
|
||||
cfg, err := h.store.GetQuestionnaireConfig(id)
|
||||
if err != nil {
|
||||
writeError(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
t, _ := h.store.GetTournament(id)
|
||||
resp := map[string]any{
|
||||
"config": cfg,
|
||||
}
|
||||
if t != nil {
|
||||
resp["teams"] = t.Teams
|
||||
}
|
||||
writeJSON(w, 200, resp)
|
||||
}
|
||||
|
||||
func (h *Handler) SubmitQuestionnaire(w http.ResponseWriter, r *http.Request) {
|
||||
id := mux.Vars(r)["id"]
|
||||
var resp models.QuestionnaireResponse
|
||||
if err := json.NewDecoder(r.Body).Decode(&resp); err != nil {
|
||||
writeError(w, 400, "invalid request body")
|
||||
return
|
||||
}
|
||||
resp.TourneyID = id
|
||||
if err := h.store.SaveQuestionnaireResponse(id, &resp); err != nil {
|
||||
writeError(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 201, map[string]string{"status": "ok", "id": resp.ID})
|
||||
}
|
||||
|
||||
func (h *Handler) GetQuestionnaireResults(w http.ResponseWriter, r *http.Request) {
|
||||
id := mux.Vars(r)["id"]
|
||||
resps, err := h.store.GetQuestionnaireResponses(id)
|
||||
if err != nil {
|
||||
writeError(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
if resps == nil {
|
||||
resps = []models.QuestionnaireResponse{}
|
||||
}
|
||||
writeJSON(w, 200, resps)
|
||||
}
|
||||
|
||||
// --- Results ---
|
||||
|
||||
func (h *Handler) GetResults(w http.ResponseWriter, r *http.Request) {
|
||||
id := mux.Vars(r)["id"]
|
||||
res, err := h.store.GetResults(id)
|
||||
if err != nil {
|
||||
writeError(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, res)
|
||||
}
|
||||
118
backend/internal/models/models.go
Normal file
118
backend/internal/models/models.go
Normal file
@@ -0,0 +1,118 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
type TournamentStatus string
|
||||
|
||||
const (
|
||||
StatusUpcoming TournamentStatus = "upcoming"
|
||||
StatusInProgress TournamentStatus = "in_progress"
|
||||
StatusCompleted TournamentStatus = "completed"
|
||||
)
|
||||
|
||||
type Tournament struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status TournamentStatus `json:"status"`
|
||||
Location string `json:"location"`
|
||||
Venue string `json:"venue"`
|
||||
StartDate string `json:"start_date"`
|
||||
EndDate string `json:"end_date"`
|
||||
Description string `json:"description"`
|
||||
Teams []Team `json:"teams"`
|
||||
ImageURL string `json:"image_url,omitempty"`
|
||||
Rules string `json:"rules,omitempty"`
|
||||
}
|
||||
|
||||
type Team struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Logo string `json:"logo,omitempty"`
|
||||
}
|
||||
|
||||
type Game struct {
|
||||
ID string `json:"id"`
|
||||
TourneyID string `json:"tourney_id"`
|
||||
HomeTeam string `json:"home_team"`
|
||||
AwayTeam string `json:"away_team"`
|
||||
HomeScore int `json:"home_score"`
|
||||
AwayScore int `json:"away_score"`
|
||||
StartTime string `json:"start_time"`
|
||||
Field string `json:"field"`
|
||||
Round string `json:"round"`
|
||||
Status string `json:"status"` // scheduled, live, final
|
||||
}
|
||||
|
||||
type Schedule struct {
|
||||
TourneyID string `json:"tourney_id"`
|
||||
Games []Game `json:"games"`
|
||||
}
|
||||
|
||||
type ScoreUpdate struct {
|
||||
Action string `json:"action"` // increment, decrement, set
|
||||
Team string `json:"team"` // home, away
|
||||
Value int `json:"value"` // used for "set"
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
type ScoreState struct {
|
||||
GameID string `json:"game_id"`
|
||||
HomeScore int `json:"home_score"`
|
||||
AwayScore int `json:"away_score"`
|
||||
HomeTeam string `json:"home_team"`
|
||||
AwayTeam string `json:"away_team"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type AuditEntry struct {
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
Action string `json:"action"`
|
||||
Team string `json:"team"`
|
||||
Value int `json:"value"`
|
||||
OldHome int `json:"old_home"`
|
||||
OldAway int `json:"old_away"`
|
||||
NewHome int `json:"new_home"`
|
||||
NewAway int `json:"new_away"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
type QuestionnaireConfig struct {
|
||||
TourneyID string `json:"tourney_id"`
|
||||
CustomQuestions []Question `json:"custom_questions"`
|
||||
}
|
||||
|
||||
type Question struct {
|
||||
ID string `json:"id"`
|
||||
Text string `json:"text"`
|
||||
Type string `json:"type"` // text, select, radio
|
||||
Options []string `json:"options,omitempty"`
|
||||
Required bool `json:"required"`
|
||||
}
|
||||
|
||||
type QuestionnaireResponse struct {
|
||||
ID string `json:"id"`
|
||||
TourneyID string `json:"tourney_id"`
|
||||
MyTeam string `json:"my_team"`
|
||||
SpiritWinner string `json:"spirit_winner"`
|
||||
AttendNext bool `json:"attend_next"`
|
||||
CustomAnswers map[string]string `json:"custom_answers"`
|
||||
SubmittedAt time.Time `json:"submitted_at"`
|
||||
}
|
||||
|
||||
type FinalResults struct {
|
||||
TourneyID string `json:"tourney_id"`
|
||||
Standings []Standing `json:"standings"`
|
||||
}
|
||||
|
||||
type Standing struct {
|
||||
Position int `json:"position"`
|
||||
TeamID string `json:"team_id"`
|
||||
TeamName string `json:"team_name"`
|
||||
Wins int `json:"wins"`
|
||||
Losses int `json:"losses"`
|
||||
Draws int `json:"draws"`
|
||||
PointsFor int `json:"points_for"`
|
||||
PointsAgainst int `json:"points_against"`
|
||||
SpiritScore float64 `json:"spirit_score,omitempty"`
|
||||
}
|
||||
265
backend/internal/storage/storage.go
Normal file
265
backend/internal/storage/storage.go
Normal file
@@ -0,0 +1,265 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/frisbee-tournament/backend/internal/models"
|
||||
)
|
||||
|
||||
type Store struct {
|
||||
dataDir string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func New(dataDir string) *Store {
|
||||
return &Store{dataDir: dataDir}
|
||||
}
|
||||
|
||||
func (s *Store) ensureDir(path string) error {
|
||||
return os.MkdirAll(path, 0755)
|
||||
}
|
||||
|
||||
func (s *Store) readJSON(path string, v any) error {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(data, v)
|
||||
}
|
||||
|
||||
func (s *Store) writeJSON(path string, v any) error {
|
||||
data, err := json.MarshalIndent(v, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.ensureDir(filepath.Dir(path)); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(path, data, 0644)
|
||||
}
|
||||
|
||||
// --- Tournaments ---
|
||||
|
||||
func (s *Store) tournamentsFile() string {
|
||||
return filepath.Join(s.dataDir, "tournaments.json")
|
||||
}
|
||||
|
||||
func (s *Store) GetTournaments() ([]models.Tournament, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var ts []models.Tournament
|
||||
if err := s.readJSON(s.tournamentsFile(), &ts); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return ts, nil
|
||||
}
|
||||
|
||||
func (s *Store) GetTournament(id string) (*models.Tournament, error) {
|
||||
ts, err := s.GetTournaments()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, t := range ts {
|
||||
if t.ID == id {
|
||||
return &t, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("tournament %s not found", id)
|
||||
}
|
||||
|
||||
func (s *Store) SaveTournaments(ts []models.Tournament) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.writeJSON(s.tournamentsFile(), ts)
|
||||
}
|
||||
|
||||
// --- Schedule ---
|
||||
|
||||
func (s *Store) tourneyDir(tourneyID string) string {
|
||||
return filepath.Join(s.dataDir, "tournaments", tourneyID)
|
||||
}
|
||||
|
||||
func (s *Store) GetSchedule(tourneyID string) (*models.Schedule, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var sched models.Schedule
|
||||
path := filepath.Join(s.tourneyDir(tourneyID), "schedule.json")
|
||||
if err := s.readJSON(path, &sched); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &models.Schedule{TourneyID: tourneyID}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &sched, nil
|
||||
}
|
||||
|
||||
func (s *Store) SaveSchedule(sched *models.Schedule) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := filepath.Join(s.tourneyDir(sched.TourneyID), "schedule.json")
|
||||
return s.writeJSON(path, sched)
|
||||
}
|
||||
|
||||
// --- Score ---
|
||||
|
||||
func (s *Store) gameDir(tourneyID string) string {
|
||||
return filepath.Join(s.tourneyDir(tourneyID), "games")
|
||||
}
|
||||
|
||||
func (s *Store) GetScore(tourneyID, gameID string) (*models.ScoreState, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var state models.ScoreState
|
||||
path := filepath.Join(s.gameDir(tourneyID), gameID+"_score.json")
|
||||
if err := s.readJSON(path, &state); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &models.ScoreState{GameID: gameID}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &state, nil
|
||||
}
|
||||
|
||||
func (s *Store) SaveScore(tourneyID string, state *models.ScoreState) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
path := filepath.Join(s.gameDir(tourneyID), state.GameID+"_score.json")
|
||||
return s.writeJSON(path, state)
|
||||
}
|
||||
|
||||
func (s *Store) AppendAuditLog(tourneyID, gameID string, entry models.AuditEntry) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
dir := s.gameDir(tourneyID)
|
||||
if err := s.ensureDir(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(dir, gameID+"_audit.jsonl")
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
data, err := json.Marshal(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = f.Write(append(data, '\n'))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Store) GetAuditLog(tourneyID, gameID string) ([]models.AuditEntry, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
path := filepath.Join(s.gameDir(tourneyID), gameID+"_audit.jsonl")
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var entries []models.AuditEntry
|
||||
for _, line := range splitLines(data) {
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
var e models.AuditEntry
|
||||
if err := json.Unmarshal(line, &e); err != nil {
|
||||
continue
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// --- Questionnaire ---
|
||||
|
||||
func (s *Store) GetQuestionnaireConfig(tourneyID string) (*models.QuestionnaireConfig, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var cfg models.QuestionnaireConfig
|
||||
path := filepath.Join(s.tourneyDir(tourneyID), "questionnaire_config.json")
|
||||
if err := s.readJSON(path, &cfg); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &models.QuestionnaireConfig{TourneyID: tourneyID}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (s *Store) SaveQuestionnaireResponse(tourneyID string, resp *models.QuestionnaireResponse) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
resp.SubmittedAt = time.Now()
|
||||
if resp.ID == "" {
|
||||
resp.ID = fmt.Sprintf("resp_%d", time.Now().UnixNano())
|
||||
}
|
||||
dir := filepath.Join(s.tourneyDir(tourneyID), "questionnaire_responses")
|
||||
path := filepath.Join(dir, resp.ID+".json")
|
||||
return s.writeJSON(path, resp)
|
||||
}
|
||||
|
||||
func (s *Store) GetQuestionnaireResponses(tourneyID string) ([]models.QuestionnaireResponse, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
dir := filepath.Join(s.tourneyDir(tourneyID), "questionnaire_responses")
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
var resps []models.QuestionnaireResponse
|
||||
for _, e := range entries {
|
||||
if filepath.Ext(e.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
var r models.QuestionnaireResponse
|
||||
if err := s.readJSON(filepath.Join(dir, e.Name()), &r); err == nil {
|
||||
resps = append(resps, r)
|
||||
}
|
||||
}
|
||||
return resps, nil
|
||||
}
|
||||
|
||||
// --- Results ---
|
||||
|
||||
func (s *Store) GetResults(tourneyID string) (*models.FinalResults, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
var res models.FinalResults
|
||||
path := filepath.Join(s.tourneyDir(tourneyID), "results.json")
|
||||
if err := s.readJSON(path, &res); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return &models.FinalResults{TourneyID: tourneyID}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &res, nil
|
||||
}
|
||||
|
||||
func splitLines(data []byte) [][]byte {
|
||||
var lines [][]byte
|
||||
start := 0
|
||||
for i, b := range data {
|
||||
if b == '\n' {
|
||||
lines = append(lines, data[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(data) {
|
||||
lines = append(lines, data[start:])
|
||||
}
|
||||
return lines
|
||||
}
|
||||
355
backend/internal/websocket/hub.go
Normal file
355
backend/internal/websocket/hub.go
Normal file
@@ -0,0 +1,355 @@
|
||||
package websocket
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
|
||||
"github.com/frisbee-tournament/backend/internal/models"
|
||||
"github.com/frisbee-tournament/backend/internal/storage"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
hub *Hub
|
||||
conn *websocket.Conn
|
||||
send chan []byte
|
||||
userID string
|
||||
}
|
||||
|
||||
type Hub struct {
|
||||
gameID string
|
||||
tourneyID string
|
||||
store *storage.Store
|
||||
hubMgr *HubManager
|
||||
clients map[*Client]bool
|
||||
broadcast chan []byte
|
||||
register chan *Client
|
||||
unregister chan *Client
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
// TournamentHub forwards game score updates to all schedule viewers for a tournament.
|
||||
type TournamentHub struct {
|
||||
tourneyID string
|
||||
store *storage.Store
|
||||
clients map[*Client]bool
|
||||
broadcast chan []byte
|
||||
register chan *Client
|
||||
unregister chan *Client
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func (th *TournamentHub) Run() {
|
||||
for {
|
||||
select {
|
||||
case client := <-th.register:
|
||||
th.mu.Lock()
|
||||
th.clients[client] = true
|
||||
th.mu.Unlock()
|
||||
case client := <-th.unregister:
|
||||
th.mu.Lock()
|
||||
if _, ok := th.clients[client]; ok {
|
||||
delete(th.clients, client)
|
||||
close(client.send)
|
||||
}
|
||||
th.mu.Unlock()
|
||||
case message := <-th.broadcast:
|
||||
th.mu.RLock()
|
||||
for client := range th.clients {
|
||||
select {
|
||||
case client.send <- message:
|
||||
default:
|
||||
close(client.send)
|
||||
delete(th.clients, client)
|
||||
}
|
||||
}
|
||||
th.mu.RUnlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (th *TournamentHub) RegisterClient(conn *websocket.Conn) *Client {
|
||||
c := &Client{
|
||||
conn: conn,
|
||||
send: make(chan []byte, 256),
|
||||
}
|
||||
th.register <- c
|
||||
return c
|
||||
}
|
||||
|
||||
// ReadPumpTournament keeps the connection alive (reads pongs) but ignores incoming messages.
|
||||
func (c *Client) ReadPumpTournament(th *TournamentHub) {
|
||||
defer func() {
|
||||
th.unregister <- c
|
||||
c.conn.Close()
|
||||
}()
|
||||
c.conn.SetReadLimit(512)
|
||||
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
c.conn.SetPongHandler(func(string) error {
|
||||
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
return nil
|
||||
})
|
||||
for {
|
||||
if _, _, err := c.conn.ReadMessage(); err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type HubManager struct {
|
||||
hubs map[string]*Hub
|
||||
tournamentHubs map[string]*TournamentHub
|
||||
mu sync.RWMutex
|
||||
store *storage.Store
|
||||
}
|
||||
|
||||
func NewHubManager(store *storage.Store) *HubManager {
|
||||
return &HubManager{
|
||||
hubs: make(map[string]*Hub),
|
||||
tournamentHubs: make(map[string]*TournamentHub),
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *HubManager) GetOrCreateHub(tourneyID, gameID string) *Hub {
|
||||
key := tourneyID + "/" + gameID
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if h, ok := m.hubs[key]; ok {
|
||||
return h
|
||||
}
|
||||
h := &Hub{
|
||||
gameID: gameID,
|
||||
tourneyID: tourneyID,
|
||||
store: m.store,
|
||||
hubMgr: m,
|
||||
clients: make(map[*Client]bool),
|
||||
broadcast: make(chan []byte, 256),
|
||||
register: make(chan *Client),
|
||||
unregister: make(chan *Client),
|
||||
}
|
||||
m.hubs[key] = h
|
||||
go h.Run()
|
||||
return h
|
||||
}
|
||||
|
||||
func (m *HubManager) GetOrCreateTournamentHub(tourneyID string) *TournamentHub {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
if th, ok := m.tournamentHubs[tourneyID]; ok {
|
||||
return th
|
||||
}
|
||||
th := &TournamentHub{
|
||||
tourneyID: tourneyID,
|
||||
store: m.store,
|
||||
clients: make(map[*Client]bool),
|
||||
broadcast: make(chan []byte, 256),
|
||||
register: make(chan *Client),
|
||||
unregister: make(chan *Client),
|
||||
}
|
||||
m.tournamentHubs[tourneyID] = th
|
||||
go th.Run()
|
||||
return th
|
||||
}
|
||||
|
||||
// BroadcastToTournament forwards a game update to the tournament hub if it exists.
|
||||
func (m *HubManager) BroadcastToTournament(tourneyID string, msg []byte) {
|
||||
m.mu.RLock()
|
||||
th, ok := m.tournamentHubs[tourneyID]
|
||||
m.mu.RUnlock()
|
||||
if ok {
|
||||
select {
|
||||
case th.broadcast <- msg:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) Run() {
|
||||
for {
|
||||
select {
|
||||
case client := <-h.register:
|
||||
h.mu.Lock()
|
||||
h.clients[client] = true
|
||||
h.mu.Unlock()
|
||||
// Send current score to new client
|
||||
state, err := h.store.GetScore(h.tourneyID, h.gameID)
|
||||
if err == nil {
|
||||
data, _ := json.Marshal(map[string]any{
|
||||
"type": "score_state",
|
||||
"state": state,
|
||||
})
|
||||
client.send <- data
|
||||
}
|
||||
case client := <-h.unregister:
|
||||
h.mu.Lock()
|
||||
if _, ok := h.clients[client]; ok {
|
||||
delete(h.clients, client)
|
||||
close(client.send)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
case message := <-h.broadcast:
|
||||
h.mu.RLock()
|
||||
for client := range h.clients {
|
||||
select {
|
||||
case client.send <- message:
|
||||
default:
|
||||
close(client.send)
|
||||
delete(h.clients, client)
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) HandleScoreUpdate(update models.ScoreUpdate) (*models.ScoreState, error) {
|
||||
state, err := h.store.GetScore(h.tourneyID, h.gameID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
oldHome := state.HomeScore
|
||||
oldAway := state.AwayScore
|
||||
|
||||
switch update.Action {
|
||||
case "increment":
|
||||
if state.Status == "final" {
|
||||
return state, nil
|
||||
}
|
||||
if update.Team == "home" {
|
||||
state.HomeScore++
|
||||
} else {
|
||||
state.AwayScore++
|
||||
}
|
||||
case "decrement":
|
||||
if state.Status == "final" {
|
||||
return state, nil
|
||||
}
|
||||
if update.Team == "home" && state.HomeScore > 0 {
|
||||
state.HomeScore--
|
||||
} else if update.Team == "away" && state.AwayScore > 0 {
|
||||
state.AwayScore--
|
||||
}
|
||||
case "set":
|
||||
if state.Status == "final" {
|
||||
return state, nil
|
||||
}
|
||||
if update.Team == "home" {
|
||||
state.HomeScore = update.Value
|
||||
} else {
|
||||
state.AwayScore = update.Value
|
||||
}
|
||||
case "set_status":
|
||||
// update.Team carries the target status: scheduled, live, final
|
||||
if update.Team == "scheduled" || update.Team == "live" || update.Team == "final" {
|
||||
state.Status = update.Team
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-transition: scheduled → live when score becomes non-zero
|
||||
if update.Action != "set_status" && state.Status == "scheduled" && (state.HomeScore > 0 || state.AwayScore > 0) {
|
||||
state.Status = "live"
|
||||
}
|
||||
|
||||
if err := h.store.SaveScore(h.tourneyID, state); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Write audit log
|
||||
entry := models.AuditEntry{
|
||||
Timestamp: time.Now(),
|
||||
Action: update.Action,
|
||||
Team: update.Team,
|
||||
Value: update.Value,
|
||||
OldHome: oldHome,
|
||||
OldAway: oldAway,
|
||||
NewHome: state.HomeScore,
|
||||
NewAway: state.AwayScore,
|
||||
UserID: update.UserID,
|
||||
}
|
||||
if err := h.store.AppendAuditLog(h.tourneyID, h.gameID, entry); err != nil {
|
||||
log.Printf("audit log error: %v", err)
|
||||
}
|
||||
|
||||
// Broadcast to all clients on this game hub
|
||||
msg, _ := json.Marshal(map[string]any{
|
||||
"type": "score_update",
|
||||
"state": state,
|
||||
"audit": entry,
|
||||
})
|
||||
h.broadcast <- msg
|
||||
|
||||
// Also forward to tournament hub for schedule viewers
|
||||
h.hubMgr.BroadcastToTournament(h.tourneyID, msg)
|
||||
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (h *Hub) RegisterClient(conn *websocket.Conn, userID string) *Client {
|
||||
c := &Client{
|
||||
hub: h,
|
||||
conn: conn,
|
||||
send: make(chan []byte, 256),
|
||||
userID: userID,
|
||||
}
|
||||
h.register <- c
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Client) ReadPump() {
|
||||
defer func() {
|
||||
c.hub.unregister <- c
|
||||
c.conn.Close()
|
||||
}()
|
||||
c.conn.SetReadLimit(4096)
|
||||
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
c.conn.SetPongHandler(func(string) error {
|
||||
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
||||
return nil
|
||||
})
|
||||
for {
|
||||
_, message, err := c.conn.ReadMessage()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
var update models.ScoreUpdate
|
||||
if err := json.Unmarshal(message, &update); err != nil {
|
||||
continue
|
||||
}
|
||||
update.Timestamp = time.Now()
|
||||
update.UserID = c.userID
|
||||
if _, err := c.hub.HandleScoreUpdate(update); err != nil {
|
||||
log.Printf("score update error: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) WritePump() {
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
defer func() {
|
||||
ticker.Stop()
|
||||
c.conn.Close()
|
||||
}()
|
||||
for {
|
||||
select {
|
||||
case message, ok := <-c.send:
|
||||
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if !ok {
|
||||
c.conn.WriteMessage(websocket.CloseMessage, []byte{})
|
||||
return
|
||||
}
|
||||
if err := c.conn.WriteMessage(websocket.TextMessage, message); err != nil {
|
||||
return
|
||||
}
|
||||
case <-ticker.C:
|
||||
c.conn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||
if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user