401 lines
13 KiB
Go
401 lines
13 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// csvSafe prefixes potentially dangerous CSV cell content with a single quote
|
|
// so that spreadsheet apps don't interpret it as a formula.
|
|
func csvSafe(s string) string {
|
|
if s == "" {
|
|
return s
|
|
}
|
|
switch s[0] {
|
|
case '=', '+', '-', '@', '\t', '\r':
|
|
return "'" + s
|
|
}
|
|
return s
|
|
}
|
|
|
|
func clip(s string, max int) string {
|
|
if len(s) > max {
|
|
return s[:max]
|
|
}
|
|
return s
|
|
}
|
|
|
|
func registerPenaltyRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /api/competitions/{id}/penalties", requireAuth(handleListPenalties))
|
|
mux.HandleFunc("POST /api/competitions/{id}/penalties", requireAuth(handleCreatePenalty))
|
|
mux.HandleFunc("PATCH /api/competitions/{id}/penalties/{pid}", requireAuth(handleUpdatePenalty))
|
|
mux.HandleFunc("DELETE /api/competitions/{id}/penalties/{pid}", requireAuth(handleDeletePenalty))
|
|
mux.HandleFunc("GET /api/competitions/{id}/penalties.csv", requireAuth(handleExportPenalties))
|
|
mux.HandleFunc("POST /api/competitions/{id}/penalties/apply", requireAuth(handleApplyPenalties))
|
|
}
|
|
|
|
// handleApplyPenalties bulk-marks penalties as applied (transferred=1) for a
|
|
// given task. Requires chief_scorer or system_admin role.
|
|
func handleApplyPenalties(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 != "chief_scorer" && role != "system_admin") {
|
|
writeError(w, http.StatusForbidden, "forbidden")
|
|
return
|
|
}
|
|
if closed, _ := isCompetitionClosed(id); closed {
|
|
writeError(w, http.StatusConflict, "competition_closed")
|
|
return
|
|
}
|
|
var req struct {
|
|
Task string `json:"task"`
|
|
IDs []int64 `json:"ids"`
|
|
Applied bool `json:"applied"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_body")
|
|
return
|
|
}
|
|
v := 0
|
|
if req.Applied {
|
|
v = 1
|
|
}
|
|
tx, err := db.Begin()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "tx_error")
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
if len(req.IDs) > 0 {
|
|
stmt, err := tx.Prepare("UPDATE penalties SET transferred=?, updated_at=datetime('now') WHERE id=? AND competition_id=?")
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "db_error")
|
|
return
|
|
}
|
|
defer stmt.Close()
|
|
for _, pid := range req.IDs {
|
|
stmt.Exec(v, pid, id)
|
|
}
|
|
} else {
|
|
task := clip(req.Task, 64)
|
|
if _, err := tx.Exec("UPDATE penalties SET transferred=?, updated_at=datetime('now') WHERE competition_id=? AND task=?", v, id, task); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "db_error")
|
|
return
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "commit_error")
|
|
return
|
|
}
|
|
hub.broadcast(id, "penalties_applied", map[string]any{"task": req.Task, "applied": req.Applied, "ids": req.IDs})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func loadPenalty(id int64) (*Penalty, error) {
|
|
row := db.QueryRow(`SELECT p.id,p.competition_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,p.created_by,
|
|
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.id=?`, id)
|
|
var pen Penalty
|
|
var transferred int
|
|
err := row.Scan(&pen.ID, &pen.CompetitionID, &pen.Flight, &pen.Date, &pen.PilotNumber,
|
|
&pen.PilotName, &pen.RuleNumber, &pen.Task, &pen.PenaltiesText, &pen.Description,
|
|
&pen.CreatedBy, &pen.CreatedByName, &transferred, &pen.CreatedAt, &pen.UpdatedAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pen.Transferred = transferred == 1
|
|
return &pen, nil
|
|
}
|
|
|
|
func handleListPenalties(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 p.id,p.competition_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,p.created_by,
|
|
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 DESC`, id)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "db_error")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
out := []Penalty{}
|
|
for rows.Next() {
|
|
var pen Penalty
|
|
var transferred int
|
|
rows.Scan(&pen.ID, &pen.CompetitionID, &pen.Flight, &pen.Date, &pen.PilotNumber,
|
|
&pen.PilotName, &pen.RuleNumber, &pen.Task, &pen.PenaltiesText, &pen.Description,
|
|
&pen.CreatedBy, &pen.CreatedByName, &transferred, &pen.CreatedAt, &pen.UpdatedAt)
|
|
pen.Transferred = transferred == 1
|
|
out = append(out, pen)
|
|
}
|
|
writeJSON(w, http.StatusOK, out)
|
|
}
|
|
|
|
func handleCreatePenalty(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 != "scorer" && role != "chief_scorer" && role != "system_admin") {
|
|
writeError(w, http.StatusForbidden, "forbidden")
|
|
return
|
|
}
|
|
if closed, _ := isCompetitionClosed(id); closed {
|
|
writeError(w, http.StatusConflict, "competition_closed")
|
|
return
|
|
}
|
|
var pen Penalty
|
|
if err := json.NewDecoder(r.Body).Decode(&pen); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_body")
|
|
return
|
|
}
|
|
pen.Flight = clip(pen.Flight, 64)
|
|
pen.Date = clip(pen.Date, 32)
|
|
pen.PilotNumber = clip(pen.PilotNumber, 32)
|
|
pen.RuleNumber = clip(pen.RuleNumber, 64)
|
|
pen.Task = clip(pen.Task, 64)
|
|
pen.PenaltiesText = clip(pen.PenaltiesText, 256)
|
|
pen.Description = clip(pen.Description, maxFieldLen)
|
|
transferred := 0
|
|
if pen.Transferred {
|
|
transferred = 1
|
|
}
|
|
res, err := db.Exec(`INSERT INTO penalties(competition_id,flight,date,pilot_number,rule_number,task,penalties_text,description,created_by,transferred)
|
|
VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
|
id, pen.Flight, pen.Date, pen.PilotNumber, pen.RuleNumber, pen.Task, pen.PenaltiesText, pen.Description, u.ID, transferred)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "db_error")
|
|
return
|
|
}
|
|
pid, _ := res.LastInsertId()
|
|
created, err := loadPenalty(pid)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "load_error")
|
|
return
|
|
}
|
|
hub.broadcast(id, "penalty_created", created)
|
|
writeJSON(w, http.StatusCreated, created)
|
|
}
|
|
|
|
func canEditPenalty(u *User, competitionID, createdBy int64) bool {
|
|
role, ok := canAccessCompetition(u, competitionID)
|
|
if !ok {
|
|
return false
|
|
}
|
|
if role == "system_admin" || role == "chief_scorer" {
|
|
return true
|
|
}
|
|
if role == "scorer" {
|
|
if u.ID == createdBy {
|
|
return true
|
|
}
|
|
var allow int
|
|
db.QueryRow("SELECT allow_any_scorer_edit FROM competitions WHERE id=?", competitionID).Scan(&allow)
|
|
return allow == 1
|
|
}
|
|
return false
|
|
}
|
|
|
|
func handleUpdatePenalty(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
|
|
}
|
|
pid, err := strconv.ParseInt(r.PathValue("pid"), 10, 64)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_id")
|
|
return
|
|
}
|
|
u := userFromCtx(r)
|
|
var createdBy int64
|
|
if err := db.QueryRow("SELECT created_by FROM penalties WHERE id=? AND competition_id=?", pid, id).Scan(&createdBy); err != nil {
|
|
writeError(w, http.StatusNotFound, "not_found")
|
|
return
|
|
}
|
|
if !canEditPenalty(u, id, createdBy) {
|
|
writeError(w, http.StatusForbidden, "forbidden")
|
|
return
|
|
}
|
|
if closed, _ := isCompetitionClosed(id); closed {
|
|
writeError(w, http.StatusConflict, "competition_closed")
|
|
return
|
|
}
|
|
var req struct {
|
|
Flight *string `json:"flight"`
|
|
Date *string `json:"date"`
|
|
PilotNumber *string `json:"pilot_number"`
|
|
RuleNumber *string `json:"rule_number"`
|
|
Task *string `json:"task"`
|
|
PenaltiesText *string `json:"penalties_text"`
|
|
Description *string `json:"description"`
|
|
Transferred *bool `json:"transferred"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_body")
|
|
return
|
|
}
|
|
sets := []string{}
|
|
args := []any{}
|
|
if req.Flight != nil {
|
|
sets = append(sets, "flight=?")
|
|
args = append(args, clip(*req.Flight, 64))
|
|
}
|
|
if req.Date != nil {
|
|
sets = append(sets, "date=?")
|
|
args = append(args, clip(*req.Date, 32))
|
|
}
|
|
if req.PilotNumber != nil {
|
|
sets = append(sets, "pilot_number=?")
|
|
args = append(args, clip(*req.PilotNumber, 32))
|
|
}
|
|
if req.RuleNumber != nil {
|
|
sets = append(sets, "rule_number=?")
|
|
args = append(args, clip(*req.RuleNumber, 64))
|
|
}
|
|
if req.Task != nil {
|
|
sets = append(sets, "task=?")
|
|
args = append(args, clip(*req.Task, 64))
|
|
}
|
|
if req.PenaltiesText != nil {
|
|
sets = append(sets, "penalties_text=?")
|
|
args = append(args, clip(*req.PenaltiesText, 256))
|
|
}
|
|
if req.Description != nil {
|
|
sets = append(sets, "description=?")
|
|
args = append(args, clip(*req.Description, maxFieldLen))
|
|
}
|
|
if req.Transferred != nil {
|
|
v := 0
|
|
if *req.Transferred {
|
|
v = 1
|
|
}
|
|
sets = append(sets, "transferred=?")
|
|
args = append(args, v)
|
|
}
|
|
if len(sets) > 0 {
|
|
sets = append(sets, "updated_at=datetime('now')")
|
|
args = append(args, pid)
|
|
query := "UPDATE penalties SET " + strings.Join(sets, ",") + " WHERE id=?"
|
|
if _, err := db.Exec(query, args...); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "db_error")
|
|
return
|
|
}
|
|
}
|
|
updated, err := loadPenalty(pid)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "load_error")
|
|
return
|
|
}
|
|
hub.broadcast(id, "penalty_updated", updated)
|
|
writeJSON(w, http.StatusOK, updated)
|
|
}
|
|
|
|
func handleDeletePenalty(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
|
|
}
|
|
pid, err := strconv.ParseInt(r.PathValue("pid"), 10, 64)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_id")
|
|
return
|
|
}
|
|
u := userFromCtx(r)
|
|
var createdBy int64
|
|
if err := db.QueryRow("SELECT created_by FROM penalties WHERE id=? AND competition_id=?", pid, id).Scan(&createdBy); err != nil {
|
|
writeError(w, http.StatusNotFound, "not_found")
|
|
return
|
|
}
|
|
if !canEditPenalty(u, id, createdBy) {
|
|
writeError(w, http.StatusForbidden, "forbidden")
|
|
return
|
|
}
|
|
if closed, _ := isCompetitionClosed(id); closed {
|
|
writeError(w, http.StatusConflict, "competition_closed")
|
|
return
|
|
}
|
|
db.Exec("DELETE FROM penalties WHERE id=?", pid)
|
|
hub.broadcast(id, "penalty_deleted", map[string]int64{"id": pid})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func handleExportPenalties(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 != "chief_scorer" && role != "system_admin") {
|
|
writeError(w, http.StatusForbidden, "forbidden")
|
|
return
|
|
}
|
|
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
|
|
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 {
|
|
writeError(w, http.StatusInternalServerError, "db_error")
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"penalties_%d.csv\"", id))
|
|
cw := csv.NewWriter(w)
|
|
cw.Write([]string{"id", "flight", "date", "pilot_number", "pilot_name", "rule_number", "task", "penalties", "description", "created_by", "transferred", "created_at"})
|
|
for rows.Next() {
|
|
var idv int64
|
|
var flight, date, pnum, pname, rnum, task, pens, desc, creator, createdAt string
|
|
var transferred int
|
|
rows.Scan(&idv, &flight, &date, &pnum, &pname, &rnum, &task, &pens, &desc, &creator, &transferred, &createdAt)
|
|
t := "0"
|
|
if transferred == 1 {
|
|
t = "1"
|
|
}
|
|
cw.Write([]string{
|
|
strconv.FormatInt(idv, 10),
|
|
csvSafe(flight), csvSafe(date), csvSafe(pnum), csvSafe(pname),
|
|
csvSafe(rnum), csvSafe(task), csvSafe(pens), csvSafe(desc),
|
|
csvSafe(creator), t, createdAt,
|
|
})
|
|
}
|
|
cw.Flush()
|
|
}
|