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:
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
|
||||
}
|
||||
Reference in New Issue
Block a user