package main import ( "database/sql" "encoding/csv" "encoding/json" "fmt" "net/http" "os" "path/filepath" "strconv" "strings" "time" ) func registerCompetitionRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/competitions", requireAuth(handleListCompetitions)) mux.HandleFunc("POST /api/competitions", requireAdmin(handleCreateCompetition)) mux.HandleFunc("GET /api/competitions/{id}", requireAuth(handleGetCompetition)) mux.HandleFunc("PATCH /api/competitions/{id}", requireAuth(handleUpdateCompetition)) mux.HandleFunc("DELETE /api/competitions/{id}", requireAdmin(handleDeleteCompetition)) mux.HandleFunc("POST /api/competitions/{id}/close", requireAdmin(handleCloseCompetition)) mux.HandleFunc("POST /api/competitions/{id}/reopen", requireAdmin(handleReopenCompetition)) mux.HandleFunc("GET /api/competitions/{id}/members", requireAuth(handleListMembers)) mux.HandleFunc("POST /api/competitions/{id}/members", requireAuth(handleAddMember)) mux.HandleFunc("DELETE /api/competitions/{id}/members/{uid}", requireAuth(handleRemoveMember)) } func userRole(competitionID, userID int64) (string, error) { var role string err := db.QueryRow("SELECT role FROM competition_users WHERE competition_id=? AND user_id=?", competitionID, userID).Scan(&role) if err == sql.ErrNoRows { return "", nil } return role, err } func canAccessCompetition(u *User, competitionID int64) (string, bool) { if u.IsSystemAdmin { return "system_admin", true } role, err := userRole(competitionID, u.ID) if err != nil || role == "" { return "", false } return role, true } func handleListCompetitions(w http.ResponseWriter, r *http.Request) { u := userFromCtx(r) var rows *sql.Rows var err error if u.IsSystemAdmin { rows, err = db.Query("SELECT id,name,allow_any_scorer_edit,rules_language,closed,closed_at,created_at FROM competitions ORDER BY created_at DESC") } else { rows, err = db.Query(`SELECT c.id,c.name,c.allow_any_scorer_edit,c.rules_language,c.closed,c.closed_at,c.created_at,cu.role FROM competitions c JOIN competition_users cu ON cu.competition_id=c.id WHERE cu.user_id=? ORDER BY c.created_at DESC`, u.ID) } if err != nil { writeError(w, http.StatusInternalServerError, "db_error") return } defer rows.Close() out := []Competition{} for rows.Next() { var c Competition var allow, closed int if u.IsSystemAdmin { rows.Scan(&c.ID, &c.Name, &allow, &c.RulesLanguage, &closed, &c.ClosedAt, &c.CreatedAt) c.Role = "system_admin" } else { rows.Scan(&c.ID, &c.Name, &allow, &c.RulesLanguage, &closed, &c.ClosedAt, &c.CreatedAt, &c.Role) } c.AllowAnyScorerEdit = allow == 1 c.Closed = closed == 1 out = append(out, c) } writeJSON(w, http.StatusOK, out) } func handleCreateCompetition(w http.ResponseWriter, r *http.Request) { var req struct { Name string `json:"name"` AllowAnyScorerEdit bool `json:"allow_any_scorer_edit"` RulesLanguage string `json:"rules_language"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid_body") return } if req.Name == "" { writeError(w, http.StatusBadRequest, "missing_name") return } if len(req.Name) > 200 { writeError(w, http.StatusBadRequest, "too_long") return } allow := 0 if req.AllowAnyScorerEdit { allow = 1 } lang := normalizeRulesLanguage(req.RulesLanguage) res, err := db.Exec("INSERT INTO competitions(name,allow_any_scorer_edit,rules_language) VALUES(?,?,?)", req.Name, allow, lang) if err != nil { writeError(w, http.StatusInternalServerError, "db_error") return } id, _ := res.LastInsertId() c := Competition{ID: id, Name: req.Name, AllowAnyScorerEdit: req.AllowAnyScorerEdit, RulesLanguage: lang} writeJSON(w, http.StatusCreated, c) } func normalizeRulesLanguage(lang string) string { if lang == "" { return "en" } rulesMu.RLock() _, ok := rules[lang] rulesMu.RUnlock() if !ok { return "en" } return lang } func handleGetCompetition(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid_id") return } u := userFromCtx(r) role, ok := canAccessCompetition(u, id) if !ok { writeError(w, http.StatusForbidden, "forbidden") return } var c Competition var allow, closed int err = db.QueryRow("SELECT id,name,allow_any_scorer_edit,rules_language,closed,closed_at,created_at FROM competitions WHERE id=?", id). Scan(&c.ID, &c.Name, &allow, &c.RulesLanguage, &closed, &c.ClosedAt, &c.CreatedAt) if err != nil { writeError(w, http.StatusNotFound, "not_found") return } c.AllowAnyScorerEdit = allow == 1 c.Closed = closed == 1 c.Role = role writeJSON(w, http.StatusOK, c) } func handleUpdateCompetition(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid_id") return } u := userFromCtx(r) role, ok := canAccessCompetition(u, id) if !ok || (role != "system_admin" && role != "chief_scorer") { writeError(w, http.StatusForbidden, "forbidden") return } if closed, _ := isCompetitionClosed(id); closed && role != "system_admin" { writeError(w, http.StatusConflict, "competition_closed") return } var req struct { Name *string `json:"name"` AllowAnyScorerEdit *bool `json:"allow_any_scorer_edit"` RulesLanguage *string `json:"rules_language"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid_body") return } if req.Name != nil { db.Exec("UPDATE competitions SET name=? WHERE id=?", *req.Name, id) } if req.AllowAnyScorerEdit != nil { v := 0 if *req.AllowAnyScorerEdit { v = 1 } db.Exec("UPDATE competitions SET allow_any_scorer_edit=? WHERE id=?", v, id) } if req.RulesLanguage != nil { db.Exec("UPDATE competitions SET rules_language=? WHERE id=?", normalizeRulesLanguage(*req.RulesLanguage), id) } hub.broadcast(id, "competition_updated", nil) w.WriteHeader(http.StatusNoContent) } func handleDeleteCompetition(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid_id") return } db.Exec("DELETE FROM competitions WHERE id=?", id) w.WriteHeader(http.StatusNoContent) } func isCompetitionClosed(id int64) (bool, error) { var closed int err := db.QueryRow("SELECT closed FROM competitions WHERE id=?", id).Scan(&closed) if err != nil { return false, err } return closed == 1, nil } func handleCloseCompetition(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid_id") return } var name, rulesLang string if err := db.QueryRow("SELECT name,rules_language FROM competitions WHERE id=?", id).Scan(&name, &rulesLang); err != nil { writeError(w, http.StatusNotFound, "not_found") return } path, err := writeCompetitionBackup(id, name, rulesLang) if err != nil { writeError(w, http.StatusInternalServerError, "backup_error") return } now := time.Now().UTC().Format(time.RFC3339) if _, err := db.Exec("UPDATE competitions SET closed=1, closed_at=? WHERE id=?", now, id); err != nil { writeError(w, http.StatusInternalServerError, "db_error") return } hub.broadcast(id, "competition_updated", nil) writeJSON(w, http.StatusOK, map[string]string{"backup": filepath.Base(path)}) } func handleReopenCompetition(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid_id") return } if _, err := db.Exec("UPDATE competitions SET closed=0, closed_at='' WHERE id=?", id); err != nil { writeError(w, http.StatusInternalServerError, "db_error") return } hub.broadcast(id, "competition_updated", nil) w.WriteHeader(http.StatusNoContent) } // sanitizeFilename returns a safe filesystem fragment derived from name. func sanitizeFilename(s string) string { s = strings.TrimSpace(s) var b strings.Builder for _, r := range s { switch { case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9': b.WriteRune(r) case r == '-' || r == '_': b.WriteRune(r) case r == ' ': b.WriteRune('_') } } out := b.String() if len(out) > 60 { out = out[:60] } if out == "" { out = "competition" } return out } // writeCompetitionBackup writes a CSV snapshot of all penalties (joined with // pilot and current rule text) into the backup directory and returns the path. func writeCompetitionBackup(id int64, name, rulesLang string) (string, error) { dir := backupDir if dir == "" { dir = "backup" } if err := os.MkdirAll(dir, 0o755); err != nil { return "", err } rulesMu.RLock() ruleMap := rules[rulesLang] if ruleMap == nil { ruleMap = rules["en"] } rulesMu.RUnlock() rows, err := db.Query(`SELECT p.id,p.flight,p.date,p.pilot_number, COALESCE(pl.last_name || ', ' || pl.first_name, ''), p.rule_number,p.task,p.penalties_text,p.description, COALESCE(u.display_name, u.username),p.transferred,p.created_at,p.updated_at FROM penalties p LEFT JOIN pilots pl ON pl.competition_id=p.competition_id AND pl.number=p.pilot_number LEFT JOIN users u ON u.id=p.created_by WHERE p.competition_id=? ORDER BY p.id`, id) if err != nil { return "", err } defer rows.Close() ts := time.Now().UTC().Format("20060102T150405Z") fname := fmt.Sprintf("competition_%d_%s_%s.csv", id, sanitizeFilename(name), ts) path := filepath.Join(dir, fname) f, err := os.Create(path) if err != nil { return "", err } defer f.Close() cw := csv.NewWriter(f) defer cw.Flush() if err := cw.Write([]string{ "id", "flight", "date", "pilot_number", "pilot_name", "rule_number", "rule_text", "suggested_penalty", "task", "penalties", "description", "created_by", "transferred", "created_at", "updated_at", }); err != nil { return "", err } for rows.Next() { var idv int64 var flight, date, pnum, pname, rnum, task, pens, desc, creator, createdAt, updatedAt string var transferred int if err := rows.Scan(&idv, &flight, &date, &pnum, &pname, &rnum, &task, &pens, &desc, &creator, &transferred, &createdAt, &updatedAt); err != nil { return "", err } ruleText, suggested := "", "" if ru, ok := ruleMap[rnum]; ok { ruleText = ru.Text suggested = ru.SuggestedPenalty } t := "0" if transferred == 1 { t = "1" } if err := cw.Write([]string{ strconv.FormatInt(idv, 10), csvSafe(flight), csvSafe(date), csvSafe(pnum), csvSafe(pname), csvSafe(rnum), csvSafe(ruleText), csvSafe(suggested), csvSafe(task), csvSafe(pens), csvSafe(desc), csvSafe(creator), t, createdAt, updatedAt, }); err != nil { return "", err } } return path, nil } func handleListMembers(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid_id") return } u := userFromCtx(r) if _, ok := canAccessCompetition(u, id); !ok { writeError(w, http.StatusForbidden, "forbidden") return } rows, err := db.Query(`SELECT u.id,u.username,u.display_name,cu.role FROM competition_users cu JOIN users u ON u.id=cu.user_id WHERE cu.competition_id=? ORDER BY cu.role,u.username`, id) if err != nil { writeError(w, http.StatusInternalServerError, "db_error") return } defer rows.Close() out := []CompetitionUser{} for rows.Next() { var m CompetitionUser rows.Scan(&m.UserID, &m.Username, &m.DisplayName, &m.Role) out = append(out, m) } writeJSON(w, http.StatusOK, out) } func handleAddMember(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid_id") return } u := userFromCtx(r) role, ok := canAccessCompetition(u, id) if !ok || (role != "system_admin" && role != "chief_scorer") { writeError(w, http.StatusForbidden, "forbidden") return } var req struct { UserID int64 `json:"user_id"` Role string `json:"role"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, http.StatusBadRequest, "invalid_body") return } if req.Role != "chief_scorer" && req.Role != "scorer" { writeError(w, http.StatusBadRequest, "invalid_role") return } _, err = db.Exec("INSERT OR REPLACE INTO competition_users(competition_id,user_id,role) VALUES(?,?,?)", id, req.UserID, req.Role) if err != nil { writeError(w, http.StatusInternalServerError, "db_error") return } w.WriteHeader(http.StatusNoContent) } func handleRemoveMember(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid_id") return } uid, err := strconv.ParseInt(r.PathValue("uid"), 10, 64) if err != nil { writeError(w, http.StatusBadRequest, "invalid_id") return } u := userFromCtx(r) role, ok := canAccessCompetition(u, id) if !ok || (role != "system_admin" && role != "chief_scorer") { writeError(w, http.StatusForbidden, "forbidden") return } db.Exec("DELETE FROM competition_users WHERE competition_id=? AND user_id=?", id, uid) w.WriteHeader(http.StatusNoContent) }