435 lines
13 KiB
Go
435 lines
13 KiB
Go
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)
|
|
}
|