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 }