Files
PenaltyTracker/penalties.go
T

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()
}