This commit is contained in:
2026-04-01 11:47:03 +04:00
parent cb68451c1c
commit 2465bc2ec3
43 changed files with 8210 additions and 0 deletions

1
backend/.env Normal file
View File

@@ -0,0 +1 @@
GEMINI_API_KEY=AIzaSyATpv4fmHpjPPLk-BEy4fCBL_r1EWtiWDc

95
backend/ai_handlers.go Normal file
View File

@@ -0,0 +1,95 @@
package main
import (
"database/sql"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/labstack/echo/v4"
)
func (a *App) handleScoreAdvice(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 strings.TrimSpace(a.cfg.GeminiAPIKey) == "" {
return writeError(c, http.StatusServiceUnavailable, "gemini api key is not configured")
}
proofImageData, err := a.loadScoreProofImage(stage, playerID)
if err != nil {
if err == sql.ErrNoRows {
return writeError(c, http.StatusBadRequest, "no proof image found for this score")
}
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("load proof image: %v", err))
}
if strings.TrimSpace(proofImageData) == "" {
return writeError(c, http.StatusBadRequest, "no proof image found for this score")
}
currentScore, _ := a.loadCurrentScore(stage, playerID)
advice, err := a.generateScoreAdvice(stage, playerID, proofImageData, currentScore)
if err != nil {
return writeError(c, http.StatusBadGateway, err.Error())
}
return c.JSON(http.StatusOK, advice)
}
func (a *App) loadScoreProofImage(stage string, playerID int) (string, error) {
var imageData string
err := a.db.QueryRow(`SELECT image_data FROM score_attachments WHERE stage = ? AND player_id = ?`, stage, playerID).Scan(&imageData)
if err != nil {
return "", err
}
return imageData, nil
}
func (a *App) loadCurrentScore(stage string, playerID int) (int, error) {
var score int
err := a.db.QueryRow(`SELECT score FROM scores WHERE stage = ? AND player_id = ?`, stage, playerID).Scan(&score)
if err != nil {
if err == sql.ErrNoRows {
return 0, nil
}
return 0, err
}
return score, nil
}
func (a *App) buildAdviceResponse(stage string, playerID int, raw scoreAdviceModelResponse) ScoreAdviceResponse {
advised := clampInt(raw.AdvisedScore, 0, 9999)
reason := strings.TrimSpace(raw.Reason)
if reason == "" {
reason = "AI estimated the score from visible impacts."
}
return ScoreAdviceResponse{
Stage: stage,
PlayerID: playerID,
AdvisedScore: advised,
Reason: reason,
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
}
}
func clampInt(value, min, max int) int {
if value < min {
return min
}
if value > max {
return max
}
return value
}

93
backend/auth.go Normal file
View File

@@ -0,0 +1,93 @@
package main
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"net/http"
"strings"
"time"
"github.com/labstack/echo/v4"
)
func (a *App) isAdminRequest(c echo.Context) bool {
header := strings.TrimSpace(c.Request().Header.Get(echo.HeaderAuthorization))
if header == "" || !strings.HasPrefix(strings.ToLower(header), "bearer ") {
return false
}
token := strings.TrimSpace(header[7:])
if token == "" {
return false
}
return a.sessions.ValidateToken(token)
}
func (a *App) adminOnly(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
a.sessions.PurgeExpired()
header := strings.TrimSpace(c.Request().Header.Get(echo.HeaderAuthorization))
if header == "" || !strings.HasPrefix(strings.ToLower(header), "bearer ") {
return writeError(c, http.StatusUnauthorized, "missing admin token")
}
token := strings.TrimSpace(header[7:])
if token == "" || !a.sessions.ValidateToken(token) {
return writeError(c, http.StatusUnauthorized, "invalid or expired admin token")
}
return next(c)
}
}
func (a *App) verifyAdmin(username, password string) bool {
u := strings.TrimSpace(username)
p := strings.TrimSpace(password)
if u == "" || p == "" {
return false
}
userMatch := subtle.ConstantTimeCompare([]byte(u), []byte(a.cfg.AdminUser)) == 1
passMatch := subtle.ConstantTimeCompare([]byte(p), []byte(a.cfg.AdminPass)) == 1
return userMatch && passMatch
}
func (s *SessionStore) CreateToken() (string, time.Time, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", time.Time{}, err
}
token := hex.EncodeToString(raw)
expires := time.Now().UTC().Add(s.duration)
s.mu.Lock()
s.tokens[token] = expires
s.mu.Unlock()
return token, expires, nil
}
func (s *SessionStore) ValidateToken(token string) bool {
s.mu.RLock()
expires, ok := s.tokens[token]
s.mu.RUnlock()
if !ok {
return false
}
return time.Now().UTC().Before(expires)
}
func (s *SessionStore) DeleteToken(token string) {
s.mu.Lock()
delete(s.tokens, token)
s.mu.Unlock()
}
func (s *SessionStore) PurgeExpired() {
now := time.Now().UTC()
s.mu.Lock()
for token, expiry := range s.tokens {
if now.After(expiry) {
delete(s.tokens, token)
}
}
s.mu.Unlock()
}

36
backend/config.go Normal file
View File

@@ -0,0 +1,36 @@
package main
import (
"os"
"strings"
)
type Config struct {
Port string
DBPath string
WebDir string
AdminUser string
AdminPass string
GeminiAPIKey string
GeminiModel string
}
func loadConfig() Config {
return Config{
Port: envOrDefault("PORT", "8080"),
DBPath: envOrDefault("DB_PATH", "./data/shooting.db"),
WebDir: envOrDefault("WEB_DIR", "./web"),
AdminUser: envOrDefault("ADMIN_USER", "datwyler"),
AdminPass: envOrDefault("ADMIN_PASS", "datwyler"),
GeminiAPIKey: envOrDefault("GEMINI_API_KEY", "AIzaSyATpv4fmHpjPPLk-BEy4fCBL_r1EWtiWDc"),
GeminiModel: envOrDefault("GEMINI_MODEL", "gemini-3.1-flash-lite-preview"),
}
}
func envOrDefault(key, fallback string) string {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return fallback
}
return v
}

76
backend/db.go Normal file
View File

@@ -0,0 +1,76 @@
package main
import (
"database/sql"
"fmt"
"os"
"path/filepath"
_ "modernc.org/sqlite"
)
func initDB(cfg Config) (*sql.DB, error) {
if err := os.MkdirAll(filepath.Dir(cfg.DBPath), 0o755); err != nil {
return nil, fmt.Errorf("create data dir: %w", err)
}
db, err := sql.Open("sqlite", cfg.DBPath)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
if _, err := db.Exec(`
PRAGMA foreign_keys = ON;
PRAGMA journal_mode = WAL;
PRAGMA busy_timeout = 5000;
`); err != nil {
return nil, fmt.Errorf("sqlite pragmas: %w", err)
}
schema := `
CREATE TABLE IF NOT EXISTS players (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name_ar TEXT NOT NULL,
name_en TEXT NOT NULL,
group_code TEXT NOT NULL DEFAULT '',
image_data TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS scores (
stage TEXT NOT NULL,
player_id INTEGER NOT NULL,
score INTEGER NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(stage, player_id),
FOREIGN KEY(player_id) REFERENCES players(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS score_attachments (
stage TEXT NOT NULL,
player_id INTEGER NOT NULL,
image_data TEXT NOT NULL DEFAULT '',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY(stage, player_id),
FOREIGN KEY(player_id) REFERENCES players(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT OR IGNORE INTO app_settings(key, value) VALUES
('view_proof_in_view', '0');
`
if _, err := db.Exec(schema); err != nil {
return nil, fmt.Errorf("apply schema: %w", err)
}
return db, nil
}

83
backend/events.go Normal file
View File

@@ -0,0 +1,83 @@
package main
import (
"fmt"
"net/http"
"time"
"github.com/labstack/echo/v4"
)
func (a *App) handleEvents(c echo.Context) error {
res := c.Response()
req := c.Request()
res.Header().Set(echo.HeaderContentType, "text/event-stream")
res.Header().Set("Cache-Control", "no-cache, no-transform")
res.Header().Set("Connection", "keep-alive")
res.Header().Set("X-Accel-Buffering", "no")
res.WriteHeader(http.StatusOK)
flusher, ok := res.Writer.(http.Flusher)
if !ok {
return writeError(c, http.StatusInternalServerError, "streaming not supported")
}
if _, err := fmt.Fprintf(res, "event: ready\ndata: {\"ts\":\"%s\"}\n\n", time.Now().UTC().Format(time.RFC3339)); err != nil {
return nil
}
flusher.Flush()
id, events := a.events.Subscribe()
defer a.events.Unsubscribe(id)
keepAlive := time.NewTicker(20 * time.Second)
defer keepAlive.Stop()
for {
select {
case <-req.Context().Done():
return nil
case <-events:
if _, err := fmt.Fprintf(res, "event: state\ndata: {\"ts\":\"%s\"}\n\n", time.Now().UTC().Format(time.RFC3339)); err != nil {
return nil
}
flusher.Flush()
case t := <-keepAlive.C:
if _, err := fmt.Fprintf(res, ": ping %d\n\n", t.Unix()); err != nil {
return nil
}
flusher.Flush()
}
}
}
func (h *EventHub) Subscribe() (int, <-chan struct{}) {
h.mu.Lock()
defer h.mu.Unlock()
h.nextID++
id := h.nextID
ch := make(chan struct{}, 1)
h.subscribers[id] = ch
return id, ch
}
func (h *EventHub) Unsubscribe(id int) {
h.mu.Lock()
defer h.mu.Unlock()
if ch, ok := h.subscribers[id]; ok {
delete(h.subscribers, id)
close(ch)
}
}
func (h *EventHub) Broadcast() {
h.mu.RLock()
defer h.mu.RUnlock()
for _, ch := range h.subscribers {
select {
case ch <- struct{}{}:
default:
}
}
}

203
backend/gemini.go Normal file
View File

@@ -0,0 +1,203 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
type scoreAdviceModelResponse struct {
AdvisedScore int `json:"advisedScore"`
Reason string `json:"reason"`
}
type geminiGenerateRequest struct {
Contents []geminiContent `json:"contents"`
GenerationConfig geminiGenerationConfig `json:"generationConfig,omitempty"`
SafetySettings []map[string]interface{} `json:"safetySettings,omitempty"`
}
type geminiContent struct {
Role string `json:"role,omitempty"`
Parts []geminiPart `json:"parts"`
}
type geminiPart struct {
Text string `json:"text,omitempty"`
InlineData *geminiInlineData `json:"inline_data,omitempty"`
}
type geminiInlineData struct {
MimeType string `json:"mime_type"`
Data string `json:"data"`
}
type geminiGenerationConfig struct {
Temperature float64 `json:"temperature,omitempty"`
ResponseMimeType string `json:"responseMimeType,omitempty"`
MaxOutputTokens int `json:"maxOutputTokens,omitempty"`
}
type geminiGenerateResponse struct {
Candidates []struct {
Content struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"content"`
} `json:"candidates"`
Error *struct {
Message string `json:"message"`
} `json:"error,omitempty"`
}
func (a *App) generateScoreAdvice(stage string, playerID int, imageData string, currentScore int) (ScoreAdviceResponse, error) {
mimeType, rawBase64, err := parseDataURI(imageData)
if err != nil {
return ScoreAdviceResponse{}, fmt.Errorf("invalid proof image data: %w", err)
}
model := strings.TrimSpace(a.cfg.GeminiModel)
if model == "" {
model = "gemini-2.0-flash"
}
requestPayload := geminiGenerateRequest{
Contents: []geminiContent{
{
Role: "user",
Parts: []geminiPart{
{Text: scoreAdvicePrompt(stage, currentScore)},
{
InlineData: &geminiInlineData{
MimeType: mimeType,
Data: rawBase64,
},
},
},
},
},
GenerationConfig: geminiGenerationConfig{
Temperature: 0,
ResponseMimeType: "application/json",
MaxOutputTokens: 100,
},
}
body, err := json.Marshal(requestPayload)
if err != nil {
return ScoreAdviceResponse{}, fmt.Errorf("marshal gemini request: %w", err)
}
endpoint := fmt.Sprintf(
"https://generativelanguage.googleapis.com/v1beta/models/%s:generateContent?key=%s",
url.PathEscape(model),
url.QueryEscape(a.cfg.GeminiAPIKey),
)
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
return ScoreAdviceResponse{}, fmt.Errorf("create gemini request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 25 * time.Second}
resp, err := client.Do(req)
if err != nil {
return ScoreAdviceResponse{}, fmt.Errorf("call gemini api: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 2_000_000))
if err != nil {
return ScoreAdviceResponse{}, fmt.Errorf("read gemini response: %w", err)
}
if resp.StatusCode >= 300 {
return ScoreAdviceResponse{}, fmt.Errorf("gemini api status %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
var generated geminiGenerateResponse
if err := json.Unmarshal(respBody, &generated); err != nil {
return ScoreAdviceResponse{}, fmt.Errorf("decode gemini response: %w", err)
}
if generated.Error != nil && strings.TrimSpace(generated.Error.Message) != "" {
return ScoreAdviceResponse{}, fmt.Errorf("gemini api error: %s", generated.Error.Message)
}
if len(generated.Candidates) == 0 || len(generated.Candidates[0].Content.Parts) == 0 {
return ScoreAdviceResponse{}, fmt.Errorf("gemini returned no advice")
}
rawText := strings.TrimSpace(generated.Candidates[0].Content.Parts[0].Text)
jsonText := extractJSONObject(rawText)
if jsonText == "" {
return ScoreAdviceResponse{}, fmt.Errorf("gemini response was not valid json")
}
var modelResponse scoreAdviceModelResponse
if err := json.Unmarshal([]byte(jsonText), &modelResponse); err != nil {
return ScoreAdviceResponse{}, fmt.Errorf("parse gemini advice json: %w", err)
}
return a.buildAdviceResponse(stage, playerID, modelResponse), nil
}
func parseDataURI(dataURI string) (string, string, error) {
value := strings.TrimSpace(dataURI)
if !strings.HasPrefix(value, "data:") {
return "", "", fmt.Errorf("expected data uri")
}
parts := strings.SplitN(value, ",", 2)
if len(parts) != 2 {
return "", "", fmt.Errorf("invalid data uri format")
}
header := parts[0]
payload := strings.TrimSpace(parts[1])
if payload == "" {
return "", "", fmt.Errorf("empty data uri payload")
}
mimeType := "image/jpeg"
if semicolon := strings.Index(header, ";"); semicolon > len("data:") {
mimeType = strings.TrimSpace(header[len("data:"):semicolon])
}
if mimeType == "" {
mimeType = "image/jpeg"
}
return mimeType, payload, nil
}
func extractJSONObject(raw string) string {
text := strings.TrimSpace(raw)
if text == "" {
return ""
}
if strings.HasPrefix(text, "```") {
text = strings.TrimPrefix(text, "```json")
text = strings.TrimPrefix(text, "```")
text = strings.TrimSuffix(strings.TrimSpace(text), "```")
text = strings.TrimSpace(text)
}
start := strings.Index(text, "{")
end := strings.LastIndex(text, "}")
if start == -1 || end == -1 || end <= start {
return ""
}
return strings.TrimSpace(text[start : end+1])
}
func scoreAdvicePrompt(stage string, currentScore int) string {
return fmt.Sprintf(`Target scoring assistant.
Stage: %s. Current score: %d.
Return STRICT JSON only:
{"advisedScore":<int 0..9999>,"reason":"<max 18 words>"}
Do not add markdown or extra fields.`, stage, currentScore)
}

29
backend/go.mod Normal file
View File

@@ -0,0 +1,29 @@
module shooting-event/backend
go 1.24.0
require (
github.com/labstack/echo/v4 v4.13.4
modernc.org/sqlite v1.39.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
golang.org/x/crypto v0.38.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/net v0.40.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/time v0.11.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

75
backend/go.sum Normal file
View File

@@ -0,0 +1,75 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

461
backend/handlers.go Normal file
View File

@@ -0,0 +1,461 @@
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")
}
if req.ViewProofInView == nil {
return writeError(c, http.StatusBadRequest, "viewProofInView is required")
}
if err := a.updateViewProofInView(*req.ViewProofInView); err != nil {
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 {
stage, err := validateStage(c.Param("stage"))
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")
}
if _, err := a.db.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 := a.db.Exec(`DELETE FROM score_attachments WHERE stage = ?`, stage); err != nil {
return writeError(c, http.StatusInternalServerError, fmt.Sprintf("reset stage proofs: %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) 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})
}

54
backend/main.go Normal file
View File

@@ -0,0 +1,54 @@
package main
import (
"errors"
"log"
"net/http"
"time"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
)
func main() {
cfg := loadConfig()
db, err := initDB(cfg)
if err != nil {
log.Fatalf("init db: %v", err)
}
defer db.Close()
app := &App{
db: db,
cfg: cfg,
sessions: &SessionStore{
tokens: map[string]time.Time{},
duration: 8 * time.Hour,
},
events: &EventHub{
subscribers: map[int]chan struct{}{},
},
}
e := echo.New()
e.HideBanner = true
e.HidePort = true
e.Use(middleware.Recover())
e.Use(middleware.Logger())
e.Use(middleware.RequestID())
e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
AllowOrigins: []string{"*"},
AllowMethods: []string{http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, http.MethodOptions},
AllowHeaders: []string{echo.HeaderContentType, echo.HeaderAuthorization},
}))
registerAPIRoutes(e, app)
registerWebRoutes(e, cfg)
addr := ":" + cfg.Port
log.Printf("listening on %s", addr)
if err := e.Start(addr); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
}

141
backend/models.go Normal file
View File

@@ -0,0 +1,141 @@
package main
import (
"database/sql"
"sync"
"time"
)
var scoreStages = []string{"preliminary", "prelim_tiebreak", "final", "final_tiebreak"}
type App struct {
db *sql.DB
cfg Config
sessions *SessionStore
events *EventHub
}
type SessionStore struct {
mu sync.RWMutex
tokens map[string]time.Time
duration time.Duration
}
type EventHub struct {
mu sync.RWMutex
nextID int
subscribers map[int]chan struct{}
}
type CompetitionMeta struct {
TitleAr string `json:"titleAr"`
TitleEn string `json:"titleEn"`
}
type Player struct {
ID int `json:"id"`
NameAr string `json:"nameAr"`
NameEn string `json:"nameEn"`
GroupCode string `json:"groupCode"`
ImageData string `json:"imageData"`
}
type RankingRow struct {
PlayerID int `json:"playerId"`
NameAr string `json:"nameAr"`
NameEn string `json:"nameEn"`
GroupCode string `json:"groupCode"`
ImageData string `json:"imageData"`
Score int `json:"score"`
TieBreak int `json:"tieBreak"`
Rank int `json:"rank"`
Seed int `json:"seed"`
FinalGroup int `json:"finalGroup"`
}
type TieBreakInfo struct {
Required bool `json:"required"`
Resolved bool `json:"resolved"`
Slots int `json:"slots"`
PlayerIDs []int `json:"playerIds"`
}
type DerivedState struct {
PreliminaryRanking RankingBundle `json:"preliminaryRanking"`
Finalists []RankingRow `json:"finalists"`
FinalGroups FinalGroups `json:"finalGroups"`
FinalRanking RankingBundle `json:"finalRanking"`
Podium []RankingRow `json:"podium"`
}
type RankingBundle struct {
Rows []RankingRow `json:"rows"`
TieBreak TieBreakInfo `json:"tieBreak"`
Unresolved bool `json:"unresolved"`
}
type FinalGroups struct {
Group1 []RankingRow `json:"group1"`
Group2 []RankingRow `json:"group2"`
}
type StateResponse struct {
Competition CompetitionMeta `json:"competition"`
Players []Player `json:"players"`
Scores map[string]map[string]int `json:"scores"`
ScoreProofs map[string]map[string]string `json:"scoreProofs,omitempty"`
Settings AppSettings `json:"settings"`
Derived DerivedState `json:"derived"`
ServerTime string `json:"serverTime"`
}
type AppSettings struct {
ViewProofInView bool `json:"viewProofInView"`
}
type AdminLoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type PlayerCreateRequest struct {
NameAr string `json:"nameAr"`
NameEn string `json:"nameEn"`
GroupCode string `json:"groupCode"`
}
type PlayerUpdateRequest struct {
NameAr *string `json:"nameAr"`
NameEn *string `json:"nameEn"`
GroupCode *string `json:"groupCode"`
ImageData *string `json:"imageData"`
ClearImage bool `json:"clearImage"`
}
type ScoreUpdateRequest struct {
Score int `json:"score"`
}
type ScoreProofUpdateRequest struct {
ImageData string `json:"imageData"`
}
type AdminSettingsUpdateRequest struct {
ViewProofInView *bool `json:"viewProofInView"`
}
type ResetStageRequest struct {
ResetProofs bool `json:"resetProofs"`
}
type AutoGroupPlayersRequest struct {
Groups []string `json:"groups"`
}
type ScoreAdviceResponse struct {
Stage string `json:"stage"`
PlayerID int `json:"playerId"`
AdvisedScore int `json:"advisedScore"`
Reason string `json:"reason"`
GeneratedAt string `json:"generatedAt"`
}

57
backend/routes.go Normal file
View File

@@ -0,0 +1,57 @@
package main
import (
"net/http"
"os"
"path/filepath"
"strings"
"github.com/labstack/echo/v4"
)
func registerAPIRoutes(e *echo.Echo, app *App) {
api := e.Group("/api")
api.GET("/health", app.handleHealth)
api.GET("/state", app.handleGetState)
api.GET("/events", app.handleEvents)
api.POST("/admin/login", app.handleAdminLogin)
api.POST("/admin/logout", app.handleAdminLogout, app.adminOnly)
api.GET("/admin/state", app.handleGetState, app.adminOnly)
api.PUT("/admin/settings", app.handleUpdateAdminSettings, app.adminOnly)
api.POST("/admin/players", app.handleCreatePlayer, app.adminOnly)
api.POST("/admin/players/auto-group", app.handleAutoGroupPlayers, app.adminOnly)
api.PUT("/admin/players/:id", app.handleUpdatePlayer, app.adminOnly)
api.DELETE("/admin/players/:id", app.handleDeletePlayer, app.adminOnly)
api.PUT("/admin/scores/:stage/:id", app.handleUpdateScore, app.adminOnly)
api.PUT("/admin/scores/:stage/:id/proof", app.handleUpdateScoreProof, app.adminOnly)
api.DELETE("/admin/scores/:stage/:id/proof", app.handleDeleteScoreProof, app.adminOnly)
api.POST("/admin/scores/:stage/:id/advice", app.handleScoreAdvice, app.adminOnly)
api.POST("/admin/scores/:stage/reset", app.handleResetStageScores, app.adminOnly)
}
func registerWebRoutes(e *echo.Echo, cfg Config) {
e.GET("/*", func(c echo.Context) error {
requestPath := strings.TrimPrefix(c.Param("*"), "/")
if strings.HasPrefix(requestPath, "api") {
return echo.ErrNotFound
}
if requestPath == "" {
requestPath = "index.html"
}
clean := strings.TrimPrefix(filepath.Clean("/"+requestPath), "/")
assetPath := filepath.Join(cfg.WebDir, clean)
if stat, err := os.Stat(assetPath); err == nil && !stat.IsDir() {
return c.File(assetPath)
}
indexPath := filepath.Join(cfg.WebDir, "index.html")
if stat, err := os.Stat(indexPath); err == nil && !stat.IsDir() {
return c.File(indexPath)
}
return c.String(http.StatusNotFound, "frontend build not found. run make build")
})
}

47
backend/settings.go Normal file
View File

@@ -0,0 +1,47 @@
package main
import (
"database/sql"
"fmt"
"strings"
)
func (a *App) readSettings() (AppSettings, error) {
var raw string
err := a.db.QueryRow(`SELECT value FROM app_settings WHERE key = 'view_proof_in_view'`).Scan(&raw)
if err != nil {
if err == sql.ErrNoRows {
return AppSettings{ViewProofInView: false}, nil
}
return AppSettings{}, fmt.Errorf("read app setting view_proof_in_view: %w", err)
}
return AppSettings{ViewProofInView: settingBool(raw)}, nil
}
func (a *App) updateViewProofInView(enabled bool) error {
_, err := a.db.Exec(`
INSERT INTO app_settings(key, value, updated_at)
VALUES('view_proof_in_view', ?, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP
`, settingString(enabled))
if err != nil {
return fmt.Errorf("update app setting view_proof_in_view: %w", err)
}
return nil
}
func settingBool(value string) bool {
switch strings.TrimSpace(strings.ToLower(value)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
func settingString(value bool) string {
if value {
return "1"
}
return "0"
}

482
backend/state.go Normal file
View File

@@ -0,0 +1,482 @@
package main
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
)
func (a *App) readState(includeAllProofs bool) (StateResponse, error) {
players, err := a.readPlayers()
if err != nil {
return StateResponse{}, err
}
if err := a.ensureScoreRows(players); err != nil {
return StateResponse{}, err
}
settings, err := a.readSettings()
if err != nil {
return StateResponse{}, err
}
scoreMap, err := a.readScores(players)
if err != nil {
return StateResponse{}, err
}
includeScoreProofs := includeAllProofs || settings.ViewProofInView
var scoreProofs map[string]map[int]string
if includeScoreProofs {
scoreProofs, err = a.readScoreProofs(players)
if err != nil {
return StateResponse{}, err
}
}
derived := computeDerived(players, scoreMap)
response := StateResponse{
Competition: CompetitionMeta{
TitleAr: "بطولة دويتوايلر للرماية",
TitleEn: "Datwyler Shooting Event",
},
Players: players,
Scores: scoreMapToJSON(scoreMap),
Settings: settings,
Derived: derived,
ServerTime: time.Now().UTC().Format(time.RFC3339),
}
if includeScoreProofs {
response.ScoreProofs = scoreProofMapToJSON(scoreProofs)
}
return response, nil
}
func (a *App) readPlayers() ([]Player, error) {
rows, err := a.db.Query(`
SELECT id, name_ar, name_en, group_code, image_data
FROM players
ORDER BY id ASC
`)
if err != nil {
return nil, fmt.Errorf("query players: %w", err)
}
defer rows.Close()
players := []Player{}
for rows.Next() {
var p Player
if err := rows.Scan(&p.ID, &p.NameAr, &p.NameEn, &p.GroupCode, &p.ImageData); err != nil {
return nil, fmt.Errorf("scan player: %w", err)
}
players = append(players, p)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate players: %w", err)
}
return players, nil
}
func (a *App) readScoreProofs(players []Player) (map[string]map[int]string, error) {
proofs := map[string]map[int]string{}
for _, stage := range scoreStages {
proofs[stage] = map[int]string{}
}
for _, p := range players {
for _, stage := range scoreStages {
proofs[stage][p.ID] = ""
}
}
rows, err := a.db.Query(`SELECT stage, player_id, image_data FROM score_attachments`)
if err != nil {
return nil, fmt.Errorf("query score attachments: %w", err)
}
defer rows.Close()
for rows.Next() {
var stage string
var playerID int
var imageData string
if err := rows.Scan(&stage, &playerID, &imageData); err != nil {
return nil, fmt.Errorf("scan score attachment: %w", err)
}
stage = strings.ToLower(strings.TrimSpace(stage))
stageMap, ok := proofs[stage]
if !ok {
continue
}
stageMap[playerID] = imageData
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate score attachments: %w", err)
}
return proofs, nil
}
func scoreProofMapToJSON(proofMap map[string]map[int]string) map[string]map[string]string {
out := map[string]map[string]string{}
for stage, stageMap := range proofMap {
out[stage] = map[string]string{}
for playerID, imageData := range stageMap {
if strings.TrimSpace(imageData) == "" {
continue
}
out[stage][strconv.Itoa(playerID)] = imageData
}
}
return out
}
func (a *App) ensureScoreRows(players []Player) error {
if len(players) == 0 {
return nil
}
tx, err := a.db.Begin()
if err != nil {
return fmt.Errorf("begin score row ensure tx: %w", err)
}
defer tx.Rollback()
for _, p := range players {
for _, stage := range scoreStages {
if _, err := tx.Exec(`INSERT OR IGNORE INTO scores(stage, player_id, score) VALUES(?, ?, 0)`, stage, p.ID); err != nil {
return fmt.Errorf("ensure score row (%s,%d): %w", stage, p.ID, err)
}
}
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit score row ensure tx: %w", err)
}
return nil
}
func (a *App) readScores(players []Player) (map[string]map[int]int, error) {
scores := map[string]map[int]int{}
for _, stage := range scoreStages {
scores[stage] = map[int]int{}
}
for _, p := range players {
for _, stage := range scoreStages {
scores[stage][p.ID] = 0
}
}
rows, err := a.db.Query(`SELECT stage, player_id, score FROM scores`)
if err != nil {
return nil, fmt.Errorf("query scores: %w", err)
}
defer rows.Close()
for rows.Next() {
var stage string
var playerID int
var score int
if err := rows.Scan(&stage, &playerID, &score); err != nil {
return nil, fmt.Errorf("scan score: %w", err)
}
stage = strings.ToLower(stage)
if _, ok := scores[stage]; !ok {
continue
}
scores[stage][playerID] = score
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate scores: %w", err)
}
return scores, nil
}
func scoreMapToJSON(scoreMap map[string]map[int]int) map[string]map[string]int {
out := map[string]map[string]int{}
for stage, stageMap := range scoreMap {
out[stage] = map[string]int{}
for playerID, value := range stageMap {
out[stage][strconv.Itoa(playerID)] = value
}
}
return out
}
func computeDerived(players []Player, scores map[string]map[int]int) DerivedState {
playerByID := map[int]Player{}
for _, p := range players {
playerByID[p.ID] = p
}
preRows := []RankingRow{}
for _, p := range players {
preRows = append(preRows, RankingRow{
PlayerID: p.ID,
NameAr: p.NameAr,
NameEn: p.NameEn,
GroupCode: p.GroupCode,
ImageData: p.ImageData,
Score: scores["preliminary"][p.ID],
TieBreak: scores["prelim_tiebreak"][p.ID],
})
}
sort.SliceStable(preRows, func(i, j int) bool {
if preRows[i].Score != preRows[j].Score {
return preRows[i].Score > preRows[j].Score
}
return preRows[i].PlayerID < preRows[j].PlayerID
})
assignDenseRankByScore(preRows)
preTie := TieBreakInfo{Required: false, Resolved: true, Slots: 0, PlayerIDs: []int{}}
finalists := []RankingRow{}
if len(preRows) <= 12 {
for i := range preRows {
row := preRows[i]
row.Seed = i + 1
finalists = append(finalists, row)
}
} else {
cutoff := preRows[11].Score
above := []RankingRow{}
atCutoff := []RankingRow{}
for _, row := range preRows {
if row.Score > cutoff {
above = append(above, row)
} else if row.Score == cutoff {
atCutoff = append(atCutoff, row)
}
}
slots := 12 - len(above)
if slots < 0 {
slots = 0
}
if len(atCutoff) <= slots {
finalists = append(finalists, above...)
finalists = append(finalists, atCutoff...)
} else {
preTie.Required = true
preTie.Slots = slots
preTie.Resolved = true
for _, row := range atCutoff {
preTie.PlayerIDs = append(preTie.PlayerIDs, row.PlayerID)
}
sort.Ints(preTie.PlayerIDs)
sort.SliceStable(atCutoff, func(i, j int) bool {
if atCutoff[i].TieBreak != atCutoff[j].TieBreak {
return atCutoff[i].TieBreak > atCutoff[j].TieBreak
}
return atCutoff[i].PlayerID < atCutoff[j].PlayerID
})
if slots > 0 {
boundary := atCutoff[slots-1].TieBreak
greater := 0
equal := 0
for _, row := range atCutoff {
if row.TieBreak > boundary {
greater++
} else if row.TieBreak == boundary {
equal++
}
}
if greater < slots && greater+equal > slots {
preTie.Resolved = false
}
}
finalists = append(finalists, above...)
if slots > len(atCutoff) {
slots = len(atCutoff)
}
finalists = append(finalists, atCutoff[:slots]...)
}
sort.SliceStable(finalists, func(i, j int) bool {
if finalists[i].Score != finalists[j].Score {
return finalists[i].Score > finalists[j].Score
}
if preTie.Required {
if finalists[i].TieBreak != finalists[j].TieBreak {
return finalists[i].TieBreak > finalists[j].TieBreak
}
}
return finalists[i].PlayerID < finalists[j].PlayerID
})
assignDenseRankBy(finalists, func(a, b RankingRow) bool {
if a.Score != b.Score {
return false
}
if preTie.Required {
return a.TieBreak == b.TieBreak
}
return true
})
}
for i := range finalists {
finalists[i].Seed = i + 1
if i < 6 {
finalists[i].FinalGroup = 1
} else {
finalists[i].FinalGroup = 2
}
}
finalGroup1 := []RankingRow{}
finalGroup2 := []RankingRow{}
for _, row := range finalists {
if row.FinalGroup == 1 {
finalGroup1 = append(finalGroup1, row)
} else {
finalGroup2 = append(finalGroup2, row)
}
}
finalRows := []RankingRow{}
for _, finalist := range finalists {
p := playerByID[finalist.PlayerID]
finalRows = append(finalRows, RankingRow{
PlayerID: finalist.PlayerID,
NameAr: p.NameAr,
NameEn: p.NameEn,
GroupCode: p.GroupCode,
ImageData: p.ImageData,
Score: scores["final"][finalist.PlayerID],
TieBreak: scores["final_tiebreak"][finalist.PlayerID],
Seed: finalist.Seed,
FinalGroup: finalist.FinalGroup,
})
}
sort.SliceStable(finalRows, func(i, j int) bool {
if finalRows[i].Score != finalRows[j].Score {
return finalRows[i].Score > finalRows[j].Score
}
return finalRows[i].Seed < finalRows[j].Seed
})
finalTie := TieBreakInfo{Required: false, Resolved: true, Slots: 0, PlayerIDs: []int{}}
tiedTop := map[int]bool{}
if len(finalRows) > 0 {
i := 0
for i < len(finalRows) {
j := i + 1
for j < len(finalRows) && finalRows[j].Score == finalRows[i].Score {
j++
}
if j-i > 1 && finalRows[i].Score > 0 {
startPos := i + 1
endPos := j
if startPos <= 3 || endPos <= 3 || (startPos < 3 && endPos > 3) {
for k := i; k < j; k++ {
tiedTop[finalRows[k].PlayerID] = true
}
}
}
i = j
}
if len(tiedTop) > 0 {
finalTie.Required = true
for id := range tiedTop {
finalTie.PlayerIDs = append(finalTie.PlayerIDs, id)
}
sort.Ints(finalTie.PlayerIDs)
sort.SliceStable(finalRows, func(i, j int) bool {
if finalRows[i].Score != finalRows[j].Score {
return finalRows[i].Score > finalRows[j].Score
}
iti := tiedTop[finalRows[i].PlayerID]
itj := tiedTop[finalRows[j].PlayerID]
if iti && itj && finalRows[i].TieBreak != finalRows[j].TieBreak {
return finalRows[i].TieBreak > finalRows[j].TieBreak
}
return finalRows[i].Seed < finalRows[j].Seed
})
}
}
if finalTie.Required {
assignDenseRankBy(finalRows, func(a, b RankingRow) bool {
if a.Score != b.Score {
return false
}
if tiedTop[a.PlayerID] && tiedTop[b.PlayerID] {
return a.TieBreak == b.TieBreak
}
return true
})
if len(finalRows) >= 3 {
third := finalRows[2]
greater := 0
equal := 0
for _, row := range finalRows {
if row.Score > third.Score {
greater++
continue
}
if row.Score == third.Score {
itied := tiedTop[row.PlayerID]
ttied := tiedTop[third.PlayerID]
if itied && ttied {
if row.TieBreak > third.TieBreak {
greater++
} else if row.TieBreak == third.TieBreak {
equal++
}
} else {
equal++
}
}
}
if greater < 3 && greater+equal > 3 {
finalTie.Resolved = false
}
}
} else {
assignDenseRankByScore(finalRows)
}
podium := []RankingRow{}
for i := 0; i < len(finalRows) && i < 3; i++ {
podium = append(podium, finalRows[i])
}
return DerivedState{
PreliminaryRanking: RankingBundle{Rows: preRows, TieBreak: preTie, Unresolved: preTie.Required && !preTie.Resolved},
Finalists: finalists,
FinalGroups: FinalGroups{Group1: finalGroup1, Group2: finalGroup2},
FinalRanking: RankingBundle{Rows: finalRows, TieBreak: finalTie, Unresolved: finalTie.Required && !finalTie.Resolved},
Podium: podium,
}
}
func assignDenseRankByScore(rows []RankingRow) {
assignDenseRankBy(rows, func(a, b RankingRow) bool {
return a.Score == b.Score
})
}
func assignDenseRankBy(rows []RankingRow, isEqual func(a, b RankingRow) bool) {
if len(rows) == 0 {
return
}
currentRank := 1
rows[0].Rank = currentRank
for i := 1; i < len(rows); i++ {
if !isEqual(rows[i], rows[i-1]) {
currentRank = i + 1
}
rows[i].Rank = currentRank
}
}