Add multilingual support, competition close/reopen, and backup directory

This commit is contained in:
Jan Meinl
2026-05-17 09:18:34 +02:00
parent bb9f3cd3eb
commit 777f11d93c
18 changed files with 1039 additions and 433 deletions
+171 -8
View File
@@ -2,9 +2,15 @@ package main
import (
"database/sql"
"encoding/csv"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
func registerCompetitionRoutes(mux *http.ServeMux) {
@@ -13,6 +19,8 @@ func registerCompetitionRoutes(mux *http.ServeMux) {
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))
@@ -43,9 +51,9 @@ func handleListCompetitions(w http.ResponseWriter, r *http.Request) {
var rows *sql.Rows
var err error
if u.IsSystemAdmin {
rows, err = db.Query("SELECT id,name,allow_any_scorer_edit,rules_language,created_at FROM competitions ORDER BY created_at DESC")
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.created_at,cu.role
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)
}
@@ -57,14 +65,15 @@ func handleListCompetitions(w http.ResponseWriter, r *http.Request) {
out := []Competition{}
for rows.Next() {
var c Competition
var allow int
var allow, closed int
if u.IsSystemAdmin {
rows.Scan(&c.ID, &c.Name, &allow, &c.RulesLanguage, &c.CreatedAt)
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, &c.CreatedAt, &c.Role)
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)
@@ -129,14 +138,15 @@ func handleGetCompetition(w http.ResponseWriter, r *http.Request) {
return
}
var c Competition
var allow int
err = db.QueryRow("SELECT id,name,allow_any_scorer_edit,rules_language,created_at FROM competitions WHERE id=?", id).
Scan(&c.ID, &c.Name, &allow, &c.RulesLanguage, &c.CreatedAt)
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)
}
@@ -153,6 +163,10 @@ func handleUpdateCompetition(w http.ResponseWriter, r *http.Request) {
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"`
@@ -189,6 +203,155 @@ func handleDeleteCompetition(w http.ResponseWriter, r *http.Request) {
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 {