update
This commit is contained in:
1
backend/.env
Normal file
1
backend/.env
Normal file
@@ -0,0 +1 @@
|
||||
GEMINI_API_KEY=AIzaSyATpv4fmHpjPPLk-BEy4fCBL_r1EWtiWDc
|
||||
95
backend/ai_handlers.go
Normal file
95
backend/ai_handlers.go
Normal 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
93
backend/auth.go
Normal 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
36
backend/config.go
Normal 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
76
backend/db.go
Normal 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
83
backend/events.go
Normal 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
203
backend/gemini.go
Normal 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
29
backend/go.mod
Normal 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
75
backend/go.sum
Normal 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
461
backend/handlers.go
Normal 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
54
backend/main.go
Normal 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
141
backend/models.go
Normal 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
57
backend/routes.go
Normal 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
47
backend/settings.go
Normal 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
482
backend/state.go
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user