api specs

This commit is contained in:
2026-04-29 00:45:48 +04:00
parent 27143319e3
commit 8cda54548f
67 changed files with 5052 additions and 93 deletions

44
Dockerfile.phoenix Normal file
View File

@@ -0,0 +1,44 @@
# syntax=docker/dockerfile:1.7
FROM --platform=$BUILDPLATFORM node:20-alpine AS frontend-builder
WORKDIR /app/frontend
COPY frontend/package.json frontend/pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY frontend/ ./
RUN pnpm build
FROM --platform=$BUILDPLATFORM elixir:1.18.4-otp-28 AS phoenix-builder
ENV MIX_ENV=prod
WORKDIR /app/phoenix
RUN mix local.hex --force && mix local.rebar --force
COPY phoenix/mix.exs phoenix/mix.lock ./
RUN mix deps.get --only prod
RUN mix deps.compile
COPY phoenix/config ./config
COPY phoenix/lib ./lib
COPY phoenix/priv ./priv
RUN mix compile
RUN mix release
FROM --platform=$TARGETPLATFORM elixir:1.18.4-otp-28
RUN groupadd -r app && useradd -r -g app app
WORKDIR /app
COPY --from=phoenix-builder /app/phoenix/_build/prod/rel/shooting_event_phx /app
COPY --from=frontend-builder /app/frontend/dist /app/web
RUN mkdir -p /app/data && chown -R app:app /app
USER app
ENV PHX_SERVER=true
ENV PORT=8080
ENV DB_PATH=/app/data/shooting.db
ENV WEB_DIR=/app/web
ENV SECRET_KEY_BASE=pxh_v1_V2Y3TXF4Rk9OUm5FdlVQc2s3M2hqd0t1Q2pwYUx4N3Vjb2I5Wk1qXzJ5RWRmQnJ
EXPOSE 8080
ENTRYPOINT ["/app/bin/shooting_event_phx"]
CMD ["start"]

View File

@@ -4,15 +4,17 @@ PNPM ?= pnpm
GO ?= go
CONTAINER_REG ?= repo.ssp-itinfra.com/admin
IMAGE_NAME ?= shooting-event
PHOENIX_IMAGE_NAME ?= $(IMAGE_NAME)-phoenix
ARCH ?= amd64
BUILD_DATE ?= $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
BUILD_VERSION ?= $(shell git describe --tags --always --dirty)
.PHONY: install dev dev-backend dev-frontend build build-frontend build-backend docker-build docker-run clean
.PHONY: install dev dev-backend dev-frontend build build-frontend build-backend phoenix-dev phoenix-build docker-build docker-run docker-build-phoenix docker-run-phoenix clean
install:
cd frontend && $(PNPM) install
cd backend && $(GO) mod tidy
cd phoenix && mix deps.get
dev-backend:
cd backend && $(GO) run .
@@ -40,6 +42,12 @@ build: build-frontend
cp -R frontend/dist/. backend/web/
$(MAKE) build-backend
phoenix-dev:
cd phoenix && mix deps.get && mix ecto.create && mix ecto.migrate && PORT=$${PORT:-8080} mix phx.server
phoenix-build:
cd phoenix && MIX_ENV=prod mix deps.get --only prod && MIX_ENV=prod mix compile
docker-build:
docker buildx build --load --platform=linux/$(ARCH) -t $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH) .
docker tag $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH) $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH)-latest
@@ -52,7 +60,20 @@ docker-run:
mkdir -p data
docker run --rm -p 8080:8080 -v $(PWD)/data:/app/data $(CONTAINER_REG)/$(IMAGE_NAME):$(ARCH)
docker-build-phoenix:
docker buildx build --load --platform=linux/$(ARCH) -f Dockerfile.phoenix -t $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH) .
docker tag $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH) $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)-latest
docker tag $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH) $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)-$(BUILD_VERSION)
docker push $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)
docker push $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)-latest
docker push $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)-$(BUILD_VERSION)
docker-run-phoenix:
mkdir -p data
docker run --rm -p 8080:8080 -v $(PWD)/data:/app/data $(CONTAINER_REG)/$(PHOENIX_IMAGE_NAME):$(ARCH)
release:
${MAKE} build
$(MAKE) docker-build
clean:

View File

@@ -34,29 +34,39 @@ Production-ready full-stack web app based on your original live score concept.
8. Podium is determined automatically.
9. If top-3 tie exists, podium tie-break stage appears.
## API Highlights
## API Documentation (OpenAPI)
Public:
Interactive docs are now built in:
- `GET /api/health`
- `GET /api/state`
- Swagger UI: `GET /api/docs`
- OpenAPI JSON: `GET /api/openapi.json`
Admin:
### Testing with Auth in Swagger UI
- `POST /api/admin/login`
- `POST /api/admin/logout`
- `POST /api/admin/players`
- `PUT /api/admin/players/:id`
- `DELETE /api/admin/players/:id`
- `PUT /api/admin/scores/:stage/:id`
- `POST /api/admin/scores/:stage/:id/advice`
- `POST /api/admin/scores/:stage/reset`
1. Open `/api/docs`.
2. Use the **Login & Authorize** form at the top:
- Username: `datwyler` (default)
- Password: `datwyler` (default)
3. The page calls `POST /api/admin/login`, retrieves the token, and auto-authorizes Swagger.
4. Use **Try it out** on any admin endpoint.
Stages:
### Stage Values
For score update/proof/advice endpoints (`/api/admin/scores/{stage}/...`), use:
- `prelim_r1`
- `prelim_r2`
- `prelim_r3`
- `final_r1`
- `final_r2`
- `prelim_tiebreak`
- `final_tiebreak`
For reset endpoint (`POST /api/admin/scores/{stage}/reset`), use:
- `preliminary`
- `prelim_tiebreak`
- `final`
- `prelim_tiebreak`
- `final_tiebreak`
## Local Development

View File

@@ -69,7 +69,7 @@ func (a *App) loadCurrentScore(stage string, playerID int) (int, error) {
}
func (a *App) buildAdviceResponse(stage string, playerID int, raw scoreAdviceModelResponse) ScoreAdviceResponse {
advised := clampInt(raw.AdvisedScore, 0, 9999)
advised := clampInt(raw.AdvisedScore, 0, 100)
reason := strings.TrimSpace(raw.Reason)
if reason == "" {
reason = "AI estimated the score from visible impacts."

145
backend/api_docs.go Normal file
View File

@@ -0,0 +1,145 @@
package main
import (
_ "embed"
"fmt"
"net/http"
"github.com/labstack/echo/v4"
)
//go:embed openapi/openapi.json
var openAPISpecJSON string
func (a *App) handleOpenAPISpec(c echo.Context) error {
return c.Blob(http.StatusOK, "application/json; charset=utf-8", []byte(openAPISpecJSON))
}
func (a *App) handleOpenAPIDocs(c echo.Context) error {
html := fmt.Sprintf(`<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Shooting Event API Docs</title>
<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
<style>
body {
margin: 0;
font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f7f9fc;
}
.docs-top {
background: #ffffff;
border-bottom: 1px solid #dde5f3;
padding: 14px 16px;
}
.docs-title {
margin: 0 0 8px;
font-size: 18px;
color: #1b1f40;
}
.docs-help {
margin: 0;
font-size: 14px;
color: #4c5773;
}
.auth-panel {
margin-top: 12px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.auth-panel input {
height: 36px;
border: 1px solid #cdd7eb;
border-radius: 8px;
padding: 0 10px;
font-size: 14px;
}
.auth-panel button {
height: 36px;
border: 1px solid #20284f;
background: #20284f;
color: white;
border-radius: 8px;
padding: 0 12px;
cursor: pointer;
}
.auth-status {
font-size: 13px;
color: #38446b;
}
#swagger-ui {
max-width: 1300px;
margin: 0 auto;
}
</style>
</head>
<body>
<section class="docs-top">
<h1 class="docs-title">Shooting Event OpenAPI Docs</h1>
<p class="docs-help">
Use <strong>Try it out</strong> for live testing. For admin endpoints: login with the form below,
then your token is auto-attached to Swagger Authorize.
</p>
<form id="auth-form" class="auth-panel">
<input id="admin-user" type="text" value="datwyler" autocomplete="username" placeholder="Admin username" />
<input id="admin-pass" type="password" value="datwyler" autocomplete="current-password" placeholder="Admin password" />
<button type="submit">Login & Authorize</button>
<span id="auth-status" class="auth-status"></span>
</form>
</section>
<div id="swagger-ui"></div>
<script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
const ui = SwaggerUIBundle({
url: %q,
dom_id: '#swagger-ui',
deepLinking: true,
displayRequestDuration: true,
persistAuthorization: true,
docExpansion: 'list',
tryItOutEnabled: true,
});
async function loginAndAuthorize(event) {
event.preventDefault();
const status = document.getElementById('auth-status');
status.textContent = 'Logging in...';
const username = document.getElementById('admin-user').value || '';
const password = document.getElementById('admin-pass').value || '';
try {
const response = await fetch('/api/admin/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.message || 'Login failed');
}
if (!payload.token) {
throw new Error('Login succeeded but token was not returned');
}
ui.preauthorizeApiKey('bearerAuth', payload.token);
status.textContent = 'Authorized until ' + (payload.expiresAt || 'token expiry');
} catch (error) {
status.textContent = 'Auth failed: ' + error.message;
}
}
document.getElementById('auth-form').addEventListener('submit', loginAndAuthorize);
</script>
</body>
</html>`, "/api/openapi.json")
return c.HTML(http.StatusOK, html)
}

View File

@@ -198,6 +198,6 @@ 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>"}
{"advisedScore":<int 0..100>,"reason":"<max 18 words>"}
Do not add markdown or extra fields.`, stage, currentScore)
}

View File

@@ -310,8 +310,8 @@ func (a *App) handleUpdateScore(c echo.Context) error {
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")
if req.Score < 0 || req.Score > 100 {
return writeError(c, http.StatusBadRequest, "score must be between 0 and 100")
}
res, err := a.db.Exec(`

1408
backend/openapi/openapi.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,10 @@ import (
func registerAPIRoutes(e *echo.Echo, app *App) {
api := e.Group("/api")
api.GET("/docs", app.handleOpenAPIDocs)
api.GET("/docs/", app.handleOpenAPIDocs)
api.GET("/openapi.json", app.handleOpenAPISpec)
api.GET("/health", app.handleHealth)
api.GET("/state", app.handleGetState)
api.GET("/events", app.handleEvents)

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -8,9 +8,11 @@
:competition-title="competitionTitle"
:mode="mode"
:language="language"
:is-fullscreen="isFullscreen"
:server-time="state.serverTime ? formatServerTime(state.serverTime) : ''"
@change-mode="mode = $event"
@change-language="setLanguage"
@toggle-fullscreen="toggleFullscreen"
/>
<main class="page-content">
@@ -133,6 +135,7 @@
:display-name="displayName"
:secondary-name="secondaryName"
:score-input-value="scoreInputValue"
:score-input-invalid="scoreInputInvalid"
:on-score-focus="onScoreFocus"
:on-score-input="onScoreInput"
:on-score-commit="onScoreCommit"
@@ -222,6 +225,7 @@ const mode = ref('live')
const viewTab = ref('groups')
const adminTab = ref('players')
const language = ref(localStorage.getItem('shooting_lang') === 'en' ? 'en' : 'ar')
const isFullscreen = ref(false)
const state = ref(createInitialState())
const primaryGroups = ref(loadPrimaryGroups(localStorage))
@@ -640,6 +644,11 @@ watch(
)
onMounted(async () => {
onFullscreenChange()
document.addEventListener('fullscreenchange', onFullscreenChange)
document.addEventListener('webkitfullscreenchange', onFullscreenChange)
document.addEventListener('mozfullscreenchange', onFullscreenChange)
document.addEventListener('MSFullscreenChange', onFullscreenChange)
document.documentElement.lang = language.value
document.documentElement.dir = language.value === 'ar' ? 'rtl' : 'ltr'
await fetchState()
@@ -647,6 +656,10 @@ onMounted(async () => {
})
onBeforeUnmount(() => {
document.removeEventListener('fullscreenchange', onFullscreenChange)
document.removeEventListener('webkitfullscreenchange', onFullscreenChange)
document.removeEventListener('mozfullscreenchange', onFullscreenChange)
document.removeEventListener('MSFullscreenChange', onFullscreenChange)
stopLiveRotation()
stopLiveMonitorRotation()
stopRealtimeSync()
@@ -662,6 +675,57 @@ function setLanguage(next) {
if (next === 'ar' || next === 'en') language.value = next
}
function getFullscreenElement() {
return (
document.fullscreenElement ||
document.webkitFullscreenElement ||
document.mozFullScreenElement ||
document.msFullscreenElement ||
null
)
}
function onFullscreenChange() {
isFullscreen.value = Boolean(getFullscreenElement())
}
function requestFullscreenFor(element) {
if (!element) return Promise.resolve()
const fn =
element.requestFullscreen ||
element.webkitRequestFullscreen ||
element.mozRequestFullScreen ||
element.msRequestFullscreen
if (!fn) return Promise.resolve()
const result = fn.call(element)
if (result && typeof result.then === 'function') return result
return Promise.resolve()
}
function exitFullscreen() {
const fn =
document.exitFullscreen ||
document.webkitExitFullscreen ||
document.mozCancelFullScreen ||
document.msExitFullscreen
if (!fn) return Promise.resolve()
const result = fn.call(document)
if (result && typeof result.then === 'function') return result
return Promise.resolve()
}
async function toggleFullscreen() {
try {
if (getFullscreenElement()) {
await exitFullscreen()
return
}
await requestFullscreenFor(document.documentElement)
} catch {
// Ignore fullscreen errors, mostly caused by browser policy.
}
}
function normalizeLiveActiveView(value) {
const normalized = String(value || '').trim().toLowerCase()
const allowed = new Set(['group_live', 'prelim_tie', 'prelim_overall', 'final_groups', 'final_tie', 'podium'])
@@ -1209,7 +1273,7 @@ async function applySuggestedScore() {
if (!scoreAdviceModal.advice) return
const stage = scoreAdviceModal.stage
const playerId = scoreAdviceModal.playerId
const score = Number(scoreAdviceModal.advice.advisedScore || 0)
const score = clampScoreValue(scoreAdviceModal.advice.advisedScore)
await updateScoreValue(stage, playerId, score)
scoreAdviceModal.currentScore = score
closeScoreAdviceModal()
@@ -1287,26 +1351,48 @@ function scoreDraftKey(stage, playerId) {
return `${stage}:${playerId}`
}
function clampScoreValue(value) {
let score = Number(value)
if (!Number.isFinite(score)) score = 0
score = Math.trunc(score)
if (score < 0) return 0
if (score > 100) return 100
return score
}
function scoreInputValue(stage, playerId) {
const draft = scoreDrafts[scoreDraftKey(stage, playerId)]
if (draft) return draft.value
return String(scoreFor(stage, playerId))
}
function scoreInputInvalid(stage, playerId) {
const draft = scoreDrafts[scoreDraftKey(stage, playerId)]
return Boolean(draft?.invalid)
}
function parseDraftScoreValue(value) {
let score = Number(value === '' ? 0 : value)
if (!Number.isFinite(score)) score = 0
return Math.trunc(score)
}
function onScoreFocus(stage, playerId) {
const key = scoreDraftKey(stage, playerId)
if (!scoreDrafts[key]) {
scoreDrafts[key] = { value: String(scoreFor(stage, playerId)), committing: false }
scoreDrafts[key] = { value: String(scoreFor(stage, playerId)), committing: false, invalid: false }
}
}
function onScoreInput(stage, playerId, event) {
const key = scoreDraftKey(stage, playerId)
const raw = String(event?.target?.value ?? '')
const cleaned = raw.replace(/[^\d]/g, '').slice(0, 4)
const current = scoreDrafts[key] || { value: String(scoreFor(stage, playerId)), committing: false }
scoreDrafts[key] = { ...current, value: cleaned }
if (event?.target && event.target.value !== cleaned) event.target.value = cleaned
const digits = raw.replace(/[^\d]/g, '').slice(0, 3)
const parsed = parseDraftScoreValue(digits)
const invalid = digits !== '' && (parsed < 0 || parsed > 100)
const current = scoreDrafts[key] || { value: String(scoreFor(stage, playerId)), committing: false, invalid: false }
scoreDrafts[key] = { ...current, value: digits, invalid }
if (event?.target && event.target.value !== digits) event.target.value = digits
}
async function onScoreCommit(stage, playerId) {
@@ -1314,11 +1400,11 @@ async function onScoreCommit(stage, playerId) {
const draft = scoreDrafts[key]
if (!draft || draft.committing) return
let score = Number(draft.value === '' ? 0 : draft.value)
if (!Number.isFinite(score)) score = 0
score = Math.trunc(score)
const score = parseDraftScoreValue(draft.value)
const invalid = score < 0 || score > 100
if (score < 0 || score > 9999) {
if (invalid) {
scoreDrafts[key] = { ...draft, invalid: true }
showToast(t('messages.invalidScore'), 'error')
return
}
@@ -1328,12 +1414,12 @@ async function onScoreCommit(stage, playerId) {
return
}
scoreDrafts[key] = { ...draft, committing: true }
scoreDrafts[key] = { ...draft, invalid: false, committing: true }
await updateScoreValue(stage, playerId, score, key)
}
async function updateScoreValue(stage, playerId, score, draftKey = '') {
if (score < 0 || score > 9999) {
if (score < 0 || score > 100) {
showToast(t('messages.invalidScore'), 'error')
return
}

View File

@@ -98,6 +98,7 @@
:display-name="displayName"
:secondary-name="secondaryName"
:score-input-value="scoreInputValue"
:score-input-invalid="scoreInputInvalid"
:on-score-focus="onScoreFocus"
:on-score-input="onScoreInput"
:on-score-commit="onScoreCommit"
@@ -139,6 +140,7 @@
:display-name="displayName"
:secondary-name="secondaryName"
:score-input-value="scoreInputValue"
:score-input-invalid="scoreInputInvalid"
:on-score-focus="onScoreFocus"
:on-score-input="onScoreInput"
:on-score-commit="onScoreCommit"
@@ -188,6 +190,7 @@
:display-name="displayName"
:secondary-name="secondaryName"
:score-input-value="scoreInputValue"
:score-input-invalid="scoreInputInvalid"
:on-score-focus="onScoreFocus"
:on-score-input="onScoreInput"
:on-score-commit="onScoreCommit"
@@ -224,6 +227,7 @@
:display-name="displayName"
:secondary-name="secondaryName"
:score-input-value="scoreInputValue"
:score-input-invalid="scoreInputInvalid"
:on-score-focus="onScoreFocus"
:on-score-input="onScoreInput"
:on-score-commit="onScoreCommit"
@@ -313,6 +317,7 @@ defineProps({
displayName: { type: Function, required: true },
secondaryName: { type: Function, required: true },
scoreInputValue: { type: Function, required: true },
scoreInputInvalid: { type: Function, required: true },
onScoreFocus: { type: Function, required: true },
onScoreInput: { type: Function, required: true },
onScoreCommit: { type: Function, required: true },

View File

@@ -16,6 +16,35 @@
<span>{{ optionsLabel }}</span>
<span class="control-toggle-meta">{{ modeShort }} · {{ languageShort }}</span>
</button>
<button
class="control-icon-btn"
type="button"
:class="{ active: isFullscreen }"
:aria-label="fullscreenActionLabel"
:title="fullscreenActionLabel"
@click="toggleFullscreen"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
v-if="!isFullscreen"
d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
v-else
d="M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</button>
</div>
<Transition name="controls-reveal">
@@ -57,9 +86,10 @@ const props = defineProps({
mode: { type: String, required: true },
language: { type: String, required: true },
serverTime: { type: String, default: '' },
isFullscreen: { type: Boolean, default: false },
})
const emit = defineEmits(['change-mode', 'change-language'])
const emit = defineEmits(['change-mode', 'change-language', 'toggle-fullscreen'])
const controlsOpen = ref(false)
const controlsRoot = ref(null)
@@ -71,6 +101,7 @@ const modeShort = computed(() => {
return props.language === 'ar' ? 'إدارة' : 'Admin'
})
const languageShort = computed(() => (props.language === 'ar' ? 'AR' : 'EN'))
const fullscreenActionLabel = computed(() => (props.isFullscreen ? props.t('actions.exitFullscreen') : props.t('actions.enterFullscreen')))
const modeStatusLabel = computed(() => {
if (props.mode === 'view') return '● ' + props.t('labels.liveTracker')
if (props.mode === 'live') return '● ' + props.t('liveMode')
@@ -91,6 +122,10 @@ function changeLanguage(nextLanguage) {
controlsOpen.value = false
}
function toggleFullscreen() {
emit('toggle-fullscreen')
}
function onGlobalPointerDown(event) {
if (!controlsOpen.value) return
const root = controlsRoot.value

View File

@@ -1,14 +1,15 @@
<template>
<section class="live-mode-shell">
<div class="live-mode-overlay">
<div class="live-mode-root">
<section class="live-mode-shell">
<div class="live-mode-overlay">
<header class="live-mode-header">
<h2>{{ t('sections.liveModeTitle') }}</h2>
<p>{{ t('sections.liveModeSubtitle') }}</p>
</header>
<div class="live-screen-head live-head-selection">
<!-- <div class="live-screen-head live-head-selection">
<span class="live-chip strong">{{ activeViewLabel }}</span>
</div>
</div> -->
<div class="live-mode-grid single">
<article v-if="activeView === 'group_live'" class="live-screen-card wide">
@@ -256,7 +257,7 @@
<img class="podium-img" :src="podiumImage(podiumOrdered[0])" :alt="podiumName(podiumOrdered[0])" />
</div>
<div class="podium-medal-icon">🥈</div>
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[0]) }">{{ podiumName(podiumOrdered[0]) }}</h3>
<h3 class="podium-name" dir="auto" :title="podiumName(podiumOrdered[0])" :class="{ empty: !podiumHasResult(podiumOrdered[0]) }">{{ podiumName(podiumOrdered[0]) }}</h3>
<div class="podium-score-wrap">
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[0]) }}</p>
<p v-if="podiumTieSubtitle(podiumOrdered[0])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[0]) }}</p>
@@ -268,7 +269,7 @@
<img class="podium-img" :src="podiumImage(podiumOrdered[1])" :alt="podiumName(podiumOrdered[1])" />
</div>
<div class="podium-medal-icon">🥇</div>
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[1]) }">{{ podiumName(podiumOrdered[1]) }}</h3>
<h3 class="podium-name" dir="auto" :title="podiumName(podiumOrdered[1])" :class="{ empty: !podiumHasResult(podiumOrdered[1]) }">{{ podiumName(podiumOrdered[1]) }}</h3>
<div class="podium-score-wrap">
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[1]) }}</p>
<p v-if="podiumTieSubtitle(podiumOrdered[1])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[1]) }}</p>
@@ -280,7 +281,7 @@
<img class="podium-img" :src="podiumImage(podiumOrdered[2])" :alt="podiumName(podiumOrdered[2])" />
</div>
<div class="podium-medal-icon">🥉</div>
<h3 class="podium-name" :class="{ empty: !podiumHasResult(podiumOrdered[2]) }">{{ podiumName(podiumOrdered[2]) }}</h3>
<h3 class="podium-name" dir="auto" :title="podiumName(podiumOrdered[2])" :class="{ empty: !podiumHasResult(podiumOrdered[2]) }">{{ podiumName(podiumOrdered[2]) }}</h3>
<div class="podium-score-wrap">
<p class="podium-score">{{ podiumScoreMain(podiumOrdered[2]) }}</p>
<p v-if="podiumTieSubtitle(podiumOrdered[2])" class="podium-score-sub">{{ podiumTieSubtitle(podiumOrdered[2]) }}</p>
@@ -289,8 +290,22 @@
</div>
</article>
</div>
</div>
</section>
</div>
</section>
<footer class="live-mode-footer live-mode-fixed-footer">
<div class="live-footer-slot left">
<img src="/simon_logo.png" alt="Simon logo" class="live-footer-logo" />
</div>
<div class="live-footer-slot center">
<img src="/arture_logo.png" alt="Arture logo" class="live-footer-logo" />
</div>
<div class="live-footer-slot right">
<img src="/datwyler_logo.png" alt="Datwyler logo" class="live-footer-logo" />
</div>
</footer>
</div>
</template>
<script setup>

View File

@@ -41,11 +41,12 @@
<td v-for="round in roundStages" :key="'cell-' + round.stage + '-' + row.playerId" class="multi-round-cell">
<input
class="score-input score-input-compact"
:class="{ 'score-input-over-limit': scoreInputInvalid(round.stage, row.playerId) }"
type="number"
inputmode="numeric"
pattern="[0-9]*"
min="0"
max="9999"
max="100"
:value="scoreInputValue(round.stage, row.playerId)"
@focus="onScoreFocus(round.stage, row.playerId)"
@input="onScoreInput(round.stage, row.playerId, $event)"
@@ -103,11 +104,12 @@
<span class="score-label">{{ round.label }}</span>
<input
class="score-input"
:class="{ 'score-input-over-limit': scoreInputInvalid(round.stage, row.playerId) }"
type="number"
inputmode="numeric"
pattern="[0-9]*"
min="0"
max="9999"
max="100"
:value="scoreInputValue(round.stage, row.playerId)"
@focus="onScoreFocus(round.stage, row.playerId)"
@input="onScoreInput(round.stage, row.playerId, $event)"
@@ -164,6 +166,7 @@ const props = defineProps({
displayName: { type: Function, required: true },
secondaryName: { type: Function, required: true },
scoreInputValue: { type: Function, required: true },
scoreInputInvalid: { type: Function, required: true },
onScoreFocus: { type: Function, required: true },
onScoreInput: { type: Function, required: true },
onScoreCommit: { type: Function, required: true },

View File

@@ -40,11 +40,12 @@
<td>
<input
class="score-input"
:class="{ 'score-input-over-limit': scoreInputInvalid(stage, row.playerId) }"
type="number"
inputmode="numeric"
pattern="[0-9]*"
min="0"
max="9999"
max="100"
:value="scoreInputValue(stage, row.playerId)"
@focus="onScoreFocus(stage, row.playerId)"
@input="onScoreInput(stage, row.playerId, $event)"
@@ -109,11 +110,12 @@
<label class="score-label">{{ inputLabel }}</label>
<input
class="score-input"
:class="{ 'score-input-over-limit': scoreInputInvalid(stage, row.playerId) }"
type="number"
inputmode="numeric"
pattern="[0-9]*"
min="0"
max="9999"
max="100"
:value="scoreInputValue(stage, row.playerId)"
@focus="onScoreFocus(stage, row.playerId)"
@input="onScoreInput(stage, row.playerId, $event)"
@@ -170,6 +172,7 @@ const props = defineProps({
displayName: { type: Function, required: true },
secondaryName: { type: Function, required: true },
scoreInputValue: { type: Function, required: true },
scoreInputInvalid: { type: Function, required: true },
onScoreFocus: { type: Function, required: true },
onScoreInput: { type: Function, required: true },
onScoreCommit: { type: Function, required: true },

View File

@@ -14,6 +14,7 @@ export const I18N = {
liveTracker: 'LIVE TRACKER',
mode: 'الوضع',
language: 'اللغة',
fullscreen: 'ملء الشاشة',
lastSync: 'آخر مزامنة',
loading: 'جاري تحميل بيانات البطولة...',
players: 'لاعب',
@@ -139,6 +140,8 @@ export const I18N = {
resetPosition: 'إعادة الضبط',
liveModeRotate: 'تدوير تلقائي',
liveModeFixed: 'ثابت',
enterFullscreen: 'دخول ملء الشاشة',
exitFullscreen: 'الخروج من ملء الشاشة',
},
auth: {
username: 'اسم المستخدم',
@@ -166,7 +169,7 @@ export const I18N = {
confirmDelete: 'هل تريد حذف اللاعب؟',
confirmReset: 'هل تريد تصفير نتائج هذه المرحلة؟',
resetProofPrompt: 'هل تريد أيضًا حذف صور الإثبات لهذه المرحلة؟',
invalidScore: 'النتيجة يجب أن تكون من 0 إلى 9999.',
invalidScore: 'النتيجة يجب أن تكون من 0 إلى 100.',
unauthorized: 'انتهت صلاحية جلسة الإدارة. يرجى تسجيل الدخول مرة أخرى.',
errorPrefix: 'حدث خطأ',
noGroupsConfigured: 'لا توجد مجموعات أساسية مهيأة.',
@@ -190,6 +193,7 @@ export const I18N = {
liveTracker: 'LIVE TRACKER',
mode: 'Mode',
language: 'Language',
fullscreen: 'Fullscreen',
lastSync: 'Last sync',
loading: 'Loading tournament data...',
players: 'Players',
@@ -315,6 +319,8 @@ export const I18N = {
resetPosition: 'Reset position',
liveModeRotate: 'Auto rotate',
liveModeFixed: 'Fixed',
enterFullscreen: 'Enter Fullscreen',
exitFullscreen: 'Exit Fullscreen',
},
auth: {
username: 'Username',
@@ -342,7 +348,7 @@ export const I18N = {
confirmDelete: 'Delete this player?',
confirmReset: 'Reset this stage scores?',
resetProofPrompt: 'Also remove all proof images for this stage?',
invalidScore: 'Score must be between 0 and 9999.',
invalidScore: 'Score must be between 0 and 100.',
unauthorized: 'Admin session expired. Please login again.',
errorPrefix: 'Error',
noGroupsConfigured: 'No primary groups configured.',

View File

@@ -117,6 +117,8 @@ body {
.control-toggle-row {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
}
.control-toggle-btn {
@@ -138,6 +140,35 @@ body {
background: linear-gradient(180deg, #f0f6ff 0%, #e6f0ff 100%);
}
.control-icon-btn {
width: 38px;
height: 38px;
border: 1px solid #cfd6e7;
background: linear-gradient(180deg, #f8fbff 0%, #eef4ff 100%);
border-radius: 12px;
color: #445478;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 4px 12px rgba(27, 31, 64, 0.08);
}
.control-icon-btn:hover {
background: linear-gradient(180deg, #f0f6ff 0%, #e6f0ff 100%);
}
.control-icon-btn.active {
background: var(--navy);
border-color: var(--navy);
color: #fff;
}
.control-icon-btn svg {
width: 18px;
height: 18px;
}
.control-toggle-meta {
font-family: "IBM Plex Mono", monospace;
color: #6c7a97;
@@ -901,6 +932,23 @@ select.name-input {
color: #7d87a1;
}
.score-input-over-limit {
border-color: #ec2f23 !important;
color: #8e1a14;
background: #fff2f1;
animation: score-over-limit-blink 0.7s ease-in-out infinite;
}
@keyframes score-over-limit-blink {
0%,
100% {
box-shadow: 0 0 0 0 rgba(236, 47, 35, 0.18);
}
50% {
box-shadow: 0 0 0 4px rgba(236, 47, 35, 0.28);
}
}
.proof-actions {
display: flex;
align-items: center;
@@ -1123,12 +1171,18 @@ select.name-input {
color: var(--ok);
}
.live-mode-root {
position: relative;
--live-footer-height: 74px;
--live-footer-offset: 16px;
padding-bottom: calc(var(--live-footer-height) + var(--live-footer-offset) + env(safe-area-inset-bottom));
}
.live-mode-shell {
position: relative;
min-height: 72vh;
border-radius: 18px;
overflow: hidden;
background-image: url('/bg_live.png');
background-size: contain;
background-position: center;
background-repeat: no-repeat;
@@ -1139,12 +1193,12 @@ select.name-input {
.live-mode-overlay {
min-height: 72vh;
padding: 16px;
background: linear-gradient(135deg, rgba(7, 18, 43, 0.72) 0%, rgba(9, 27, 56, 0.6) 45%, rgba(12, 30, 68, 0.66) 100%);
background: #171b3c;
}
.live-mode-header h2 {
margin: 0;
color: #f8d78b;
color: rgba(240, 248, 255, 0.84);
font-size: clamp(22px, 3vw, 34px);
}
@@ -1167,7 +1221,7 @@ select.name-input {
.live-screen-card {
border: 1px solid rgba(200, 223, 255, 0.28);
border-radius: 14px;
background: rgba(8, 16, 38, 0.62);
background: #1a1e3f;
box-shadow: 0 8px 18px rgba(7, 19, 42, 0.18);
backdrop-filter: blur(3px);
padding: 10px;
@@ -1186,7 +1240,7 @@ select.name-input {
.live-focus-banner {
border: 1px solid rgba(168, 205, 242, 0.4);
border-radius: 14px;
background: linear-gradient(135deg, rgba(16, 32, 72, 0.86) 0%, rgba(11, 27, 60, 0.84) 100%);
background: rgba(23, 27, 60, 0.9);
padding: 10px 14px;
display: grid;
gap: 2px;
@@ -1211,28 +1265,13 @@ select.name-input {
text-shadow: 0 3px 10px rgba(0, 0, 0, 0.35);
}
.live-focus-banner.focus-a .live-focus-title {
color: #98c4ff;
}
.live-focus-banner.focus-b .live-focus-title {
color: #8de6ff;
}
.live-focus-banner.focus-c .live-focus-title {
color: #ffad9d;
}
.live-focus-banner.focus-d .live-focus-title {
color: #ffd98f;
}
.live-focus-banner.focus-u .live-focus-title {
color: #d9e3f2;
}
.live-focus-banner.focus-a .live-focus-title,
.live-focus-banner.focus-b .live-focus-title,
.live-focus-banner.focus-c .live-focus-title,
.live-focus-banner.focus-d .live-focus-title,
.live-focus-banner.focus-u .live-focus-title,
.live-focus-banner.focus-final .live-focus-title {
color: #f9e3a1;
color: #d9e3f2;
}
.live-screen-head {
@@ -1245,6 +1284,10 @@ select.name-input {
.live-head-selection {
margin-bottom: 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.live-screen-head h3 {
@@ -1293,6 +1336,56 @@ select.name-input {
filter: blur(1px);
}
.live-mode-footer {
margin-top: 0;
padding: 10px 12px;
border: 1px solid rgba(158, 198, 238, 0.38);
border-radius: 14px;
background: #1a1e3f;
display: grid;
grid-template-columns: 1fr 1fr 1fr;
align-items: center;
gap: 10px;
direction: ltr;
}
.live-mode-fixed-footer {
position: fixed;
inset-inline: 24px;
bottom: calc(var(--live-footer-offset) + env(safe-area-inset-bottom));
box-shadow: 0 10px 24px rgba(8, 18, 39, 0.02);
}
.live-footer-slot {
display: flex;
align-items: center;
}
.live-footer-slot.left {
justify-content: flex-start;
}
.live-footer-slot.center {
justify-content: center;
}
.live-footer-slot.right {
justify-content: flex-end;
}
.live-footer-logo {
max-width: min(35vw, 200px);
padding: 10px;
width: auto;
height: auto;
object-fit: contain;
filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2));
}
.live-footer-slot.right .live-footer-logo {
max-width: min(45vw, 280px);
}
.multi-round-cell {
min-width: 98px;
}
@@ -1376,7 +1469,7 @@ select.name-input {
}
.live-score-table thead th {
background: linear-gradient(90deg, #11254a 0%, #124665 100%);
background: #171b3c;
}
.live-screen-card .score-table td {
@@ -1391,49 +1484,103 @@ select.name-input {
.live-screen-card .table-wrap {
border-color: rgba(158, 186, 216, 0.28);
background: rgba(11, 24, 49, 0.65);
background: #171b3c;
}
.live-screen-card .qualified-row {
background: linear-gradient(90deg, rgba(16, 160, 194, 0.24), rgba(16, 160, 194, 0.06));
background: rgba(16, 160, 194, 0.12);
}
.live-podium {
margin-top: 60px;
height: 390px;
margin-top: 72px;
min-height: 500px;
gap: 22px;
padding: 0 18px;
}
.podium-live-card .podium-col {
background: linear-gradient(180deg, rgba(12, 23, 52, 0.95) 0%, rgba(9, 19, 43, 0.95) 100%);
background: #1a1e3f;
border: 1px solid rgba(177, 205, 236, 0.26);
box-shadow: 0 16px 30px rgba(2, 10, 25, 0.45);
box-shadow: 0 18px 34px rgba(2, 10, 25, 0.45);
min-width: 0;
max-width: 330px;
border-radius: 22px 22px 0 0;
}
.podium-live-card .podium-col.pos-1 {
border-top: 8px solid #e7c45f;
background: linear-gradient(180deg, rgba(48, 38, 15, 0.95) 0%, rgba(18, 24, 48, 0.96) 100%);
height: 330px;
border-top: 10px solid rgba(231, 196, 95, 0.4);
background: #1a1e3f;
height: 430px;
box-shadow: 0 22px 44px rgba(231, 196, 95, 0.15);
}
.podium-live-card .podium-col.pos-2 {
border-top: 8px solid #b8c3d7;
background: linear-gradient(180deg, rgba(37, 44, 62, 0.95) 0%, rgba(12, 20, 43, 0.96) 100%);
height: 260px;
border-top: 10px solid rgba(184, 195, 215, 0.4);
background: #1a1e3f;
height: 350px;
}
.podium-live-card .podium-col.pos-3 {
border-top: 8px solid #cf8b45;
background: linear-gradient(180deg, rgba(59, 36, 23, 0.95) 0%, rgba(14, 22, 45, 0.96) 100%);
height: 240px;
border-top: 10px solid rgba(207, 139, 69, 0.4);
background: #1a1e3f;
height: 320px;
}
.podium-live-card .podium-avatar-wrap {
top: -82px;
}
.podium-live-card .podium-col.pos-1 .podium-avatar-wrap {
top: -112px;
}
.podium-live-card .podium-img {
width: 150px;
height: 150px;
border-width: 6px;
}
.podium-live-card .podium-col.pos-1 .podium-img {
width: 210px;
height: 210px;
border-width: 7px;
}
.podium-live-card .podium-name {
color: #f2f8ff;
font-size: clamp(19px, 1.8vw, 24px);
width: calc(100% - 20px);
max-width: calc(100% - 20px);
text-align: center;
line-height: 1.2;
white-space: normal;
overflow-wrap: anywhere;
word-break: break-word;
unicode-bidi: plaintext;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
min-height: 2.4em;
max-height: 2.4em;
}
.podium-live-card .podium-col.pos-1 .podium-name {
font-size: clamp(22px, 2.2vw, 30px);
-webkit-line-clamp: 3;
line-clamp: 3;
min-height: 3.6em;
max-height: 3.6em;
}
.podium-live-card .podium-name.empty {
color: rgba(204, 220, 240, 0.78);
font-size: 22px;
-webkit-line-clamp: 2;
line-clamp: 2;
min-height: 2.4em;
max-height: 2.4em;
}
.podium-live-card .podium-score {
@@ -1441,12 +1588,29 @@ select.name-input {
color: #ffd888;
border-color: rgba(255, 255, 255, 0.18);
margin-bottom: 0;
font-size: 16px;
padding: 10px 16px;
}
.podium-live-card .podium-col.pos-1 .podium-score {
font-size: 18px;
padding: 12px 18px;
}
.podium-live-card .podium-medal-icon {
filter: drop-shadow(0 4px 10px rgba(0, 0, 0, 0.35));
font-size: 60px;
margin-top: 86px;
}
.podium-live-card .podium-col.pos-1 .podium-medal-icon {
font-size: 84px;
margin-top: 118px;
}
.podium-panel {
background: #f0f2f8;
}
@@ -2111,10 +2275,15 @@ select.name-input {
}
.control-toggle-btn {
width: 100%;
flex: 1;
justify-content: space-between;
}
.control-icon-btn {
width: 40px;
height: 40px;
}
.tab-bar {
grid-auto-columns: minmax(126px, 1fr);
}
@@ -2170,14 +2339,19 @@ select.name-input {
grid-template-columns: 1fr;
}
.live-mode-root {
--live-footer-height: 62px;
--live-footer-offset: 10px;
}
.live-mode-shell {
border-radius: 12px;
min-height: 0;
min-height: 70dvh;
}
.live-mode-overlay {
padding: 10px;
min-height: 0;
min-height: 70dvh;
}
.live-mode-grid {
@@ -2193,6 +2367,23 @@ select.name-input {
align-items: flex-start;
}
.live-mode-footer {
padding: 8px;
gap: 6px;
}
.live-mode-fixed-footer {
inset-inline: 10px;
}
.live-footer-logo {
max-height: 36px;
}
.live-footer-slot.right .live-footer-logo {
max-width: min(40vw, 150px);
}
.status-row {
justify-content: flex-start;
}
@@ -2229,6 +2420,36 @@ select.name-input {
padding-bottom: 16px;
}
.podium-live-card .podium-img {
width: 128px;
height: 128px;
}
.podium-live-card .podium-col.pos-1 .podium-img {
width: 156px;
height: 156px;
}
.podium-live-card .podium-medal-icon {
font-size: 50px;
margin-top: 64px;
}
.podium-live-card .podium-col.pos-1 .podium-medal-icon {
font-size: 64px;
margin-top: 84px;
}
.podium-live-card .podium-name {
-webkit-line-clamp: 3;
min-height: 3.45em;
}
.podium-live-card .podium-col.pos-1 .podium-name {
-webkit-line-clamp: 3;
min-height: 3.45em;
}
.competitor-image {
width: 56px;
height: 56px;

137
getdata.json Normal file
View File

@@ -0,0 +1,137 @@
{
"response": {
"protocolVersion": {
"protocol": "HTTP",
"minor": 1,
"major": 1
},
"allHeaders": [],
"statusLine": {
"statusCode": 200,
"protocolVersion": {
"protocol": "HTTP",
"minor": 1,
"major": 1
},
"reasonPhrase": "OK"
},
"locale": "en_GB",
"params": {
"names": []
}
},
"data": [
[
"Input Voltage",
"",
"0",
"0.0",
"V"
],
[
"I/P Fault Voltage",
"",
"0",
"0.0",
"V"
],
[
"Output Voltage",
"",
"0",
"0.0",
"V"
],
[
"Load",
"",
"0",
"0.0",
"%"
],
[
"Input Frequency",
"",
"0",
"0.0",
"Hz"
],
[
"Battery Cell Voltage",
"",
"0",
"0.0",
"V"
],
[
"Temperature",
"",
"0",
"0.0",
"℃"
],
[
"Utility State",
"",
"0",
"0.0",
""
],
[
"Battery Low Voltage",
"",
"0",
"0.0",
""
],
[
"Bypass Mode",
"",
"0",
"0.0",
""
],
[
"Fault State",
"",
"0",
"0.0",
""
],
[
"UPS Type",
"",
"0",
"0.0",
""
],
[
"Testing",
"",
"0",
"0.0",
""
],
[
"Shutdown Active",
"",
"0",
"0.0",
""
],
[
"Buzzer",
"",
"0",
"0.0",
""
],
[
"Communication State",
"2026.04.28 08:05:16",
"0.0",
"Lost",
""
]
]
}

5
phoenix/.formatter.exs Normal file
View File

@@ -0,0 +1,5 @@
[
import_deps: [:ecto, :ecto_sql, :phoenix],
subdirectories: ["priv/*/migrations"],
inputs: ["*.{ex,exs}", "{config,lib,test}/**/*.{ex,exs}", "priv/*/seeds.exs"]
]

31
phoenix/.gitignore vendored Normal file
View File

@@ -0,0 +1,31 @@
# The directory Mix will write compiled artifacts to.
/_build/
# If you run "mix test --cover", coverage assets end up here.
/cover/
# The directory Mix downloads your dependencies sources to.
/deps/
# Where 3rd-party dependencies like ExDoc output generated docs.
/doc/
# Ignore .fetch files in case you like to edit your project deps locally.
/.fetch
# If the VM crashes, it generates a dump, let's ignore it too.
erl_crash.dump
# Also ignore archive artifacts (built via "mix archive.build").
*.ez
# Temporary files, for example, from tests.
/tmp/
# Ignore package tarball (built via "mix hex.build").
shooting_event_phx-*.tar
# Database files
*.db
*.db-*

111
phoenix/AGENTS.md Normal file
View File

@@ -0,0 +1,111 @@
This is a web application written using the Phoenix web framework.
## Project guidelines
- Use `mix precommit` alias when you are done with all changes and fix any pending issues
- Use the already included and available `:req` (`Req`) library for HTTP requests, **avoid** `:httpoison`, `:tesla`, and `:httpc`. Req is included by default and is the preferred HTTP client for Phoenix apps
### Phoenix v1.8 guidelines
- **Always** begin your LiveView templates with `<Layouts.app flash={@flash} ...>` which wraps all inner content
- The `MyAppWeb.Layouts` module is aliased in the `my_app_web.ex` file, so you can use it without needing to alias it again
- Anytime you run into errors with no `current_scope` assign:
- You failed to follow the Authenticated Routes guidelines, or you failed to pass `current_scope` to `<Layouts.app>`
- **Always** fix the `current_scope` error by moving your routes to the proper `live_session` and ensure you pass `current_scope` as needed
- Phoenix v1.8 moved the `<.flash_group>` component to the `Layouts` module. You are **forbidden** from calling `<.flash_group>` outside of the `layouts.ex` module
- Out of the box, `core_components.ex` imports an `<.icon name="hero-x-mark" class="w-5 h-5"/>` component for hero icons. **Always** use the `<.icon>` component for icons, **never** use `Heroicons` modules or similar
- **Always** use the imported `<.input>` component for form inputs from `core_components.ex` when available. `<.input>` is imported and using it will save steps and prevent errors
- If you override the default input classes (`<.input class="myclass px-2 py-1 rounded-lg">)`) class with your own values, no default classes are inherited, so your
custom classes must fully style the input
<!-- usage-rules-start -->
<!-- phoenix:elixir-start -->
## Elixir guidelines
- Elixir lists **do not support index based access via the access syntax**
**Never do this (invalid)**:
i = 0
mylist = ["blue", "green"]
mylist[i]
Instead, **always** use `Enum.at`, pattern matching, or `List` for index based list access, ie:
i = 0
mylist = ["blue", "green"]
Enum.at(mylist, i)
- Elixir variables are immutable, but can be rebound, so for block expressions like `if`, `case`, `cond`, etc
you *must* bind the result of the expression to a variable if you want to use it and you CANNOT rebind the result inside the expression, ie:
# INVALID: we are rebinding inside the `if` and the result never gets assigned
if connected?(socket) do
socket = assign(socket, :val, val)
end
# VALID: we rebind the result of the `if` to a new variable
socket =
if connected?(socket) do
assign(socket, :val, val)
end
- **Never** nest multiple modules in the same file as it can cause cyclic dependencies and compilation errors
- **Never** use map access syntax (`changeset[:field]`) on structs as they do not implement the Access behaviour by default. For regular structs, you **must** access the fields directly, such as `my_struct.field` or use higher level APIs that are available on the struct if they exist, `Ecto.Changeset.get_field/2` for changesets
- Elixir's standard library has everything necessary for date and time manipulation. Familiarize yourself with the common `Time`, `Date`, `DateTime`, and `Calendar` interfaces by accessing their documentation as necessary. **Never** install additional dependencies unless asked or for date/time parsing (which you can use the `date_time_parser` package)
- Don't use `String.to_atom/1` on user input (memory leak risk)
- Predicate function names should not start with `is_` and should end in a question mark. Names like `is_thing` should be reserved for guards
- Elixir's builtin OTP primitives like `DynamicSupervisor` and `Registry`, require names in the child spec, such as `{DynamicSupervisor, name: MyApp.MyDynamicSup}`, then you can use `DynamicSupervisor.start_child(MyApp.MyDynamicSup, child_spec)`
- Use `Task.async_stream(collection, callback, options)` for concurrent enumeration with back-pressure. The majority of times you will want to pass `timeout: :infinity` as option
## Mix guidelines
- Read the docs and options before using tasks (by using `mix help task_name`)
- To debug test failures, run tests in a specific file with `mix test test/my_test.exs` or run all previously failed tests with `mix test --failed`
- `mix deps.clean --all` is **almost never needed**. **Avoid** using it unless you have good reason
## Test guidelines
- **Always use `start_supervised!/1`** to start processes in tests as it guarantees cleanup between tests
- **Avoid** `Process.sleep/1` and `Process.alive?/1` in tests
- Instead of sleeping to wait for a process to finish, **always** use `Process.monitor/1` and assert on the DOWN message:
ref = Process.monitor(pid)
assert_receive {:DOWN, ^ref, :process, ^pid, :normal}
- Instead of sleeping to synchronize before the next call, **always** use `_ = :sys.get_state/1` to ensure the process has handled prior messages
<!-- phoenix:elixir-end -->
<!-- phoenix:phoenix-start -->
## Phoenix guidelines
- Remember Phoenix router `scope` blocks include an optional alias which is prefixed for all routes within the scope. **Always** be mindful of this when creating routes within a scope to avoid duplicate module prefixes.
- You **never** need to create your own `alias` for route definitions! The `scope` provides the alias, ie:
scope "/admin", AppWeb.Admin do
pipe_through :browser
live "/users", UserLive, :index
end
the UserLive route would point to the `AppWeb.Admin.UserLive` module
- `Phoenix.View` no longer is needed or included with Phoenix, don't use it
<!-- phoenix:phoenix-end -->
<!-- phoenix:ecto-start -->
## Ecto Guidelines
- **Always** preload Ecto associations in queries when they'll be accessed in templates, ie a message that needs to reference the `message.user.email`
- Remember `import Ecto.Query` and other supporting modules when you write `seeds.exs`
- `Ecto.Schema` fields always use the `:string` type, even for `:text`, columns, ie: `field :name, :string`
- `Ecto.Changeset.validate_number/2` **DOES NOT SUPPORT the `:allow_nil` option**. By default, Ecto validations only run if a change for the given field exists and the change value is not nil, so such as option is never needed
- You **must** use `Ecto.Changeset.get_field(changeset, :field)` to access changeset fields
- Fields which are set programmatically, such as `user_id`, must not be listed in `cast` calls or similar for security purposes. Instead they must be explicitly set when creating the struct
- **Always** invoke `mix ecto.gen.migration migration_name_using_underscores` when generating migration files, so the correct timestamp and conventions are applied
<!-- phoenix:ecto-end -->
<!-- usage-rules-end -->

51
phoenix/README.md Normal file
View File

@@ -0,0 +1,51 @@
# Shooting Event Phoenix Backend
This folder contains a Phoenix + SQLite implementation of the existing Go API contract used by the Vue frontend.
## Run (dev)
```bash
cd phoenix
mix deps.get
mix ecto.create
mix ecto.migrate
PORT=8080 mix phx.server
```
Or from project root:
```bash
make phoenix-dev
```
## Build
```bash
make phoenix-build
```
## API compatibility
The Phoenix app exposes the same `/api/*` endpoints currently used by the Vue app, including:
- Public:
- `GET /api/health`
- `GET /api/state`
- `GET /api/events` (SSE)
- Admin:
- `POST /api/admin/login`
- `POST /api/admin/logout`
- `GET /api/admin/state`
- `PUT /api/admin/settings`
- Player CRUD + auto-group
- Score/proof update/delete + reset
- AI score advice endpoint
## Environment
- `PORT` (default: `8080`)
- `DB_PATH` (default: `../data/shooting_phoenix.db`)
- `ADMIN_USER` (default: `datwyler`)
- `ADMIN_PASS` (default: `datwyler`)
- `GEMINI_API_KEY` (default follows existing project fallback)
- `GEMINI_MODEL` (default: `gemini-3.1-flash-lite-preview`)

35
phoenix/config/config.exs Normal file
View File

@@ -0,0 +1,35 @@
# This file is responsible for configuring your application
# and its dependencies with the aid of the Config module.
#
# This configuration file is loaded before any dependency and
# is restricted to this project.
# General application configuration
import Config
config :shooting_event_phx,
ecto_repos: [ShootingEventPhx.Repo],
generators: [timestamp_type: :utc_datetime]
# Configure the endpoint
config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
url: [host: "localhost"],
adapter: Bandit.PhoenixAdapter,
render_errors: [
formats: [json: ShootingEventPhxWeb.ErrorJSON],
layout: false
],
pubsub_server: ShootingEventPhx.PubSub,
live_view: [signing_salt: "FIh+7Vu7"]
# Configure Elixir's Logger
config :logger, :default_formatter,
format: "$time $metadata[$level] $message\n",
metadata: [:request_id]
# Use Jason for JSON parsing in Phoenix
config :phoenix, :json_library, Jason
# Import environment specific config. This must remain at the bottom
# of this file so it overrides the configuration defined above.
import_config "#{config_env()}.exs"

60
phoenix/config/dev.exs Normal file
View File

@@ -0,0 +1,60 @@
import Config
# Configure your database
config :shooting_event_phx, ShootingEventPhx.Repo,
database: Path.expand("../shooting_event_phx_dev.db", __DIR__),
pool_size: 5,
stacktrace: true,
show_sensitive_data_on_connection_error: true
# For development, we disable any cache and enable
# debugging and code reloading.
#
# The watchers configuration can be used to run external
# watchers to your application. For example, we can use it
# to bundle .js and .css sources.
config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
# Binding to loopback ipv4 address prevents access from other machines.
# Change to `ip: {0, 0, 0, 0}` to allow access from other machines.
http: [ip: {127, 0, 0, 1}],
check_origin: false,
code_reloader: true,
debug_errors: true,
secret_key_base: "lw8ztXfcXK/Q4GVw+LtQGEo34zfdV+uCkmdj2BkwfDlxIZzE8jQyMbU8AqKNBoVW",
watchers: []
# ## SSL Support
#
# In order to use HTTPS in development, a self-signed
# certificate can be generated by running the following
# Mix task:
#
# mix phx.gen.cert
#
# Run `mix help phx.gen.cert` for more information.
#
# The `http:` config above can be replaced with:
#
# https: [
# port: 4001,
# cipher_suite: :strong,
# keyfile: "priv/cert/selfsigned_key.pem",
# certfile: "priv/cert/selfsigned.pem"
# ],
#
# If desired, both `http:` and `https:` keys can be
# configured to run both http and https servers on
# different ports.
# Enable dev routes for dashboard and mailbox
config :shooting_event_phx, dev_routes: true
# Do not include metadata nor timestamps in development logs
config :logger, :default_formatter, format: "[$level] $message\n"
# Set a higher stacktrace during development. Avoid configuring such
# in production as building large stacktraces may be expensive.
config :phoenix, :stacktrace_depth, 20
# Initialize plugs at runtime for faster development compilation
config :phoenix, :plug_init_mode, :runtime

19
phoenix/config/prod.exs Normal file
View File

@@ -0,0 +1,19 @@
import Config
# Force using SSL in production. This also sets the "strict-security-transport" header,
# known as HSTS. If you have a health check endpoint, you may want to exclude it below.
# Note `:force_ssl` is required to be set at compile-time.
config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
force_ssl: [
rewrite_on: [:x_forwarded_proto],
exclude: [
# paths: ["/health"],
hosts: ["localhost", "127.0.0.1"]
]
]
# Do not print debug messages in production
config :logger, level: :info
# Runtime production configuration, including reading
# of environment variables, is done on config/runtime.exs.

View File

@@ -0,0 +1,92 @@
import Config
# config/runtime.exs is executed for all environments, including
# during releases. It is executed after compilation and before the
# system starts, so it is typically used to load production configuration
# and secrets from environment variables or elsewhere. Do not define
# any compile-time configuration in here, as it won't be applied.
# The block below contains prod specific runtime configuration.
# ## Using releases
#
# If you use `mix release`, you need to explicitly enable the server
# by passing the PHX_SERVER=true when you start it:
#
# PHX_SERVER=true bin/shooting_event_phx start
#
# Alternatively, you can use `mix phx.gen.release` to generate a `bin/server`
# script that automatically sets the env var above.
if System.get_env("PHX_SERVER") do
config :shooting_event_phx, ShootingEventPhxWeb.Endpoint, server: true
end
default_db_path = Path.expand("../../data/shooting_phoenix.db", __DIR__)
database_path = System.get_env("DB_PATH") || System.get_env("DATABASE_PATH") || default_db_path
config :shooting_event_phx, ShootingEventPhx.Repo,
database: database_path,
pool_size: String.to_integer(System.get_env("POOL_SIZE") || "5")
config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
http: [ip: {0, 0, 0, 0}, port: String.to_integer(System.get_env("PORT", "8080"))]
if config_env() == :prod do
# The secret key base is used to sign/encrypt cookies and other secrets.
# A default value is used in config/dev.exs and config/test.exs but you
# want to use a different value for prod and you most likely don't want
# to check this value into version control, so we use an environment
# variable instead.
secret_key_base =
System.get_env("SECRET_KEY_BASE") ||
raise """
environment variable SECRET_KEY_BASE is missing.
You can generate one by calling: mix phx.gen.secret
"""
host = System.get_env("PHX_HOST") || "example.com"
config :shooting_event_phx, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"],
http: [
# Enable IPv6 and bind on all interfaces.
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
# See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
ip: {0, 0, 0, 0, 0, 0, 0, 0}
],
secret_key_base: secret_key_base
# ## SSL Support
#
# To get SSL working, you will need to add the `https` key
# to your endpoint configuration:
#
# config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
# https: [
# ...,
# port: 443,
# cipher_suite: :strong,
# keyfile: System.get_env("SOME_APP_SSL_KEY_PATH"),
# certfile: System.get_env("SOME_APP_SSL_CERT_PATH")
# ]
#
# The `cipher_suite` is set to `:strong` to support only the
# latest and more secure SSL ciphers. This means old browsers
# and clients may not be supported. You can set it to
# `:compatible` for wider support.
#
# `:keyfile` and `:certfile` expect an absolute path to the key
# and cert in disk or a relative path inside priv, for example
# "priv/ssl/server.key". For all supported SSL configuration
# options, see https://hexdocs.pm/plug/Plug.SSL.html#configure/1
#
# We also recommend setting `force_ssl` in your config/prod.exs,
# ensuring no data is ever sent via http, always redirecting to https:
#
# config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
# force_ssl: [hsts: true]
#
# Check `Plug.SSL` for all available options in `force_ssl`.
end

28
phoenix/config/test.exs Normal file
View File

@@ -0,0 +1,28 @@
import Config
# Configure your database
#
# The MIX_TEST_PARTITION environment variable can be used
# to provide built-in test partitioning in CI environment.
# Run `mix help test` for more information.
config :shooting_event_phx, ShootingEventPhx.Repo,
database: Path.expand("../shooting_event_phx_test.db", __DIR__),
pool_size: 5,
pool: Ecto.Adapters.SQL.Sandbox
# We don't run a server during test. If one is required,
# you can enable the server option below.
config :shooting_event_phx, ShootingEventPhxWeb.Endpoint,
http: [ip: {127, 0, 0, 1}, port: 4002],
secret_key_base: "h/THyq4VyIU51PLFwsrgMS2n9bs3Dj+RkC7my5e/FhUC91q7EUB3WcYfdvEKJgX3",
server: false
# Print only warnings and errors during test
config :logger, level: :warning
# Initialize plugs at runtime for faster test compilation
config :phoenix, :plug_init_mode, :runtime
# Sort query params output of verified routes for robust url comparisons
config :phoenix,
sort_verified_routes_query_params: true

View File

@@ -0,0 +1,9 @@
defmodule ShootingEventPhx do
@moduledoc """
ShootingEventPhx keeps the contexts that define your domain
and business logic.
Contexts are also responsible for managing your data, regardless
if it comes from the database, an external API or others.
"""
end

View File

@@ -0,0 +1,199 @@
defmodule ShootingEventPhx.AI.Gemini do
@moduledoc false
def generate_score_advice(stage, player_id, image_data, current_score) do
with api_key when is_binary(api_key) and api_key != "" <- gemini_api_key(),
{:ok, mime_type, raw_base64} <- parse_data_uri(image_data),
{:ok, body} <- build_request_body(stage, current_score, mime_type, raw_base64),
{:ok, response} <- request_gemini(body, api_key),
{:ok, model_response} <- parse_gemini_response(response) do
{:ok,
%{
"stage" => stage,
"playerId" => player_id,
"advisedScore" => clamp(model_response["advisedScore"] || 0, 0, 9999),
"reason" => normalize_reason(model_response["reason"]),
"generatedAt" =>
DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
}}
else
nil -> {:error, :service_unavailable, "gemini api key is not configured"}
{:error, message} when is_binary(message) -> {:error, :bad_gateway, message}
{:error, status, message} -> {:error, status, message}
_ -> {:error, :bad_gateway, "failed to generate score advice"}
end
end
defp gemini_api_key do
System.get_env("GEMINI_API_KEY", "AIzaSyATpv4fmHpjPPLk-BEy4fCBL_r1EWtiWDc")
|> String.trim()
|> case do
"" -> nil
v -> v
end
end
defp gemini_model do
System.get_env("GEMINI_MODEL", "gemini-3.1-flash-lite-preview")
|> String.trim()
|> case do
"" -> "gemini-3.1-flash-lite-preview"
v -> v
end
end
defp parse_data_uri(data_uri) do
value = data_uri |> to_string() |> String.trim()
if String.starts_with?(value, "data:") do
case String.split(value, ",", parts: 2) do
[header, payload] ->
payload = String.trim(payload)
if payload == "" do
{:error, "invalid proof image data: empty payload"}
else
mime_type =
case String.split(header, ";", parts: 2) do
["data:" <> mime, _] when mime != "" -> mime
_ -> "image/jpeg"
end
{:ok, mime_type, payload}
end
_ ->
{:error, "invalid proof image data: invalid data uri format"}
end
else
{:error, "invalid proof image data: expected data uri"}
end
end
defp build_request_body(stage, current_score, mime_type, raw_base64) do
request = %{
"contents" => [
%{
"role" => "user",
"parts" => [
%{"text" => score_advice_prompt(stage, current_score)},
%{"inline_data" => %{"mime_type" => mime_type, "data" => raw_base64}}
]
}
],
"generationConfig" => %{
"temperature" => 0,
"responseMimeType" => "application/json",
"maxOutputTokens" => 100
}
}
{:ok, Jason.encode!(request)}
end
defp request_gemini(body, api_key) do
endpoint =
"https://generativelanguage.googleapis.com/v1beta/models/#{URI.encode(gemini_model())}:generateContent?key=#{URI.encode_www_form(api_key)}"
case Req.post(endpoint,
headers: [{"content-type", "application/json"}],
body: body,
receive_timeout: 25_000
) do
{:ok, %Req.Response{status: status, body: response_body}} when status < 300 ->
{:ok, response_body}
{:ok, %Req.Response{status: status, body: response_body}} ->
{:error, "gemini api status #{status}: #{inspect(response_body)}"}
{:error, reason} ->
{:error, "call gemini api: #{inspect(reason)}"}
end
end
defp parse_gemini_response(response_body) when is_map(response_body) do
cond do
is_map(response_body["error"]) and is_binary(response_body["error"]["message"]) ->
{:error, "gemini api error: #{response_body["error"]["message"]}"}
true ->
text =
get_in(response_body, [
"candidates",
Access.at(0),
"content",
"parts",
Access.at(0),
"text"
])
|> to_string()
|> String.trim()
json_text = extract_json_object(text)
if json_text == "" do
{:error, "gemini response was not valid json"}
else
case Jason.decode(json_text) do
{:ok, parsed} -> {:ok, parsed}
{:error, reason} -> {:error, "parse gemini advice json: #{inspect(reason)}"}
end
end
end
end
defp parse_gemini_response(_), do: {:error, "decode gemini response: unexpected payload"}
defp extract_json_object(raw) do
text =
raw
|> String.trim()
|> String.trim_leading("```json")
|> String.trim_leading("```")
|> String.trim_trailing("```")
|> String.trim()
start_idx = binary_index(text, "{")
end_idx = binary_rindex(text, "}")
if is_integer(start_idx) and is_integer(end_idx) and end_idx > start_idx do
text
|> String.slice(start_idx..end_idx)
|> String.trim()
else
""
end
end
defp score_advice_prompt(stage, current_score) do
"Target scoring assistant.\nStage: #{stage}. Current score: #{current_score}.\n" <>
"Return STRICT JSON only:\n{\"advisedScore\":<int 0..9999>,\"reason\":\"<max 18 words>\"}\n" <>
"Do not add markdown or extra fields."
end
defp normalize_reason(reason) do
value = reason |> to_string() |> String.trim()
if value == "", do: "AI estimated the score from visible impacts.", else: value
end
defp clamp(value, min, _max) when value < min, do: min
defp clamp(value, _min, max) when value > max, do: max
defp clamp(value, _min, _max), do: value
defp binary_index(text, pattern) do
case :binary.match(text, pattern) do
:nomatch -> nil
{idx, _len} -> idx
end
end
defp binary_rindex(text, pattern) do
text
|> :binary.matches(pattern)
|> List.last()
|> case do
nil -> nil
{idx, _len} -> idx
end
end
end

View File

@@ -0,0 +1,43 @@
defmodule ShootingEventPhx.Application do
# See https://hexdocs.pm/elixir/Application.html
# for more information on OTP Applications
@moduledoc false
use Application
@impl true
def start(_type, _args) do
children = [
ShootingEventPhxWeb.Telemetry,
ShootingEventPhx.Repo,
{Ecto.Migrator,
repos: Application.fetch_env!(:shooting_event_phx, :ecto_repos), skip: skip_migrations?()},
{DNSCluster,
query: Application.get_env(:shooting_event_phx, :dns_cluster_query) || :ignore},
{Phoenix.PubSub, name: ShootingEventPhx.PubSub},
ShootingEventPhx.Auth.SessionStore,
# Start a worker by calling: ShootingEventPhx.Worker.start_link(arg)
# {ShootingEventPhx.Worker, arg},
# Start to serve requests, typically the last entry
ShootingEventPhxWeb.Endpoint
]
# See https://hexdocs.pm/elixir/Supervisor.html
# for other strategies and supported options
opts = [strategy: :one_for_one, name: ShootingEventPhx.Supervisor]
Supervisor.start_link(children, opts)
end
# Tell Phoenix to update the endpoint configuration
# whenever the application is updated.
@impl true
def config_change(changed, _new, removed) do
ShootingEventPhxWeb.Endpoint.config_change(changed, removed)
:ok
end
defp skip_migrations?() do
# By default, sqlite migrations are run when using a release
System.get_env("RELEASE_NAME") == nil
end
end

View File

@@ -0,0 +1,51 @@
defmodule ShootingEventPhx.Auth do
@moduledoc false
alias ShootingEventPhx.Auth.SessionStore
def admin_user, do: System.get_env("ADMIN_USER", "datwyler")
def admin_pass, do: System.get_env("ADMIN_PASS", "datwyler")
def verify_admin(username, password) do
u = username |> to_string() |> String.trim()
p = password |> to_string() |> String.trim()
if u == "" or p == "" do
false
else
secure_compare(u, admin_user()) and secure_compare(p, admin_pass())
end
end
def bearer_token(conn) do
conn
|> Plug.Conn.get_req_header("authorization")
|> List.first()
|> parse_bearer_token()
end
def admin_request?(conn) do
case bearer_token(conn) do
nil -> false
token -> SessionStore.validate_token(token)
end
end
defp parse_bearer_token(nil), do: nil
defp parse_bearer_token(header) do
value = String.trim(header || "")
if String.starts_with?(String.downcase(value), "bearer ") do
token = value |> String.slice(7..-1//1) |> String.trim()
if token == "", do: nil, else: token
else
nil
end
end
defp secure_compare(left, right) do
Plug.Crypto.secure_compare(left, right)
rescue
_ -> false
end
end

View File

@@ -0,0 +1,46 @@
defmodule ShootingEventPhx.Auth.SessionStore do
@moduledoc false
use Agent
@duration_seconds 8 * 60 * 60
def start_link(_opts) do
Agent.start_link(fn -> %{} end, name: __MODULE__)
end
def create_token do
token = :crypto.strong_rand_bytes(32) |> Base.encode16(case: :lower)
expires_at = DateTime.add(DateTime.utc_now(), @duration_seconds, :second)
Agent.update(__MODULE__, &Map.put(&1, token, expires_at))
{:ok, token, expires_at}
end
def validate_token(token) when is_binary(token) do
purge_expired()
Agent.get(__MODULE__, fn tokens ->
case Map.get(tokens, token) do
%DateTime{} = expires_at -> DateTime.compare(DateTime.utc_now(), expires_at) == :lt
_ -> false
end
end)
end
def delete_token(token) when is_binary(token) do
Agent.update(__MODULE__, &Map.delete(&1, token))
end
def purge_expired do
now = DateTime.utc_now()
Agent.update(__MODULE__, fn tokens ->
Enum.reduce(tokens, %{}, fn {token, expires_at}, acc ->
if DateTime.compare(now, expires_at) == :lt do
Map.put(acc, token, expires_at)
else
acc
end
end)
end)
end
end

View File

@@ -0,0 +1,11 @@
defmodule ShootingEventPhx.Domain.AppSetting do
use Ecto.Schema
@primary_key {:key, :string, []}
@timestamps_opts [inserted_at: false, updated_at: :updated_at, type: :utc_datetime]
schema "app_settings" do
field :value, :string, default: ""
timestamps()
end
end

View File

@@ -0,0 +1,272 @@
defmodule ShootingEventPhx.Domain.Derived do
@moduledoc false
alias ShootingEventPhx.Domain.Stages
def compute(players, scores) do
player_by_id = Map.new(players, &{&1.id, &1})
pre_rows =
players
|> Enum.map(fn p ->
%{
"playerId" => p.id,
"nameAr" => p.name_ar,
"nameEn" => p.name_en,
"groupCode" => p.group_code,
"imageData" => p.image_data,
"score" => score_total(scores, p.id, Stages.preliminary_round_stages()),
"tieBreak" => get_score(scores, "prelim_tiebreak", p.id),
"rank" => 0,
"seed" => 0,
"finalGroup" => 0
}
end)
|> Enum.sort_by(fn row -> {-row["score"], row["playerId"]} end)
|> assign_dense_rank_by_score()
{pre_tie, finalists} = compute_finalists(pre_rows)
finalists =
finalists
|> Enum.with_index(1)
|> Enum.map(fn {row, idx} ->
row
|> Map.put("seed", idx)
|> Map.put("finalGroup", if(idx <= 6, do: 1, else: 2))
end)
final_group1 = Enum.filter(finalists, &(&1["finalGroup"] == 1))
final_group2 = Enum.filter(finalists, &(&1["finalGroup"] == 2))
final_rows =
finalists
|> Enum.map(fn finalist ->
p = Map.fetch!(player_by_id, finalist["playerId"])
%{
"playerId" => finalist["playerId"],
"nameAr" => p.name_ar,
"nameEn" => p.name_en,
"groupCode" => p.group_code,
"imageData" => p.image_data,
"score" => score_total(scores, finalist["playerId"], Stages.final_round_stages()),
"tieBreak" => get_score(scores, "final_tiebreak", finalist["playerId"]),
"rank" => 0,
"seed" => finalist["seed"],
"finalGroup" => finalist["finalGroup"]
}
end)
|> compute_final_ranking()
podium = final_rows |> Enum.take(3)
final_tie = final_tie_info(final_rows)
%{
"preliminaryRanking" => %{
"rows" => pre_rows,
"tieBreak" => pre_tie,
"unresolved" => pre_tie["required"] and not pre_tie["resolved"]
},
"finalists" => finalists,
"finalGroups" => %{"group1" => final_group1, "group2" => final_group2},
"finalRanking" => %{
"rows" => final_rows,
"tieBreak" => final_tie,
"unresolved" => final_tie["required"] and not final_tie["resolved"]
},
"podium" => podium
}
end
defp compute_finalists(pre_rows) do
if length(pre_rows) <= 12 do
finalists =
pre_rows
|> Enum.with_index(1)
|> Enum.map(fn {row, seed} -> Map.put(row, "seed", seed) end)
{empty_tie(), finalists}
else
cutoff = Enum.at(pre_rows, 11)["score"]
above = Enum.filter(pre_rows, &(&1["score"] > cutoff))
at_cutoff = Enum.filter(pre_rows, &(&1["score"] == cutoff))
slots = max(12 - length(above), 0)
if length(at_cutoff) <= slots do
{empty_tie(), above ++ at_cutoff}
else
tie = %{
"required" => true,
"resolved" => true,
"slots" => slots,
"playerIds" => at_cutoff |> Enum.map(& &1["playerId"]) |> Enum.sort()
}
sorted_at_cutoff =
Enum.sort_by(at_cutoff, fn row -> {-row["tieBreak"], row["playerId"]} end)
tie =
if slots > 0 do
boundary = Enum.at(sorted_at_cutoff, slots - 1)["tieBreak"]
greater = Enum.count(sorted_at_cutoff, &(&1["tieBreak"] > boundary))
equal = Enum.count(sorted_at_cutoff, &(&1["tieBreak"] == boundary))
resolved = not (greater < slots and greater + equal > slots)
Map.put(tie, "resolved", resolved)
else
tie
end
finalists = above ++ Enum.take(sorted_at_cutoff, min(slots, length(sorted_at_cutoff)))
finalists =
finalists
|> Enum.sort_by(fn row ->
if tie["required"] do
{-row["score"], -row["tieBreak"], row["playerId"]}
else
{-row["score"], row["playerId"]}
end
end)
|> assign_dense_rank(fn a, b ->
if tie["required"],
do: a["score"] == b["score"] and a["tieBreak"] == b["tieBreak"],
else: a["score"] == b["score"]
end)
{tie, finalists}
end
end
end
defp compute_final_ranking(rows) do
sorted = Enum.sort_by(rows, fn row -> {-row["score"], row["seed"]} end)
tied_top = tied_top_for_podium(sorted)
rows =
if MapSet.size(tied_top) > 0 do
Enum.sort_by(sorted, fn row ->
tied = MapSet.member?(tied_top, row["playerId"])
tie_break_key = if tied, do: -row["tieBreak"], else: 0
{-row["score"], tie_break_key, row["seed"]}
end)
else
sorted
end
if MapSet.size(tied_top) > 0 do
assign_dense_rank(rows, fn a, b ->
if a["score"] != b["score"] do
false
else
tied_a = MapSet.member?(tied_top, a["playerId"])
tied_b = MapSet.member?(tied_top, b["playerId"])
if tied_a and tied_b, do: a["tieBreak"] == b["tieBreak"], else: true
end
end)
else
assign_dense_rank_by_score(rows)
end
end
defp final_tie_info(final_rows) do
tied_top = tied_top_for_podium(final_rows)
if MapSet.size(tied_top) == 0 do
empty_tie()
else
player_ids = tied_top |> MapSet.to_list() |> Enum.sort()
resolved =
if length(final_rows) >= 3 do
third = Enum.at(final_rows, 2)
{greater, equal} =
Enum.reduce(final_rows, {0, 0}, fn row, {g, e} ->
cond do
row["score"] > third["score"] ->
{g + 1, e}
row["score"] < third["score"] ->
{g, e}
true ->
row_tied = MapSet.member?(tied_top, row["playerId"])
third_tied = MapSet.member?(tied_top, third["playerId"])
cond do
row_tied and third_tied and row["tieBreak"] > third["tieBreak"] -> {g + 1, e}
row_tied and third_tied and row["tieBreak"] == third["tieBreak"] -> {g, e + 1}
not row_tied or not third_tied -> {g, e + 1}
true -> {g, e}
end
end
end)
not (greater < 3 and greater + equal > 3)
else
true
end
%{"required" => true, "resolved" => resolved, "slots" => 0, "playerIds" => player_ids}
end
end
defp tied_top_for_podium(rows) do
rows
|> Enum.chunk_by(& &1["score"])
|> Enum.reduce({MapSet.new(), 1}, fn chunk, {set, start_pos} ->
size = length(chunk)
end_pos = start_pos + size - 1
score = List.first(chunk)["score"]
include? =
size > 1 and score > 0 and
(start_pos <= 3 or end_pos <= 3 or (start_pos < 3 and end_pos > 3))
next_set =
if include? do
Enum.reduce(chunk, set, fn row, acc -> MapSet.put(acc, row["playerId"]) end)
else
set
end
{next_set, end_pos + 1}
end)
|> elem(0)
end
defp empty_tie, do: %{"required" => false, "resolved" => true, "slots" => 0, "playerIds" => []}
defp score_total(scores, player_id, stages) do
Enum.reduce(stages, 0, fn stage, acc -> acc + get_score(scores, stage, player_id) end)
end
defp get_score(scores, stage, player_id) do
stage_map = Map.get(scores, stage, %{})
Map.get(stage_map, player_id, 0)
end
defp assign_dense_rank_by_score(rows) do
assign_dense_rank(rows, fn a, b -> a["score"] == b["score"] end)
end
defp assign_dense_rank([], _equal_fun), do: []
defp assign_dense_rank(rows, equal_fun) do
{ranked_rev, _prev, _rank, _idx} =
Enum.reduce(rows, {[], nil, 1, 0}, fn row, {acc, prev, rank, idx} ->
idx1 = idx + 1
next_rank =
if is_map(prev) and equal_fun.(row, prev) do
rank
else
idx1
end
{[%{row | "rank" => next_rank} | acc], row, next_rank, idx1}
end)
Enum.reverse(ranked_rev)
end
end

View File

@@ -0,0 +1,353 @@
defmodule ShootingEventPhx.Domain.EventData do
@moduledoc false
import Ecto.Query
alias ShootingEventPhx.Repo
alias ShootingEventPhx.Domain.{Player, Score, ScoreAttachment, Stages}
def list_players do
Repo.all(from p in Player, order_by: [asc: p.id])
end
def ensure_score_rows(players) do
Repo.transaction(fn ->
for player <- players, stage <- Stages.score_stages() do
Repo.query!(
"""
INSERT OR IGNORE INTO scores(stage, player_id, score)
VALUES(?1, ?2, 0)
""",
[stage, player.id]
)
end
end)
end
def read_scores(players) do
base =
for stage <- Stages.score_stages(), into: %{} do
{stage, Map.new(players, &{&1.id, 0})}
end
rows =
Repo.all(
from s in Score,
select: {s.stage, s.player_id, s.score}
)
Enum.reduce(rows, base, fn {stage, player_id, score}, acc ->
if Map.has_key?(acc, stage) do
Map.update!(acc, stage, &Map.put(&1, player_id, score))
else
acc
end
end)
end
def read_score_proofs(players) do
base =
for stage <- Stages.score_stages(), into: %{} do
{stage, Map.new(players, &{&1.id, ""})}
end
rows =
Repo.all(
from sa in ScoreAttachment,
select: {sa.stage, sa.player_id, sa.image_data}
)
Enum.reduce(rows, base, fn {stage, player_id, image_data}, acc ->
if Map.has_key?(acc, stage) do
Map.update!(acc, stage, &Map.put(&1, player_id, image_data || ""))
else
acc
end
end)
end
def score_map_to_json(scores) do
for {stage, stage_map} <- scores, into: %{} do
{stage,
for({player_id, value} <- stage_map, into: %{}, do: {Integer.to_string(player_id), value})}
end
end
def proof_map_to_json(proofs) do
for {stage, stage_map} <- proofs, into: %{} do
compact =
for {player_id, image_data} <- stage_map,
String.trim(to_string(image_data)) != "",
into: %{} do
{Integer.to_string(player_id), image_data}
end
{stage, compact}
end
end
def create_player(attrs) do
name_ar = attrs["nameAr"] |> to_string() |> String.trim()
name_en = attrs["nameEn"] |> to_string() |> String.trim()
group_code = attrs["groupCode"] |> normalize_group()
cond do
name_ar == "" or name_en == "" ->
{:error, :bad_request, "both Arabic and English names are required"}
true ->
Repo.transaction(fn ->
{:ok, player} =
%Player{name_ar: name_ar, name_en: name_en, group_code: group_code, image_data: ""}
|> Repo.insert()
for stage <- Stages.score_stages() do
Repo.query!(
"""
INSERT OR IGNORE INTO scores(stage, player_id, score) VALUES(?1, ?2, 0)
""",
[stage, player.id]
)
end
end)
|> case do
{:ok, _} -> :ok
{:error, reason} -> {:error, :internal, inspect(reason)}
end
end
end
def update_player(id, attrs) do
with %Player{} = player <- Repo.get(Player, id) do
updates = player_updates(attrs)
cond do
Map.has_key?(updates, :_error) ->
{:error, :bad_request, Map.get(updates, :_error)}
map_size(updates) == 0 ->
{:error, :bad_request, "no fields to update"}
true ->
case Repo.update(Ecto.Changeset.change(player, updates)) do
{:ok, _} -> :ok
{:error, changeset} -> {:error, :bad_request, format_changeset_errors(changeset)}
end
end
else
nil -> {:error, :not_found, "player not found"}
end
end
def delete_player(id) do
case Repo.get(Player, id) do
nil ->
{:error, :not_found, "player not found"}
player ->
case Repo.delete(player) do
{:ok, _} -> :ok
{:error, reason} -> {:error, :internal, inspect(reason)}
end
end
end
def auto_group_players(groups) do
normalized_groups =
groups
|> List.wrap()
|> Enum.map(&normalize_group/1)
|> Enum.reject(&(&1 == ""))
|> Enum.uniq()
if normalized_groups == [] do
{:error, :bad_request, "at least one group is required"}
else
player_ids = Repo.all(from p in Player, select: p.id, order_by: [asc: p.id])
shuffled = Enum.shuffle(player_ids)
case Repo.transaction(fn ->
Enum.with_index(shuffled)
|> Enum.each(fn {player_id, idx} ->
group_code = Enum.at(normalized_groups, rem(idx, length(normalized_groups)))
Repo.query!(
"UPDATE players SET group_code = ?1, updated_at = CURRENT_TIMESTAMP WHERE id = ?2",
[group_code, player_id]
)
end)
end) do
{:ok, _} -> :ok
{:error, reason} -> {:error, :internal, inspect(reason)}
end
end
end
def update_score(stage, player_id, score) when is_integer(score) do
if score < 0 or score > 9999 do
{:error, :bad_request, "score must be between 0 and 9999"}
else
Repo.query!(
"""
INSERT INTO scores(stage, player_id, score)
VALUES(?1, ?2, ?3)
ON CONFLICT(stage, player_id) DO UPDATE SET score = excluded.score, updated_at = CURRENT_TIMESTAMP
""",
[stage, player_id, score]
)
:ok
end
end
def update_score_proof(stage, player_id, image_data) do
payload = to_string(image_data || "") |> String.trim()
cond do
payload == "" ->
{:error, :bad_request, "imageData is required"}
byte_size(payload) > 5_000_000 ->
{:error, :bad_request, "image payload too large"}
true ->
Repo.query!(
"""
INSERT INTO score_attachments(stage, player_id, image_data)
VALUES(?1, ?2, ?3)
ON CONFLICT(stage, player_id) DO UPDATE SET image_data = excluded.image_data, updated_at = CURRENT_TIMESTAMP
""",
[stage, player_id, payload]
)
:ok
end
end
def delete_score_proof(stage, player_id) do
Repo.query!("DELETE FROM score_attachments WHERE stage = ?1 AND player_id = ?2", [
stage,
player_id
])
:ok
end
def reset_stage_scores(stages, reset_proofs?) do
case Repo.transaction(fn ->
Enum.each(stages, fn stage ->
Repo.query!(
"UPDATE scores SET score = 0, updated_at = CURRENT_TIMESTAMP WHERE stage = ?1",
[stage]
)
if reset_proofs? do
Repo.query!("DELETE FROM score_attachments WHERE stage = ?1", [stage])
end
end)
end) do
{:ok, _} -> :ok
{:error, reason} -> {:error, :internal, inspect(reason)}
end
end
def load_score_proof_image(stage, player_id) do
case Repo.one(
from sa in ScoreAttachment,
where: sa.stage == ^stage and sa.player_id == ^player_id,
select: sa.image_data
) do
nil -> {:error, :not_found}
image -> {:ok, image}
end
end
def load_current_score(stage, player_id) do
score =
Repo.one(
from s in Score, where: s.stage == ^stage and s.player_id == ^player_id, select: s.score
)
{:ok, score || 0}
end
defp player_updates(attrs) do
updates = %{}
updates =
case Map.get(attrs, "nameAr") do
nil ->
updates
name ->
trimmed = to_string(name) |> String.trim()
if trimmed == "",
do: Map.put(updates, :_error, "arabic name cannot be empty"),
else: Map.put(updates, :name_ar, trimmed)
end
updates =
case Map.get(attrs, "nameEn") do
nil ->
updates
name ->
trimmed = to_string(name) |> String.trim()
if trimmed == "",
do: Map.put(updates, :_error, "english name cannot be empty"),
else: Map.put(updates, :name_en, trimmed)
end
updates =
if Map.has_key?(attrs, "groupCode"),
do: Map.put(updates, :group_code, normalize_group(Map.get(attrs, "groupCode"))),
else: updates
updates =
if Map.has_key?(attrs, "imageData") do
image = attrs |> Map.get("imageData") |> to_string() |> String.trim()
Map.put(updates, :image_data, image)
else
updates
end
updates =
if truthy?(Map.get(attrs, "clearImage")) do
Map.put(updates, :image_data, "")
else
updates
end
case Map.pop(updates, :_error) do
{nil, sanitized} ->
if byte_size(Map.get(sanitized, :image_data, "")) > 2_500_000 do
%{_error: "image payload too large"}
else
sanitized
end
{message, _} ->
%{_error: message}
end
end
defp normalize_group(group), do: group |> to_string() |> String.trim()
defp truthy?(value) do
case value do
true -> true
1 -> true
"1" -> true
"true" -> true
"yes" -> true
"on" -> true
_ -> false
end
end
defp format_changeset_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, _opts} -> msg end)
|> Enum.map(fn {field, msgs} -> "#{field} #{Enum.join(msgs, ",")}" end)
|> Enum.join("; ")
end
end

View File

@@ -0,0 +1,14 @@
defmodule ShootingEventPhx.Domain.Player do
use Ecto.Schema
@primary_key {:id, :id, autogenerate: true}
@timestamps_opts [inserted_at: :created_at, updated_at: :updated_at, type: :utc_datetime]
schema "players" do
field :name_ar, :string
field :name_en, :string
field :group_code, :string, default: ""
field :image_data, :string, default: ""
timestamps()
end
end

View File

@@ -0,0 +1,13 @@
defmodule ShootingEventPhx.Domain.Score do
use Ecto.Schema
@primary_key false
@timestamps_opts [inserted_at: false, updated_at: :updated_at, type: :utc_datetime]
schema "scores" do
field :stage, :string, primary_key: true
field :player_id, :id, primary_key: true
field :score, :integer, default: 0
timestamps()
end
end

View File

@@ -0,0 +1,13 @@
defmodule ShootingEventPhx.Domain.ScoreAttachment do
use Ecto.Schema
@primary_key false
@timestamps_opts [inserted_at: false, updated_at: :updated_at, type: :utc_datetime]
schema "score_attachments" do
field :stage, :string, primary_key: true
field :player_id, :id, primary_key: true
field :image_data, :string, default: ""
timestamps()
end
end

View File

@@ -0,0 +1,297 @@
defmodule ShootingEventPhx.Domain.Settings do
@moduledoc false
import Ecto.Query
alias ShootingEventPhx.Repo
alias ShootingEventPhx.Domain.AppSetting
@setting_view_proof_in_view "view_proof_in_view"
@setting_live_active_view "live_active_view"
@setting_live_show_group_live "live_show_group_live"
@setting_live_group_display_mode "live_group_display_mode"
@setting_live_group_fixed_code "live_group_fixed_code"
@setting_live_show_prelim_tie "live_show_prelim_tie"
@setting_live_show_prelim_overall "live_show_prelim_overall"
@setting_live_show_final_groups "live_show_final_groups"
@setting_live_final_display_mode "live_final_display_mode"
@setting_live_final_fixed_group "live_final_fixed_group"
@setting_live_show_final_tie "live_show_final_tie"
@setting_live_show_podium "live_show_podium"
@setting_live_rotation_interval "live_rotation_interval_sec"
@setting_live_rotation_players "live_rotation_player_count"
@allowed_live_views MapSet.new([
"group_live",
"prelim_tie",
"prelim_overall",
"final_groups",
"final_tie",
"podium"
])
def default_live_mode do
%{
"activeView" => "group_live",
"showGroupLive" => true,
"groupDisplayMode" => "rotate",
"groupFixedCode" => "",
"showPrelimTie" => true,
"showPrelimOverall" => true,
"showFinalGroups" => true,
"finalDisplayMode" => "rotate",
"finalFixedGroup" => 1,
"showFinalTie" => true,
"showPodium" => true,
"rotationIntervalSec" => 5,
"rotationPlayerCount" => 12
}
end
def read_settings do
all =
Repo.all(from s in AppSetting, select: {s.key, s.value})
|> Map.new()
live = default_live_mode()
live =
live
|> Map.put("showGroupLive", setting_bool(Map.get(all, @setting_live_show_group_live, "1")))
|> Map.put(
"activeView",
normalize_live_active_view(
Map.get(all, @setting_live_active_view, live["activeView"]),
live["activeView"]
)
)
|> Map.put(
"groupDisplayMode",
normalize_display_mode(
Map.get(all, @setting_live_group_display_mode, live["groupDisplayMode"]),
live["groupDisplayMode"]
)
)
|> Map.put(
"groupFixedCode",
String.upcase(String.trim(Map.get(all, @setting_live_group_fixed_code, "")))
)
|> Map.put("showPrelimTie", setting_bool(Map.get(all, @setting_live_show_prelim_tie, "1")))
|> Map.put(
"showPrelimOverall",
setting_bool(Map.get(all, @setting_live_show_prelim_overall, "1"))
)
|> Map.put(
"showFinalGroups",
setting_bool(Map.get(all, @setting_live_show_final_groups, "1"))
)
|> Map.put(
"finalDisplayMode",
normalize_display_mode(
Map.get(all, @setting_live_final_display_mode, live["finalDisplayMode"]),
live["finalDisplayMode"]
)
)
|> Map.put(
"finalFixedGroup",
normalize_final_fixed_group(Map.get(all, @setting_live_final_fixed_group, "1"), 1)
)
|> Map.put("showFinalTie", setting_bool(Map.get(all, @setting_live_show_final_tie, "1")))
|> Map.put("showPodium", setting_bool(Map.get(all, @setting_live_show_podium, "1")))
|> Map.put(
"rotationIntervalSec",
normalize_rotation_interval(Map.get(all, @setting_live_rotation_interval, "5"), 5)
)
|> Map.put(
"rotationPlayerCount",
normalize_rotation_player_count(Map.get(all, @setting_live_rotation_players, "12"), 12)
)
%{
"viewProofInView" => setting_bool(Map.get(all, @setting_view_proof_in_view, "0")),
"liveMode" => live
}
end
def update_settings(%{} = req) do
has_update =
Map.has_key?(req, "viewProofInView") or
Map.has_key?(req, :viewProofInView) or
Map.has_key?(req, "liveMode") or
Map.has_key?(req, :liveMode)
if not has_update do
{:error, "at least one settings field is required"}
else
Repo.transaction(fn ->
req
|> get_value("viewProofInView")
|> maybe_upsert(@setting_view_proof_in_view)
live_patch = get_value(req, "liveMode")
if is_map(live_patch), do: persist_live_patch(live_patch)
end)
|> case do
{:ok, _} -> :ok
{:error, %Ecto.ConstraintError{} = e} -> {:error, Exception.message(e)}
{:error, reason} when is_binary(reason) -> {:error, reason}
{:error, reason} -> {:error, inspect(reason)}
end
end
end
defp persist_live_patch(patch) do
patch
|> get_value("activeView")
|> maybe_upsert(@setting_live_active_view, fn v ->
normalize_live_active_view(v, "group_live")
end)
patch
|> get_value("showGroupLive")
|> maybe_upsert(@setting_live_show_group_live)
patch
|> get_value("groupDisplayMode")
|> maybe_upsert(@setting_live_group_display_mode, fn v ->
normalize_display_mode(v, "rotate")
end)
patch
|> get_value("groupFixedCode")
|> maybe_upsert(@setting_live_group_fixed_code, fn v ->
v |> to_string() |> String.trim() |> String.upcase()
end)
patch
|> get_value("showPrelimTie")
|> maybe_upsert(@setting_live_show_prelim_tie)
patch
|> get_value("showPrelimOverall")
|> maybe_upsert(@setting_live_show_prelim_overall)
patch
|> get_value("showFinalGroups")
|> maybe_upsert(@setting_live_show_final_groups)
patch
|> get_value("finalDisplayMode")
|> maybe_upsert(@setting_live_final_display_mode, fn v ->
normalize_display_mode(v, "rotate")
end)
patch
|> get_value("finalFixedGroup")
|> maybe_upsert(@setting_live_final_fixed_group, fn v -> normalize_final_fixed_group(v, 1) end)
patch
|> get_value("showFinalTie")
|> maybe_upsert(@setting_live_show_final_tie)
patch
|> get_value("showPodium")
|> maybe_upsert(@setting_live_show_podium)
patch
|> get_value("rotationIntervalSec")
|> maybe_upsert(@setting_live_rotation_interval, fn v -> normalize_rotation_interval(v, 5) end)
patch
|> get_value("rotationPlayerCount")
|> maybe_upsert(@setting_live_rotation_players, fn v ->
normalize_rotation_player_count(v, 12)
end)
end
defp maybe_upsert(nil, _key), do: :ok
defp maybe_upsert(value, key) do
normalized = setting_string(value)
Repo.insert!(
%AppSetting{key: key, value: normalized},
on_conflict: [set: [value: normalized, updated_at: DateTime.utc_now()]],
conflict_target: :key
)
end
defp maybe_upsert(nil, _key, _fun), do: :ok
defp maybe_upsert(value, key, normalize_fun) do
normalized = value |> normalize_fun.() |> setting_string()
maybe_upsert(normalized, key)
end
defp get_value(map, key) when is_map(map) do
cond do
Map.has_key?(map, key) -> Map.get(map, key)
Map.has_key?(map, String.to_atom(key)) -> Map.get(map, String.to_atom(key))
true -> nil
end
end
defp setting_bool(value) do
case value |> to_string() |> String.trim() |> String.downcase() do
"1" -> true
"true" -> true
"yes" -> true
"on" -> true
_ -> false
end
end
defp setting_string(v) when is_boolean(v), do: if(v, do: "1", else: "0")
defp setting_string(v) when is_integer(v), do: Integer.to_string(v)
defp setting_string(v), do: v |> to_string()
defp normalize_display_mode(value, fallback) do
case value |> to_string() |> String.trim() |> String.downcase() do
"fixed" -> "fixed"
"rotate" -> "rotate"
_ -> if(String.downcase(to_string(fallback)) == "fixed", do: "fixed", else: "rotate")
end
end
defp normalize_final_fixed_group(value, fallback) do
parsed =
case Integer.parse(to_string(value)) do
{v, _} -> v
_ -> fallback
end
if parsed == 2, do: 2, else: 1
end
defp normalize_rotation_interval(value, fallback) do
parsed =
case Integer.parse(to_string(value)) do
{v, _} -> v
_ -> fallback
end
parsed |> max(3) |> min(30)
end
defp normalize_rotation_player_count(value, fallback) do
parsed =
case Integer.parse(to_string(value)) do
{v, _} -> v
_ -> fallback
end
parsed |> max(3) |> min(40)
end
defp normalize_live_active_view(value, fallback) do
normalized =
value
|> to_string()
|> String.trim()
|> String.downcase()
cond do
MapSet.member?(@allowed_live_views, normalized) -> normalized
MapSet.member?(@allowed_live_views, to_string(fallback)) -> fallback
true -> "group_live"
end
end
end

View File

@@ -0,0 +1,42 @@
defmodule ShootingEventPhx.Domain.Stages do
@moduledoc false
@preliminary_round_stages ["prelim_r1", "prelim_r2", "prelim_r3"]
@final_round_stages ["final_r1", "final_r2"]
@tie_break_stages ["prelim_tiebreak", "final_tiebreak"]
@score_stages @preliminary_round_stages ++ @final_round_stages ++ @tie_break_stages
def preliminary_round_stages, do: @preliminary_round_stages
def final_round_stages, do: @final_round_stages
def tie_break_stages, do: @tie_break_stages
def score_stages, do: @score_stages
def valid_stage?(stage), do: normalize_stage(stage) in @score_stages
def normalize_stage(stage) do
stage
|> to_string()
|> String.trim()
|> String.downcase()
end
def validate_stage(stage) do
normalized = normalize_stage(stage)
if normalized in @score_stages do
{:ok, normalized}
else
{:error, "invalid stage"}
end
end
def resolve_reset_stages(stage) do
case normalize_stage(stage) do
"preliminary" -> {:ok, @preliminary_round_stages}
"final" -> {:ok, @final_round_stages}
"prelim_tiebreak" -> {:ok, ["prelim_tiebreak"]}
"final_tiebreak" -> {:ok, ["final_tiebreak"]}
_ -> {:error, "invalid stage"}
end
end
end

View File

@@ -0,0 +1,43 @@
defmodule ShootingEventPhx.Domain.StateService do
@moduledoc false
alias ShootingEventPhx.Domain.{Derived, EventData, Settings}
def read_state(include_all_proofs \\ false) do
players = EventData.list_players()
_ = EventData.ensure_score_rows(players)
settings = Settings.read_settings()
scores = EventData.read_scores(players)
include_score_proofs = include_all_proofs or settings["viewProofInView"]
score_proofs = if include_score_proofs, do: EventData.read_score_proofs(players), else: %{}
derived = Derived.compute(players, scores)
response = %{
"competition" => %{
"titleAr" => "بطولة دويتوايلر للرماية",
"titleEn" => "Datwyler Shooting Event"
},
"players" => Enum.map(players, &player_to_json/1),
"scores" => EventData.score_map_to_json(scores),
"settings" => settings,
"derived" => derived,
"serverTime" => DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
}
if include_score_proofs do
Map.put(response, "scoreProofs", EventData.proof_map_to_json(score_proofs))
else
response
end
end
defp player_to_json(player) do
%{
"id" => player.id,
"nameAr" => player.name_ar,
"nameEn" => player.name_en,
"groupCode" => player.group_code,
"imageData" => player.image_data
}
end
end

View File

@@ -0,0 +1,15 @@
defmodule ShootingEventPhx.Events do
@moduledoc false
@topic "state_updates"
def topic, do: @topic
def subscribe do
Phoenix.PubSub.subscribe(ShootingEventPhx.PubSub, @topic)
end
def broadcast do
payload = {:state, DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()}
Phoenix.PubSub.broadcast(ShootingEventPhx.PubSub, @topic, payload)
end
end

View File

@@ -0,0 +1,5 @@
defmodule ShootingEventPhx.Repo do
use Ecto.Repo,
otp_app: :shooting_event_phx,
adapter: Ecto.Adapters.SQLite3
end

View File

@@ -0,0 +1,63 @@
defmodule ShootingEventPhxWeb do
@moduledoc """
The entrypoint for defining your web interface, such
as controllers, components, channels, and so on.
This can be used in your application as:
use ShootingEventPhxWeb, :controller
use ShootingEventPhxWeb, :html
The definitions below will be executed for every controller,
component, etc, so keep them short and clean, focused
on imports, uses and aliases.
Do NOT define functions inside the quoted expressions
below. Instead, define additional modules and import
those modules here.
"""
def static_paths, do: ~w(assets fonts images favicon.ico robots.txt)
def router do
quote do
use Phoenix.Router, helpers: false
# Import common connection and controller functions to use in pipelines
import Plug.Conn
import Phoenix.Controller
end
end
def channel do
quote do
use Phoenix.Channel
end
end
def controller do
quote do
use Phoenix.Controller, formats: [:html, :json]
import Plug.Conn
unquote(verified_routes())
end
end
def verified_routes do
quote do
use Phoenix.VerifiedRoutes,
endpoint: ShootingEventPhxWeb.Endpoint,
router: ShootingEventPhxWeb.Router,
statics: ShootingEventPhxWeb.static_paths()
end
end
@doc """
When used, dispatch to the appropriate controller/live_view/etc.
"""
defmacro __using__(which) when is_atom(which) do
apply(__MODULE__, which, [])
end
end

View File

@@ -0,0 +1,221 @@
defmodule ShootingEventPhxWeb.ApiController do
use ShootingEventPhxWeb, :controller
alias ShootingEventPhx.{Auth, Events}
alias ShootingEventPhx.Auth.SessionStore
alias ShootingEventPhx.Domain.{EventData, Settings, Stages, StateService}
alias ShootingEventPhx.AI.Gemini
def health(conn, _params) do
json(conn, %{"status" => "ok"})
end
def state(conn, _params) do
state = StateService.read_state(Auth.admin_request?(conn))
json(conn, state)
end
def admin_state(conn, _params) do
json(conn, StateService.read_state(true))
end
def admin_login(conn, params) do
username = Map.get(params, "username", "")
password = Map.get(params, "password", "")
if Auth.verify_admin(username, password) do
{:ok, token, expires_at} = SessionStore.create_token()
json(conn, %{
"token" => token,
"expiresAt" => DateTime.to_iso8601(expires_at),
"username" => Auth.admin_user()
})
else
error(conn, :unauthorized, "invalid credentials")
end
end
def admin_logout(conn, _params) do
case Auth.bearer_token(conn) do
nil -> :ok
token -> SessionStore.delete_token(token)
end
json(conn, %{"ok" => true})
end
def update_settings(conn, params) do
case Settings.update_settings(params) do
:ok ->
Events.broadcast()
json(conn, StateService.read_state(true))
{:error, message} ->
error(conn, :bad_request, message)
end
end
def create_player(conn, params) do
case EventData.create_player(params) do
:ok ->
Events.broadcast()
conn |> put_status(:created) |> json(StateService.read_state(true))
{:error, :bad_request, message} ->
error(conn, :bad_request, message)
{:error, _, message} ->
error(conn, :internal_server_error, message)
end
end
def update_player(conn, %{"id" => id} = params) do
with {player_id, ""} <- Integer.parse(to_string(id)),
result <- EventData.update_player(player_id, params),
:ok <- result do
Events.broadcast()
json(conn, StateService.read_state(true))
else
:error -> error(conn, :bad_request, "invalid player id")
{:error, :bad_request, message} -> error(conn, :bad_request, message)
{:error, :not_found, message} -> error(conn, :not_found, message)
{:error, _, message} -> error(conn, :internal_server_error, message)
end
end
def delete_player(conn, %{"id" => id}) do
with {player_id, ""} <- Integer.parse(to_string(id)),
:ok <- EventData.delete_player(player_id) do
Events.broadcast()
json(conn, StateService.read_state(true))
else
:error -> error(conn, :bad_request, "invalid player id")
{:error, :not_found, message} -> error(conn, :not_found, message)
{:error, _, message} -> error(conn, :internal_server_error, message)
end
end
def auto_group_players(conn, params) do
groups = Map.get(params, "groups", [])
case EventData.auto_group_players(groups) do
:ok ->
Events.broadcast()
json(conn, StateService.read_state(true))
{:error, :bad_request, message} ->
error(conn, :bad_request, message)
{:error, _, message} ->
error(conn, :internal_server_error, message)
end
end
def update_score(conn, %{"stage" => stage, "id" => id} = params) do
with {:ok, normalized_stage} <- Stages.validate_stage(stage),
{player_id, ""} <- Integer.parse(to_string(id)),
score <- parse_score(Map.get(params, "score")),
:ok <- EventData.update_score(normalized_stage, player_id, score) do
Events.broadcast()
json(conn, StateService.read_state(true))
else
{:error, message} -> error(conn, :bad_request, message)
:error -> error(conn, :bad_request, "invalid player id")
{:invalid_score, message} -> error(conn, :bad_request, message)
{:error, :bad_request, message} -> error(conn, :bad_request, message)
{:error, _, message} -> error(conn, :internal_server_error, message)
end
end
def reset_stage(conn, %{"stage" => stage} = params) do
with {:ok, target_stages} <- Stages.resolve_reset_stages(stage),
reset_proofs <- truthy?(Map.get(params, "resetProofs")),
:ok <- EventData.reset_stage_scores(target_stages, reset_proofs) do
Events.broadcast()
json(conn, StateService.read_state(true))
else
{:error, message} -> error(conn, :bad_request, message)
{:error, _, message} -> error(conn, :internal_server_error, message)
end
end
def update_score_proof(conn, %{"stage" => stage, "id" => id} = params) do
with {:ok, normalized_stage} <- Stages.validate_stage(stage),
{player_id, ""} <- Integer.parse(to_string(id)),
image_data when is_binary(image_data) <- Map.get(params, "imageData"),
:ok <- EventData.update_score_proof(normalized_stage, player_id, image_data) do
Events.broadcast()
json(conn, StateService.read_state(true))
else
{:error, message} -> error(conn, :bad_request, message)
:error -> error(conn, :bad_request, "invalid player id")
nil -> error(conn, :bad_request, "imageData is required")
{:error, :bad_request, message} -> error(conn, :bad_request, message)
{:error, _, message} -> error(conn, :internal_server_error, message)
end
end
def delete_score_proof(conn, %{"stage" => stage, "id" => id}) do
with {:ok, normalized_stage} <- Stages.validate_stage(stage),
{player_id, ""} <- Integer.parse(to_string(id)),
:ok <- EventData.delete_score_proof(normalized_stage, player_id) do
Events.broadcast()
json(conn, StateService.read_state(true))
else
{:error, message} -> error(conn, :bad_request, message)
:error -> error(conn, :bad_request, "invalid player id")
{:error, _, message} -> error(conn, :internal_server_error, message)
end
end
def score_advice(conn, %{"stage" => stage, "id" => id}) do
with {:ok, normalized_stage} <- Stages.validate_stage(stage),
{player_id, ""} <- Integer.parse(to_string(id)),
{:ok, image_data} <- EventData.load_score_proof_image(normalized_stage, player_id),
{:ok, current_score} <- EventData.load_current_score(normalized_stage, player_id),
{:ok, advice} <-
Gemini.generate_score_advice(normalized_stage, player_id, image_data, current_score) do
json(conn, advice)
else
{:error, :not_found} -> error(conn, :bad_request, "no proof image found for this score")
{:error, :service_unavailable, message} -> error(conn, :service_unavailable, message)
{:error, :bad_gateway, message} -> error(conn, :bad_gateway, message)
{:error, message} when is_binary(message) -> error(conn, :bad_request, message)
:error -> error(conn, :bad_request, "invalid player id")
end
end
defp parse_score(value) do
cond do
is_integer(value) ->
value
is_binary(value) ->
case Integer.parse(String.trim(value)) do
{v, ""} -> v
_ -> {:invalid_score, "invalid request body"}
end
true ->
{:invalid_score, "invalid request body"}
end
end
defp truthy?(value) do
case value do
true -> true
1 -> true
"1" -> true
"true" -> true
"yes" -> true
"on" -> true
_ -> false
end
end
defp error(conn, status, message) do
conn
|> put_status(status)
|> json(%{"message" => message})
end
end

View File

@@ -0,0 +1,21 @@
defmodule ShootingEventPhxWeb.ErrorJSON do
@moduledoc """
This module is invoked by your endpoint in case of errors on JSON requests.
See config/config.exs.
"""
# If you want to customize a particular status code,
# you may add your own clauses, such as:
#
# def render("500.json", _assigns) do
# %{errors: %{detail: "Internal Server Error"}}
# end
# By default, Phoenix returns the status message from
# the template name. For example, "404.json" becomes
# "Not Found".
def render(template, _assigns) do
%{errors: %{detail: Phoenix.Controller.status_message_from_template(template)}}
end
end

View File

@@ -0,0 +1,40 @@
defmodule ShootingEventPhxWeb.EventsController do
use ShootingEventPhxWeb, :controller
alias ShootingEventPhx.Events
def stream(conn, _params) do
conn =
conn
|> put_resp_header("content-type", "text/event-stream")
|> put_resp_header("cache-control", "no-cache, no-transform")
|> put_resp_header("connection", "keep-alive")
|> put_resp_header("x-accel-buffering", "no")
|> send_chunked(200)
now = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601()
{:ok, conn} = chunk(conn, "event: ready\ndata: {\"ts\":\"#{now}\"}\n\n")
:ok = Events.subscribe()
loop_stream(conn)
end
defp loop_stream(conn) do
receive do
{:state, ts} ->
case chunk(conn, "event: state\ndata: {\"ts\":\"#{ts}\"}\n\n") do
{:ok, conn} -> loop_stream(conn)
{:error, :closed} -> conn
{:error, _} -> conn
end
after
20_000 ->
now_unix = DateTime.utc_now() |> DateTime.to_unix()
case chunk(conn, ": ping #{now_unix}\n\n") do
{:ok, conn} -> loop_stream(conn)
{:error, :closed} -> conn
{:error, _} -> conn
end
end
end
end

View File

@@ -0,0 +1,57 @@
defmodule ShootingEventPhxWeb.FrontendController do
use ShootingEventPhxWeb, :controller
def serve(conn, %{"path" => segments}) do
request_path =
segments
|> List.wrap()
|> Enum.join("/")
|> String.trim_leading("/")
if String.starts_with?(request_path, "api") do
send_resp(conn, 404, "Not Found")
else
web_dir = web_dir()
requested = if request_path == "", do: "index.html", else: request_path
requested_file = safe_file_path(web_dir, requested)
cond do
requested_file != nil and File.regular?(requested_file) ->
send_static_file(conn, requested_file)
File.regular?(Path.join(web_dir, "index.html")) ->
send_static_file(conn, Path.join(web_dir, "index.html"))
true ->
send_resp(conn, 404, "frontend build not found. run make build")
end
end
end
defp web_dir do
System.get_env("WEB_DIR") || Path.expand("../frontend/dist", File.cwd!())
end
defp safe_file_path(web_dir, request_path) do
cleaned = request_path |> Path.expand(web_dir)
root = Path.expand(web_dir)
if String.starts_with?(cleaned, root) do
cleaned
else
nil
end
end
defp send_static_file(conn, file_path) do
content_type =
case MIME.from_path(file_path) do
"" -> "application/octet-stream"
type -> type
end
conn
|> put_resp_content_type(content_type)
|> send_file(200, file_path)
end
end

View File

@@ -0,0 +1,50 @@
defmodule ShootingEventPhxWeb.Endpoint do
use Phoenix.Endpoint, otp_app: :shooting_event_phx
# The session will be stored in the cookie and signed,
# this means its contents can be read but not tampered with.
# Set :encryption_salt if you would also like to encrypt it.
@session_options [
store: :cookie,
key: "_shooting_event_phx_key",
signing_salt: "RrxJbij4",
same_site: "Lax"
]
# socket "/live", Phoenix.LiveView.Socket,
# websocket: [connect_info: [session: @session_options]],
# longpoll: [connect_info: [session: @session_options]]
# Serve at "/" the static files from "priv/static" directory.
#
# When code reloading is disabled (e.g., in production),
# the `gzip` option is enabled to serve compressed
# static files generated by running `phx.digest`.
plug Plug.Static,
at: "/",
from: :shooting_event_phx,
gzip: not code_reloading?,
only: ShootingEventPhxWeb.static_paths(),
raise_on_missing_only: code_reloading?
# Code reloading can be explicitly enabled under the
# :code_reloader configuration of your endpoint.
if code_reloading? do
plug Phoenix.CodeReloader
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :shooting_event_phx
end
plug Plug.RequestId
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
plug Plug.Parsers,
parsers: [:urlencoded, :multipart, :json],
pass: ["*/*"],
json_decoder: Phoenix.json_library()
plug Plug.MethodOverride
plug Plug.Head
plug ShootingEventPhxWeb.Plugs.CORS
plug Plug.Session, @session_options
plug ShootingEventPhxWeb.Router
end

View File

@@ -0,0 +1,18 @@
defmodule ShootingEventPhxWeb.Plugs.AdminOnly do
@moduledoc false
import Plug.Conn
alias ShootingEventPhx.Auth
def init(opts), do: opts
def call(conn, _opts) do
if Auth.admin_request?(conn) do
conn
else
conn
|> put_status(:unauthorized)
|> Phoenix.Controller.json(%{"message" => "invalid or expired admin token"})
|> halt()
end
end
end

View File

@@ -0,0 +1,20 @@
defmodule ShootingEventPhxWeb.Plugs.CORS do
@moduledoc false
import Plug.Conn
def init(opts), do: opts
def call(conn, _opts) do
conn =
conn
|> put_resp_header("access-control-allow-origin", "*")
|> put_resp_header("access-control-allow-methods", "GET,POST,PUT,DELETE,OPTIONS")
|> put_resp_header("access-control-allow-headers", "content-type,authorization")
if conn.method == "OPTIONS" do
conn |> send_resp(204, "") |> halt()
else
conn
end
end
end

View File

@@ -0,0 +1,49 @@
defmodule ShootingEventPhxWeb.Router do
use ShootingEventPhxWeb, :router
pipeline :api_json do
plug :accepts, ["json"]
end
pipeline :frontend do
end
pipeline :admin_api do
plug ShootingEventPhxWeb.Plugs.AdminOnly
end
scope "/api", ShootingEventPhxWeb do
get "/events", EventsController, :stream
end
scope "/api", ShootingEventPhxWeb do
pipe_through :api_json
get "/health", ApiController, :health
get "/state", ApiController, :state
post "/admin/login", ApiController, :admin_login
scope "/admin" do
pipe_through :admin_api
post "/logout", ApiController, :admin_logout
get "/state", ApiController, :admin_state
put "/settings", ApiController, :update_settings
post "/players", ApiController, :create_player
post "/players/auto-group", ApiController, :auto_group_players
put "/players/:id", ApiController, :update_player
delete "/players/:id", ApiController, :delete_player
put "/scores/:stage/:id", ApiController, :update_score
put "/scores/:stage/:id/proof", ApiController, :update_score_proof
delete "/scores/:stage/:id/proof", ApiController, :delete_score_proof
post "/scores/:stage/:id/advice", ApiController, :score_advice
post "/scores/:stage/reset", ApiController, :reset_stage
end
end
scope "/", ShootingEventPhxWeb do
pipe_through :frontend
match :*, "/*path", FrontendController, :serve
end
end

View File

@@ -0,0 +1,93 @@
defmodule ShootingEventPhxWeb.Telemetry do
use Supervisor
import Telemetry.Metrics
def start_link(arg) do
Supervisor.start_link(__MODULE__, arg, name: __MODULE__)
end
@impl true
def init(_arg) do
children = [
# Telemetry poller will execute the given period measurements
# every 10_000ms. Learn more here: https://hexdocs.pm/telemetry_metrics
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
# Add reporters as children of your supervision tree.
# {Telemetry.Metrics.ConsoleReporter, metrics: metrics()}
]
Supervisor.init(children, strategy: :one_for_one)
end
def metrics do
[
# Phoenix Metrics
summary("phoenix.endpoint.start.system_time",
unit: {:native, :millisecond}
),
summary("phoenix.endpoint.stop.duration",
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.start.system_time",
tags: [:route],
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.exception.duration",
tags: [:route],
unit: {:native, :millisecond}
),
summary("phoenix.router_dispatch.stop.duration",
tags: [:route],
unit: {:native, :millisecond}
),
summary("phoenix.socket_connected.duration",
unit: {:native, :millisecond}
),
sum("phoenix.socket_drain.count"),
summary("phoenix.channel_joined.duration",
unit: {:native, :millisecond}
),
summary("phoenix.channel_handled_in.duration",
tags: [:event],
unit: {:native, :millisecond}
),
# Database Metrics
summary("shooting_event_phx.repo.query.total_time",
unit: {:native, :millisecond},
description: "The sum of the other measurements"
),
summary("shooting_event_phx.repo.query.decode_time",
unit: {:native, :millisecond},
description: "The time spent decoding the data received from the database"
),
summary("shooting_event_phx.repo.query.query_time",
unit: {:native, :millisecond},
description: "The time spent executing the query"
),
summary("shooting_event_phx.repo.query.queue_time",
unit: {:native, :millisecond},
description: "The time spent waiting for a database connection"
),
summary("shooting_event_phx.repo.query.idle_time",
unit: {:native, :millisecond},
description:
"The time the connection spent waiting before being checked out for the query"
),
# VM Metrics
summary("vm.memory.total", unit: {:byte, :kilobyte}),
summary("vm.total_run_queue_lengths.total"),
summary("vm.total_run_queue_lengths.cpu"),
summary("vm.total_run_queue_lengths.io")
]
end
defp periodic_measurements do
[
# A module, function and arguments to be invoked periodically.
# This function must call :telemetry.execute/3 and a metric must be added above.
# {ShootingEventPhxWeb, :count_users, []}
]
end
end

70
phoenix/mix.exs Normal file
View File

@@ -0,0 +1,70 @@
defmodule ShootingEventPhx.MixProject do
use Mix.Project
def project do
[
app: :shooting_event_phx,
version: "0.1.0",
elixir: "~> 1.15",
elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod,
aliases: aliases(),
deps: deps(),
listeners: [Phoenix.CodeReloader]
]
end
# Configuration for the OTP application.
#
# Type `mix help compile.app` for more information.
def application do
[
mod: {ShootingEventPhx.Application, []},
extra_applications: [:logger, :runtime_tools]
]
end
def cli do
[
preferred_envs: [precommit: :test]
]
end
# Specifies which paths to compile per environment.
defp elixirc_paths(:test), do: ["lib", "test/support"]
defp elixirc_paths(_), do: ["lib"]
# Specifies your project dependencies.
#
# Type `mix help deps` for examples and options.
defp deps do
[
{:phoenix, "~> 1.8.5"},
{:phoenix_ecto, "~> 4.5"},
{:ecto_sql, "~> 3.13"},
{:ecto_sqlite3, ">= 0.0.0"},
{:telemetry_metrics, "~> 1.0"},
{:telemetry_poller, "~> 1.0"},
{:jason, "~> 1.2"},
{:req, "~> 0.5"},
{:dns_cluster, "~> 0.2.0"},
{:bandit, "~> 1.5"}
]
end
# Aliases are shortcuts or tasks specific to the current project.
# For example, to install project dependencies and perform other setup tasks, run:
#
# $ mix setup
#
# See the documentation for `Mix` for more info on aliases.
defp aliases do
[
setup: ["deps.get", "ecto.setup"],
"ecto.setup": ["ecto.create", "ecto.migrate", "run priv/repo/seeds.exs"],
"ecto.reset": ["ecto.drop", "ecto.setup"],
test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"],
precommit: ["compile --warnings-as-errors", "deps.unlock --unused", "format", "test"]
]
end
end

32
phoenix/mix.lock Normal file
View File

@@ -0,0 +1,32 @@
%{
"bandit": {:hex, :bandit, "1.10.4", "02b9734c67c5916a008e7eb7e2ba68aaea6f8177094a5f8d95f1fb99069aac17", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "a5faf501042ac1f31d736d9d4a813b3db4ef812e634583b6a457b0928798a51d"},
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
"db_connection": {:hex, :db_connection, "2.9.0", "a6a97c5c958a2d7091a58a9be40caf41ab496b0701d21e1d1abff3fa27a7f371", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "17d502eacaf61829db98facf6f20808ed33da6ccf495354a41e64fe42f9c509c"},
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
"ecto_sql": {:hex, :ecto_sql, "3.13.5", "2f8282b2ad97bf0f0d3217ea0a6fff320ead9e2f8770f810141189d182dc304e", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "aa36751f4e6a2b56ae79efb0e088042e010ff4935fc8684e74c23b1f49e25fdc"},
"ecto_sqlite3": {:hex, :ecto_sqlite3, "0.22.0", "edab2d0f701b7dd05dcf7e2d97769c106aff62b5cfddc000d1dd6f46b9cbd8c3", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13.0", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:exqlite, "~> 0.22", [hex: :exqlite, repo: "hexpm", optional: false]}], "hexpm", "5af9e031bffcc5da0b7bca90c271a7b1e7c04a93fecf7f6cd35bc1b1921a64bd"},
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
"exqlite": {:hex, :exqlite, "0.36.0", "07b4f95d61cb82b8d52946d0639497fa7d32117e09b2c8d25e24a38723c295cb", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.8", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "cbeca3ce781f9ff07cfa9a87486f3ebd512a143ad6a14ed5c9fca21fe0bf3ae7"},
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
"phoenix": {:hex, :phoenix, "1.8.5", "919db335247e6d4891764dc3063415b0d2457641c5f9b3751b5df03d8e20bbcf", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "83b2bb125127e02e9f475c8e3e92736325b5b01b0b9b05407bcb4083b7a32485"},
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
"telemetry": {:hex, :telemetry, "1.4.1", "ab6de178e2b29b58e8256b92b382ea3f590a47152ca3651ea857a6cae05ac423", [:rebar3], [], "hexpm", "2172e05a27531d3d31dd9782841065c50dd5c3c7699d95266b2edd54c2dafa1c"},
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
}

View File

@@ -0,0 +1,4 @@
[
import_deps: [:ecto_sql],
inputs: ["*.exs"]
]

View File

@@ -0,0 +1,92 @@
defmodule ShootingEventPhx.Repo.Migrations.InitEventSchema do
use Ecto.Migration
def up do
execute("""
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
);
""")
execute("""
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
);
""")
execute("""
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
);
""")
execute("""
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL DEFAULT '',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
""")
execute("""
INSERT OR IGNORE INTO app_settings(key, value) VALUES
('view_proof_in_view', '0'),
('live_active_view', 'group_live'),
('live_show_group_live', '1'),
('live_group_display_mode', 'rotate'),
('live_group_fixed_code', ''),
('live_show_prelim_tie', '1'),
('live_show_prelim_overall', '1'),
('live_show_final_groups', '1'),
('live_final_display_mode', 'rotate'),
('live_final_fixed_group', '1'),
('live_show_final_tie', '1'),
('live_show_podium', '1'),
('live_rotation_interval_sec', '5'),
('live_rotation_player_count', '12');
""")
execute("""
INSERT OR IGNORE INTO scores(stage, player_id, score)
SELECT 'prelim_r1', player_id, score FROM scores WHERE stage = 'preliminary';
""")
execute("""
INSERT OR IGNORE INTO scores(stage, player_id, score)
SELECT 'final_r1', player_id, score FROM scores WHERE stage = 'final';
""")
execute("""
INSERT OR IGNORE INTO score_attachments(stage, player_id, image_data)
SELECT 'prelim_r1', player_id, image_data FROM score_attachments WHERE stage = 'preliminary';
""")
execute("""
INSERT OR IGNORE INTO score_attachments(stage, player_id, image_data)
SELECT 'final_r1', player_id, image_data FROM score_attachments WHERE stage = 'final';
""")
end
def down do
execute("DROP TABLE IF EXISTS score_attachments;")
execute("DROP TABLE IF EXISTS scores;")
execute("DROP TABLE IF EXISTS app_settings;")
execute("DROP TABLE IF EXISTS players;")
end
end

View File

@@ -0,0 +1,11 @@
# Script for populating the database. You can run it as:
#
# mix run priv/repo/seeds.exs
#
# Inside the script, you can read and write to any of your
# repositories directly:
#
# ShootingEventPhx.Repo.insert!(%ShootingEventPhx.SomeSchema{})
#
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 B

View File

@@ -0,0 +1,5 @@
# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file
#
# To ban all spiders from the entire site uncomment the next two lines:
# User-agent: *
# Disallow: /

View File

@@ -0,0 +1,14 @@
defmodule ShootingEventPhxWeb.ErrorJSONTest do
use ShootingEventPhxWeb.ConnCase, async: true
test "renders 404" do
assert ShootingEventPhxWeb.ErrorJSON.render("404.json", %{}) == %{
errors: %{detail: "Not Found"}
}
end
test "renders 500" do
assert ShootingEventPhxWeb.ErrorJSON.render("500.json", %{}) ==
%{errors: %{detail: "Internal Server Error"}}
end
end

View File

@@ -0,0 +1,38 @@
defmodule ShootingEventPhxWeb.ConnCase do
@moduledoc """
This module defines the test case to be used by
tests that require setting up a connection.
Such tests rely on `Phoenix.ConnTest` and also
import other functionality to make it easier
to build common data structures and query the data layer.
Finally, if the test case interacts with the database,
we enable the SQL sandbox, so changes done to the database
are reverted at the end of every test. If you are using
PostgreSQL, you can even run database tests asynchronously
by setting `use ShootingEventPhxWeb.ConnCase, async: true`, although
this option is not recommended for other databases.
"""
use ExUnit.CaseTemplate
using do
quote do
# The default endpoint for testing
@endpoint ShootingEventPhxWeb.Endpoint
use ShootingEventPhxWeb, :verified_routes
# Import conveniences for testing with connections
import Plug.Conn
import Phoenix.ConnTest
import ShootingEventPhxWeb.ConnCase
end
end
setup tags do
ShootingEventPhx.DataCase.setup_sandbox(tags)
{:ok, conn: Phoenix.ConnTest.build_conn()}
end
end

View File

@@ -0,0 +1,58 @@
defmodule ShootingEventPhx.DataCase do
@moduledoc """
This module defines the setup for tests requiring
access to the application's data layer.
You may define functions here to be used as helpers in
your tests.
Finally, if the test case interacts with the database,
we enable the SQL sandbox, so changes done to the database
are reverted at the end of every test. If you are using
PostgreSQL, you can even run database tests asynchronously
by setting `use ShootingEventPhx.DataCase, async: true`, although
this option is not recommended for other databases.
"""
use ExUnit.CaseTemplate
using do
quote do
alias ShootingEventPhx.Repo
import Ecto
import Ecto.Changeset
import Ecto.Query
import ShootingEventPhx.DataCase
end
end
setup tags do
ShootingEventPhx.DataCase.setup_sandbox(tags)
:ok
end
@doc """
Sets up the sandbox based on the test tags.
"""
def setup_sandbox(tags) do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(ShootingEventPhx.Repo, shared: not tags[:async])
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
end
@doc """
A helper that transforms changeset errors into a map of messages.
assert {:error, changeset} = Accounts.create_user(%{password: "short"})
assert "password is too short" in errors_on(changeset).password
assert %{password: ["password is too short"]} = errors_on(changeset)
"""
def errors_on(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
Regex.replace(~r"%{(\w+)}", message, fn _, key ->
opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
end)
end)
end
end

View File

@@ -0,0 +1,2 @@
ExUnit.start()
Ecto.Adapters.SQL.Sandbox.mode(ShootingEventPhx.Repo, :manual)