Initial release: Disc Agenda frisbee tournament platform
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:
2026-03-15 14:48:15 +01:00
commit a7244406fd
38 changed files with 5749 additions and 0 deletions

View 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
}