2026-04-01 11:47:03 +04:00
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"errors"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"math/rand"
|
|
|
|
|
"net/http"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/labstack/echo/v4"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func (a *App) handleHealth(c echo.Context) error {
|
|
|
|
|
return c.JSON(http.StatusOK, map[string]string{"status": "ok"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) handleGetState(c echo.Context) error {
|
|
|
|
|
state, err := a.readState(a.isAdminRequest(c))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
|
|
|
|
}
|
|
|
|
|
return c.JSON(http.StatusOK, state)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) handleAdminLogin(c echo.Context) error {
|
|
|
|
|
var req AdminLoginRequest
|
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
|
|
|
return writeError(c, http.StatusBadRequest, "invalid request body")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !a.verifyAdmin(req.Username, req.Password) {
|
|
|
|
|
return writeError(c, http.StatusUnauthorized, "invalid credentials")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
token, expiry, err := a.sessions.CreateToken()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, "failed to create session")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return c.JSON(http.StatusOK, map[string]any{
|
|
|
|
|
"token": token,
|
|
|
|
|
"expiresAt": expiry.Format(time.RFC3339),
|
|
|
|
|
"username": a.cfg.AdminUser,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) handleAdminLogout(c echo.Context) error {
|
|
|
|
|
token := strings.TrimSpace(strings.TrimPrefix(c.Request().Header.Get(echo.HeaderAuthorization), "Bearer "))
|
|
|
|
|
a.sessions.DeleteToken(token)
|
|
|
|
|
return c.JSON(http.StatusOK, map[string]bool{"ok": true})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) handleUpdateAdminSettings(c echo.Context) error {
|
|
|
|
|
var req AdminSettingsUpdateRequest
|
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
|
|
|
return writeError(c, http.StatusBadRequest, "invalid request body")
|
|
|
|
|
}
|
2026-04-03 09:55:36 +04:00
|
|
|
if req.ViewProofInView == nil && req.LiveMode == nil {
|
|
|
|
|
return writeError(c, http.StatusBadRequest, "at least one settings field is required")
|
2026-04-01 11:47:03 +04:00
|
|
|
}
|
|
|
|
|
|
2026-04-03 09:55:36 +04:00
|
|
|
if err := a.updateSettings(req); err != nil {
|
|
|
|
|
if strings.Contains(err.Error(), "no settings to update") {
|
|
|
|
|
return writeError(c, http.StatusBadRequest, err.Error())
|
|
|
|
|
}
|
2026-04-01 11:47:03 +04:00
|
|
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
a.events.Broadcast()
|
|
|
|
|
|
|
|
|
|
state, err := a.readState(true)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
|
|
|
|
}
|
|
|
|
|
return c.JSON(http.StatusOK, state)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) handleCreatePlayer(c echo.Context) error {
|
|
|
|
|
var req PlayerCreateRequest
|
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
|
|
|
return writeError(c, http.StatusBadRequest, "invalid request body")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
nameAr := strings.TrimSpace(req.NameAr)
|
|
|
|
|
nameEn := strings.TrimSpace(req.NameEn)
|
|
|
|
|
group := normalizeGroup(req.GroupCode)
|
|
|
|
|
if nameAr == "" || nameEn == "" {
|
|
|
|
|
return writeError(c, http.StatusBadRequest, "both Arabic and English names are required")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
tx, err := a.db.Begin()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, "failed to start transaction")
|
|
|
|
|
}
|
|
|
|
|
defer tx.Rollback()
|
|
|
|
|
|
|
|
|
|
res, err := tx.Exec(`
|
|
|
|
|
INSERT INTO players(name_ar, name_en, group_code, image_data)
|
|
|
|
|
VALUES(?, ?, ?, '')
|
|
|
|
|
`, nameAr, nameEn, group)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("create player: %v", err))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
id64, err := res.LastInsertId()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, "failed to read new player id")
|
|
|
|
|
}
|
|
|
|
|
playerID := int(id64)
|
|
|
|
|
|
|
|
|
|
for _, stage := range scoreStages {
|
|
|
|
|
if _, err := tx.Exec(`INSERT OR IGNORE INTO scores(stage, player_id, score) VALUES(?, ?, 0)`, stage, playerID); err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("create score row: %v", err))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, "failed to commit create player")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
a.events.Broadcast()
|
|
|
|
|
|
|
|
|
|
state, err := a.readState(true)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return c.JSON(http.StatusCreated, state)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) handleUpdatePlayer(c echo.Context) error {
|
|
|
|
|
playerID, err := strconv.Atoi(c.Param("id"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusBadRequest, "invalid player id")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var req PlayerUpdateRequest
|
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
|
|
|
return writeError(c, http.StatusBadRequest, "invalid request body")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updates := []string{}
|
|
|
|
|
args := []any{}
|
|
|
|
|
|
|
|
|
|
if req.NameAr != nil {
|
|
|
|
|
nameAr := strings.TrimSpace(*req.NameAr)
|
|
|
|
|
if nameAr == "" {
|
|
|
|
|
return writeError(c, http.StatusBadRequest, "arabic name cannot be empty")
|
|
|
|
|
}
|
|
|
|
|
updates = append(updates, "name_ar = ?")
|
|
|
|
|
args = append(args, nameAr)
|
|
|
|
|
}
|
|
|
|
|
if req.NameEn != nil {
|
|
|
|
|
nameEn := strings.TrimSpace(*req.NameEn)
|
|
|
|
|
if nameEn == "" {
|
|
|
|
|
return writeError(c, http.StatusBadRequest, "english name cannot be empty")
|
|
|
|
|
}
|
|
|
|
|
updates = append(updates, "name_en = ?")
|
|
|
|
|
args = append(args, nameEn)
|
|
|
|
|
}
|
|
|
|
|
if req.GroupCode != nil {
|
|
|
|
|
updates = append(updates, "group_code = ?")
|
|
|
|
|
args = append(args, normalizeGroup(*req.GroupCode))
|
|
|
|
|
}
|
|
|
|
|
if req.ImageData != nil {
|
|
|
|
|
img := strings.TrimSpace(*req.ImageData)
|
|
|
|
|
if len(img) > 2_500_000 {
|
|
|
|
|
return writeError(c, http.StatusBadRequest, "image payload too large")
|
|
|
|
|
}
|
|
|
|
|
updates = append(updates, "image_data = ?")
|
|
|
|
|
args = append(args, img)
|
|
|
|
|
}
|
|
|
|
|
if req.ClearImage {
|
|
|
|
|
updates = append(updates, "image_data = ''")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(updates) == 0 {
|
|
|
|
|
return writeError(c, http.StatusBadRequest, "no fields to update")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
updates = append(updates, "updated_at = CURRENT_TIMESTAMP")
|
|
|
|
|
args = append(args, playerID)
|
|
|
|
|
|
|
|
|
|
query := fmt.Sprintf("UPDATE players SET %s WHERE id = ?", strings.Join(updates, ", "))
|
|
|
|
|
res, err := a.db.Exec(query, args...)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("update player: %v", err))
|
|
|
|
|
}
|
|
|
|
|
affected, err := res.RowsAffected()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, "failed to check update result")
|
|
|
|
|
}
|
|
|
|
|
if affected == 0 {
|
|
|
|
|
return writeError(c, http.StatusNotFound, "player not found")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
a.events.Broadcast()
|
|
|
|
|
|
|
|
|
|
state, err := a.readState(true)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return c.JSON(http.StatusOK, state)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) handleDeletePlayer(c echo.Context) error {
|
|
|
|
|
playerID, err := strconv.Atoi(c.Param("id"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusBadRequest, "invalid player id")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res, err := a.db.Exec(`DELETE FROM players WHERE id = ?`, playerID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("delete player: %v", err))
|
|
|
|
|
}
|
|
|
|
|
affected, err := res.RowsAffected()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, "failed to check delete result")
|
|
|
|
|
}
|
|
|
|
|
if affected == 0 {
|
|
|
|
|
return writeError(c, http.StatusNotFound, "player not found")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
a.events.Broadcast()
|
|
|
|
|
|
|
|
|
|
state, err := a.readState(true)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return c.JSON(http.StatusOK, state)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) handleAutoGroupPlayers(c echo.Context) error {
|
|
|
|
|
var req AutoGroupPlayersRequest
|
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
|
|
|
return writeError(c, http.StatusBadRequest, "invalid request body")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
groups := normalizeGroups(req.Groups)
|
|
|
|
|
if len(groups) == 0 {
|
|
|
|
|
return writeError(c, http.StatusBadRequest, "at least one group is required")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rows, err := a.db.Query(`SELECT id FROM players ORDER BY id ASC`)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("query players: %v", err))
|
|
|
|
|
}
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
|
|
playerIDs := []int{}
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
var id int
|
|
|
|
|
if err := rows.Scan(&id); err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("scan player: %v", err))
|
|
|
|
|
}
|
|
|
|
|
playerIDs = append(playerIDs, id)
|
|
|
|
|
}
|
|
|
|
|
if err := rows.Err(); err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("read players: %v", err))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
|
|
|
|
|
rng.Shuffle(len(playerIDs), func(i, j int) {
|
|
|
|
|
playerIDs[i], playerIDs[j] = playerIDs[j], playerIDs[i]
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
tx, err := a.db.Begin()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, "failed to start transaction")
|
|
|
|
|
}
|
|
|
|
|
defer tx.Rollback()
|
|
|
|
|
|
|
|
|
|
for i, playerID := range playerIDs {
|
|
|
|
|
groupCode := groups[i%len(groups)]
|
|
|
|
|
if _, err := tx.Exec(`UPDATE players SET group_code = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, groupCode, playerID); err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("assign player group: %v", err))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := tx.Commit(); err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, "failed to commit group assignment")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
a.events.Broadcast()
|
|
|
|
|
|
|
|
|
|
state, err := a.readState(true)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
|
|
|
|
}
|
|
|
|
|
return c.JSON(http.StatusOK, state)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) handleUpdateScore(c echo.Context) error {
|
|
|
|
|
stage, err := validateStage(c.Param("stage"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusBadRequest, err.Error())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
playerID, err := strconv.Atoi(c.Param("id"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusBadRequest, "invalid player id")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var req ScoreUpdateRequest
|
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
|
|
|
return writeError(c, http.StatusBadRequest, "invalid request body")
|
|
|
|
|
}
|
|
|
|
|
if req.Score < 0 || req.Score > 9999 {
|
|
|
|
|
return writeError(c, http.StatusBadRequest, "score must be between 0 and 9999")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
res, err := a.db.Exec(`
|
|
|
|
|
INSERT INTO scores(stage, player_id, score)
|
|
|
|
|
VALUES(?, ?, ?)
|
|
|
|
|
ON CONFLICT(stage, player_id) DO UPDATE SET score = excluded.score, updated_at = CURRENT_TIMESTAMP
|
|
|
|
|
`, stage, playerID, req.Score)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("update score: %v", err))
|
|
|
|
|
}
|
|
|
|
|
if _, err := res.RowsAffected(); err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, "failed to check score update")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
a.events.Broadcast()
|
|
|
|
|
|
|
|
|
|
state, err := a.readState(true)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return c.JSON(http.StatusOK, state)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) handleResetStageScores(c echo.Context) error {
|
2026-04-03 09:55:36 +04:00
|
|
|
targetStages, err := resolveResetStages(c.Param("stage"))
|
2026-04-01 11:47:03 +04:00
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusBadRequest, err.Error())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var req ResetStageRequest
|
|
|
|
|
if err := c.Bind(&req); err != nil && !errors.Is(err, io.EOF) {
|
|
|
|
|
return writeError(c, http.StatusBadRequest, "invalid request body")
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 09:55:36 +04:00
|
|
|
tx, err := a.db.Begin()
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, "failed to start reset transaction")
|
2026-04-01 11:47:03 +04:00
|
|
|
}
|
2026-04-03 09:55:36 +04:00
|
|
|
defer tx.Rollback()
|
|
|
|
|
|
|
|
|
|
for _, stage := range targetStages {
|
|
|
|
|
if _, err := tx.Exec(`UPDATE scores SET score = 0, updated_at = CURRENT_TIMESTAMP WHERE stage = ?`, stage); err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("reset stage scores: %v", err))
|
|
|
|
|
}
|
|
|
|
|
if req.ResetProofs {
|
|
|
|
|
if _, err := tx.Exec(`DELETE FROM score_attachments WHERE stage = ?`, stage); err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("reset stage proofs: %v", err))
|
|
|
|
|
}
|
2026-04-01 11:47:03 +04:00
|
|
|
}
|
|
|
|
|
}
|
2026-04-03 09:55:36 +04:00
|
|
|
if err := tx.Commit(); err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("commit stage reset: %v", err))
|
|
|
|
|
}
|
2026-04-01 11:47:03 +04:00
|
|
|
|
|
|
|
|
a.events.Broadcast()
|
|
|
|
|
|
|
|
|
|
state, err := a.readState(true)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return c.JSON(http.StatusOK, state)
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-03 09:55:36 +04:00
|
|
|
func resolveResetStages(stage string) ([]string, error) {
|
|
|
|
|
switch strings.ToLower(strings.TrimSpace(stage)) {
|
|
|
|
|
case "preliminary":
|
|
|
|
|
return append([]string{}, preliminaryRoundStages...), nil
|
|
|
|
|
case "final":
|
|
|
|
|
return append([]string{}, finalRoundStages...), nil
|
|
|
|
|
case "prelim_tiebreak", "final_tiebreak":
|
|
|
|
|
normalized, err := validateStage(stage)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return []string{normalized}, nil
|
|
|
|
|
default:
|
|
|
|
|
return nil, fmt.Errorf("invalid stage")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 11:47:03 +04:00
|
|
|
func (a *App) handleUpdateScoreProof(c echo.Context) error {
|
|
|
|
|
stage, err := validateStage(c.Param("stage"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusBadRequest, err.Error())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
playerID, err := strconv.Atoi(c.Param("id"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusBadRequest, "invalid player id")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var req ScoreProofUpdateRequest
|
|
|
|
|
if err := c.Bind(&req); err != nil {
|
|
|
|
|
return writeError(c, http.StatusBadRequest, "invalid request body")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
imageData := strings.TrimSpace(req.ImageData)
|
|
|
|
|
if imageData == "" {
|
|
|
|
|
return writeError(c, http.StatusBadRequest, "imageData is required")
|
|
|
|
|
}
|
|
|
|
|
if len(imageData) > 5_000_000 {
|
|
|
|
|
return writeError(c, http.StatusBadRequest, "image payload too large")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if _, err := a.db.Exec(`
|
|
|
|
|
INSERT INTO score_attachments(stage, player_id, image_data)
|
|
|
|
|
VALUES(?, ?, ?)
|
|
|
|
|
ON CONFLICT(stage, player_id) DO UPDATE SET image_data = excluded.image_data, updated_at = CURRENT_TIMESTAMP
|
|
|
|
|
`, stage, playerID, imageData); err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("update score proof: %v", err))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
a.events.Broadcast()
|
|
|
|
|
|
|
|
|
|
state, err := a.readState(true)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
|
|
|
|
}
|
|
|
|
|
return c.JSON(http.StatusOK, state)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *App) handleDeleteScoreProof(c echo.Context) error {
|
|
|
|
|
stage, err := validateStage(c.Param("stage"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusBadRequest, err.Error())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
playerID, err := strconv.Atoi(c.Param("id"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusBadRequest, "invalid player id")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if _, err := a.db.Exec(`DELETE FROM score_attachments WHERE stage = ? AND player_id = ?`, stage, playerID); err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("delete score proof: %v", err))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
a.events.Broadcast()
|
|
|
|
|
|
|
|
|
|
state, err := a.readState(true)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return writeError(c, http.StatusInternalServerError, err.Error())
|
|
|
|
|
}
|
|
|
|
|
return c.JSON(http.StatusOK, state)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func validateStage(stage string) (string, error) {
|
|
|
|
|
normalized := strings.ToLower(strings.TrimSpace(stage))
|
|
|
|
|
for _, allowed := range scoreStages {
|
|
|
|
|
if normalized == allowed {
|
|
|
|
|
return normalized, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return "", fmt.Errorf("invalid stage")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func normalizeGroup(group string) string {
|
|
|
|
|
return strings.TrimSpace(group)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func normalizeGroups(groups []string) []string {
|
|
|
|
|
seen := map[string]bool{}
|
|
|
|
|
out := []string{}
|
|
|
|
|
for _, group := range groups {
|
|
|
|
|
normalized := normalizeGroup(group)
|
|
|
|
|
if normalized == "" || seen[normalized] {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
seen[normalized] = true
|
|
|
|
|
out = append(out, normalized)
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func writeError(c echo.Context, status int, message string) error {
|
|
|
|
|
return c.JSON(status, map[string]string{"message": message})
|
|
|
|
|
}
|