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>
266 lines
6.1 KiB
Go
266 lines
6.1 KiB
Go
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
|
|
}
|