{ "openapi": "3.0.3", "info": { "title": "Shooting Event API", "description": "REST API for live scoring, admin control, proof images, and AI score advice.", "version": "1.0.0" }, "servers": [ { "url": "/api", "description": "Same host API base path" } ], "tags": [ { "name": "Public", "description": "Public endpoints available without admin authentication" }, { "name": "Admin", "description": "Admin endpoints protected by bearer token" } ], "paths": { "/health": { "get": { "tags": [ "Public" ], "summary": "Health check", "operationId": "getHealth", "responses": { "200": { "description": "Service is healthy", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/HealthResponse" } } } } } } }, "/state": { "get": { "tags": [ "Public" ], "summary": "Get application state", "description": "Returns public state by default. If a valid admin bearer token is sent, `scoreProofs` is included.", "operationId": "getState", "security": [ {}, { "bearerAuth": [] } ], "responses": { "200": { "description": "Current application state", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StateResponse" } } } }, "500": { "$ref": "#/components/responses/InternalServerError" } } } }, "/events": { "get": { "tags": [ "Public" ], "summary": "Live update stream (SSE)", "description": "Server-Sent Events stream. Emits `ready` and `state` events plus periodic keepalive pings.", "operationId": "streamEvents", "responses": { "200": { "description": "Event stream opened", "content": { "text/event-stream": { "schema": { "type": "string", "example": "event: ready\\ndata: {\"ts\":\"2026-04-28T10:00:00Z\"}\\n\\n" } } } }, "500": { "$ref": "#/components/responses/InternalServerError" } } } }, "/admin/login": { "post": { "tags": [ "Admin" ], "summary": "Admin login", "description": "Authenticates admin credentials and returns a bearer token valid for 8 hours.", "operationId": "adminLogin", "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AdminLoginRequest" } } } }, "responses": { "200": { "description": "Login successful", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AdminLoginResponse" } } } }, "400": { "$ref": "#/components/responses/BadRequest" }, "401": { "$ref": "#/components/responses/Unauthorized" }, "500": { "$ref": "#/components/responses/InternalServerError" } } } }, "/admin/logout": { "post": { "tags": [ "Admin" ], "summary": "Admin logout", "operationId": "adminLogout", "security": [ { "bearerAuth": [] } ], "responses": { "200": { "description": "Token invalidated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/OkResponse" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" } } } }, "/admin/state": { "get": { "tags": [ "Admin" ], "summary": "Get full admin state", "description": "Same shape as `/state`, always includes admin-visible fields such as `scoreProofs`.", "operationId": "getAdminState", "security": [ { "bearerAuth": [] } ], "responses": { "200": { "description": "Full admin state", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StateResponse" } } } }, "401": { "$ref": "#/components/responses/Unauthorized" }, "500": { "$ref": "#/components/responses/InternalServerError" } } } }, "/admin/settings": { "put": { "tags": [ "Admin" ], "summary": "Update app settings", "operationId": "updateAdminSettings", "security": [ { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AdminSettingsUpdateRequest" } } } }, "responses": { "200": { "description": "Updated state", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StateResponse" } } } }, "400": { "$ref": "#/components/responses/BadRequest" }, "401": { "$ref": "#/components/responses/Unauthorized" }, "500": { "$ref": "#/components/responses/InternalServerError" } } } }, "/admin/players": { "post": { "tags": [ "Admin" ], "summary": "Create player", "operationId": "createPlayer", "security": [ { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PlayerCreateRequest" } } } }, "responses": { "201": { "description": "Player created; full updated state returned", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StateResponse" } } } }, "400": { "$ref": "#/components/responses/BadRequest" }, "401": { "$ref": "#/components/responses/Unauthorized" }, "500": { "$ref": "#/components/responses/InternalServerError" } } } }, "/admin/players/auto-group": { "post": { "tags": [ "Admin" ], "summary": "Auto-assign player groups", "description": "Randomly shuffles players and assigns group codes in round-robin order.", "operationId": "autoGroupPlayers", "security": [ { "bearerAuth": [] } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AutoGroupPlayersRequest" } } } }, "responses": { "200": { "description": "Updated state", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StateResponse" } } } }, "400": { "$ref": "#/components/responses/BadRequest" }, "401": { "$ref": "#/components/responses/Unauthorized" }, "500": { "$ref": "#/components/responses/InternalServerError" } } } }, "/admin/players/{id}": { "put": { "tags": [ "Admin" ], "summary": "Update player", "operationId": "updatePlayer", "security": [ { "bearerAuth": [] } ], "parameters": [ { "$ref": "#/components/parameters/PlayerIdParam" } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PlayerUpdateRequest" } } } }, "responses": { "200": { "description": "Updated state", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StateResponse" } } } }, "400": { "$ref": "#/components/responses/BadRequest" }, "401": { "$ref": "#/components/responses/Unauthorized" }, "404": { "$ref": "#/components/responses/NotFound" }, "500": { "$ref": "#/components/responses/InternalServerError" } } }, "delete": { "tags": [ "Admin" ], "summary": "Delete player", "operationId": "deletePlayer", "security": [ { "bearerAuth": [] } ], "parameters": [ { "$ref": "#/components/parameters/PlayerIdParam" } ], "responses": { "200": { "description": "Updated state", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StateResponse" } } } }, "400": { "$ref": "#/components/responses/BadRequest" }, "401": { "$ref": "#/components/responses/Unauthorized" }, "404": { "$ref": "#/components/responses/NotFound" }, "500": { "$ref": "#/components/responses/InternalServerError" } } } }, "/admin/scores/{stage}/{id}": { "put": { "tags": [ "Admin" ], "summary": "Set a score", "description": "Upserts score for a given stage/player. Score must be 0..100.", "operationId": "updateScore", "security": [ { "bearerAuth": [] } ], "parameters": [ { "$ref": "#/components/parameters/ScoreStageParam" }, { "$ref": "#/components/parameters/PlayerIdParam" } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ScoreUpdateRequest" } } } }, "responses": { "200": { "description": "Updated state", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StateResponse" } } } }, "400": { "$ref": "#/components/responses/BadRequest" }, "401": { "$ref": "#/components/responses/Unauthorized" }, "500": { "$ref": "#/components/responses/InternalServerError" } } } }, "/admin/scores/{stage}/{id}/proof": { "put": { "tags": [ "Admin" ], "summary": "Upload score proof image", "description": "Stores proof image data (typically a data URL string).", "operationId": "updateScoreProof", "security": [ { "bearerAuth": [] } ], "parameters": [ { "$ref": "#/components/parameters/ScoreStageParam" }, { "$ref": "#/components/parameters/PlayerIdParam" } ], "requestBody": { "required": true, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ScoreProofUpdateRequest" } } } }, "responses": { "200": { "description": "Updated state", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StateResponse" } } } }, "400": { "$ref": "#/components/responses/BadRequest" }, "401": { "$ref": "#/components/responses/Unauthorized" }, "500": { "$ref": "#/components/responses/InternalServerError" } } }, "delete": { "tags": [ "Admin" ], "summary": "Delete score proof image", "operationId": "deleteScoreProof", "security": [ { "bearerAuth": [] } ], "parameters": [ { "$ref": "#/components/parameters/ScoreStageParam" }, { "$ref": "#/components/parameters/PlayerIdParam" } ], "responses": { "200": { "description": "Updated state", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StateResponse" } } } }, "400": { "$ref": "#/components/responses/BadRequest" }, "401": { "$ref": "#/components/responses/Unauthorized" }, "500": { "$ref": "#/components/responses/InternalServerError" } } } }, "/admin/scores/{stage}/{id}/advice": { "post": { "tags": [ "Admin" ], "summary": "Get AI score advice", "description": "Uses configured Gemini model with uploaded proof image to propose a score and reason.", "operationId": "getScoreAdvice", "security": [ { "bearerAuth": [] } ], "parameters": [ { "$ref": "#/components/parameters/ScoreStageParam" }, { "$ref": "#/components/parameters/PlayerIdParam" } ], "responses": { "200": { "description": "Advice generated", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ScoreAdviceResponse" } } } }, "400": { "$ref": "#/components/responses/BadRequest" }, "401": { "$ref": "#/components/responses/Unauthorized" }, "502": { "description": "AI upstream returned an error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, "503": { "description": "AI service not configured", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, "500": { "$ref": "#/components/responses/InternalServerError" } } } }, "/admin/scores/{stage}/reset": { "post": { "tags": [ "Admin" ], "summary": "Reset a stage family", "description": "Resets scores to zero for one stage family or tie-break stage. Optionally removes proofs.", "operationId": "resetStage", "security": [ { "bearerAuth": [] } ], "parameters": [ { "$ref": "#/components/parameters/ResetStageParam" } ], "requestBody": { "required": false, "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ResetStageRequest" } } } }, "responses": { "200": { "description": "Updated state", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/StateResponse" } } } }, "400": { "$ref": "#/components/responses/BadRequest" }, "401": { "$ref": "#/components/responses/Unauthorized" }, "500": { "$ref": "#/components/responses/InternalServerError" } } } } }, "components": { "securitySchemes": { "bearerAuth": { "type": "http", "scheme": "bearer", "bearerFormat": "Token" } }, "parameters": { "PlayerIdParam": { "name": "id", "in": "path", "required": true, "description": "Player ID", "schema": { "type": "integer", "minimum": 1 } }, "ScoreStageParam": { "name": "stage", "in": "path", "required": true, "description": "Score stage key", "schema": { "type": "string", "enum": [ "prelim_r1", "prelim_r2", "prelim_r3", "final_r1", "final_r2", "prelim_tiebreak", "final_tiebreak" ] } }, "ResetStageParam": { "name": "stage", "in": "path", "required": true, "description": "Reset target", "schema": { "type": "string", "enum": [ "preliminary", "final", "prelim_tiebreak", "final_tiebreak" ] } } }, "responses": { "BadRequest": { "description": "Invalid input", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, "Unauthorized": { "description": "Missing or invalid bearer token", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, "NotFound": { "description": "Resource not found", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } }, "InternalServerError": { "description": "Internal server error", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ErrorResponse" } } } } }, "schemas": { "ErrorResponse": { "type": "object", "required": [ "message" ], "properties": { "message": { "type": "string" } } }, "OkResponse": { "type": "object", "required": [ "ok" ], "properties": { "ok": { "type": "boolean" } } }, "HealthResponse": { "type": "object", "required": [ "status" ], "properties": { "status": { "type": "string", "example": "ok" } } }, "AdminLoginRequest": { "type": "object", "required": [ "username", "password" ], "properties": { "username": { "type": "string" }, "password": { "type": "string" } } }, "AdminLoginResponse": { "type": "object", "required": [ "token", "expiresAt", "username" ], "properties": { "token": { "type": "string" }, "expiresAt": { "type": "string", "format": "date-time" }, "username": { "type": "string" } } }, "CompetitionMeta": { "type": "object", "required": [ "titleAr", "titleEn" ], "properties": { "titleAr": { "type": "string" }, "titleEn": { "type": "string" } } }, "Player": { "type": "object", "required": [ "id", "nameAr", "nameEn", "groupCode", "imageData" ], "properties": { "id": { "type": "integer" }, "nameAr": { "type": "string" }, "nameEn": { "type": "string" }, "groupCode": { "type": "string" }, "imageData": { "type": "string" } } }, "RankingRow": { "type": "object", "required": [ "playerId", "nameAr", "nameEn", "groupCode", "imageData", "score", "tieBreak", "rank", "seed", "finalGroup" ], "properties": { "playerId": { "type": "integer" }, "nameAr": { "type": "string" }, "nameEn": { "type": "string" }, "groupCode": { "type": "string" }, "imageData": { "type": "string" }, "score": { "type": "integer" }, "tieBreak": { "type": "integer" }, "rank": { "type": "integer" }, "seed": { "type": "integer" }, "finalGroup": { "type": "integer" } } }, "TieBreakInfo": { "type": "object", "required": [ "required", "resolved", "slots", "playerIds" ], "properties": { "required": { "type": "boolean" }, "resolved": { "type": "boolean" }, "slots": { "type": "integer" }, "playerIds": { "type": "array", "items": { "type": "integer" } } } }, "RankingBundle": { "type": "object", "required": [ "rows", "tieBreak", "unresolved" ], "properties": { "rows": { "type": "array", "items": { "$ref": "#/components/schemas/RankingRow" } }, "tieBreak": { "$ref": "#/components/schemas/TieBreakInfo" }, "unresolved": { "type": "boolean" } } }, "FinalGroups": { "type": "object", "required": [ "group1", "group2" ], "properties": { "group1": { "type": "array", "items": { "$ref": "#/components/schemas/RankingRow" } }, "group2": { "type": "array", "items": { "$ref": "#/components/schemas/RankingRow" } } } }, "DerivedState": { "type": "object", "required": [ "preliminaryRanking", "finalists", "finalGroups", "finalRanking", "podium" ], "properties": { "preliminaryRanking": { "$ref": "#/components/schemas/RankingBundle" }, "finalists": { "type": "array", "items": { "$ref": "#/components/schemas/RankingRow" } }, "finalGroups": { "$ref": "#/components/schemas/FinalGroups" }, "finalRanking": { "$ref": "#/components/schemas/RankingBundle" }, "podium": { "type": "array", "items": { "$ref": "#/components/schemas/RankingRow" } } } }, "LiveModeSettings": { "type": "object", "required": [ "activeView", "showGroupLive", "groupDisplayMode", "groupFixedCode", "showPrelimTie", "showPrelimOverall", "showFinalGroups", "finalDisplayMode", "finalFixedGroup", "showFinalTie", "showPodium", "rotationIntervalSec", "rotationPlayerCount" ], "properties": { "activeView": { "type": "string", "enum": [ "group_live", "prelim_tie", "prelim_overall", "final_groups", "final_tie", "final_overall", "podium" ] }, "showGroupLive": { "type": "boolean" }, "groupDisplayMode": { "type": "string", "enum": [ "rotate", "fixed" ] }, "groupFixedCode": { "type": "string" }, "showPrelimTie": { "type": "boolean" }, "showPrelimOverall": { "type": "boolean" }, "showFinalGroups": { "type": "boolean" }, "finalDisplayMode": { "type": "string", "enum": [ "rotate", "fixed" ] }, "finalFixedGroup": { "type": "integer", "enum": [ 1, 2 ] }, "showFinalTie": { "type": "boolean" }, "showPodium": { "type": "boolean" }, "rotationIntervalSec": { "type": "integer", "minimum": 3, "maximum": 30 }, "rotationPlayerCount": { "type": "integer", "minimum": 3, "maximum": 40 } } }, "LiveModeSettingsUpdateRequest": { "type": "object", "description": "Partial patch. Send only fields that need to change.", "properties": { "activeView": { "type": "string", "enum": [ "group_live", "prelim_tie", "prelim_overall", "final_groups", "final_tie", "final_overall", "podium" ] }, "showGroupLive": { "type": "boolean" }, "groupDisplayMode": { "type": "string", "enum": [ "rotate", "fixed" ] }, "groupFixedCode": { "type": "string" }, "showPrelimTie": { "type": "boolean" }, "showPrelimOverall": { "type": "boolean" }, "showFinalGroups": { "type": "boolean" }, "finalDisplayMode": { "type": "string", "enum": [ "rotate", "fixed" ] }, "finalFixedGroup": { "type": "integer", "enum": [ 1, 2 ] }, "showFinalTie": { "type": "boolean" }, "showPodium": { "type": "boolean" }, "rotationIntervalSec": { "type": "integer", "minimum": 3, "maximum": 30 }, "rotationPlayerCount": { "type": "integer", "minimum": 3, "maximum": 40 } } }, "AppSettings": { "type": "object", "required": [ "viewProofInView", "liveMode" ], "properties": { "viewProofInView": { "type": "boolean" }, "liveMode": { "$ref": "#/components/schemas/LiveModeSettings" } } }, "StateResponse": { "type": "object", "required": [ "competition", "players", "scores", "settings", "derived", "serverTime" ], "properties": { "competition": { "$ref": "#/components/schemas/CompetitionMeta" }, "players": { "type": "array", "items": { "$ref": "#/components/schemas/Player" } }, "scores": { "type": "object", "additionalProperties": { "type": "object", "additionalProperties": { "type": "integer" } } }, "scoreProofs": { "type": "object", "additionalProperties": { "type": "object", "additionalProperties": { "type": "string" } }, "description": "Included for admin-authenticated state requests." }, "settings": { "$ref": "#/components/schemas/AppSettings" }, "derived": { "$ref": "#/components/schemas/DerivedState" }, "serverTime": { "type": "string", "format": "date-time" } } }, "AdminSettingsUpdateRequest": { "type": "object", "description": "At least one field must be provided.", "properties": { "viewProofInView": { "type": "boolean" }, "liveMode": { "$ref": "#/components/schemas/LiveModeSettingsUpdateRequest" } } }, "PlayerCreateRequest": { "type": "object", "required": [ "nameAr", "nameEn", "groupCode" ], "properties": { "nameAr": { "type": "string" }, "nameEn": { "type": "string" }, "groupCode": { "type": "string" } } }, "PlayerUpdateRequest": { "type": "object", "description": "Partial patch. Set `clearImage` true to remove image.", "properties": { "nameAr": { "type": "string" }, "nameEn": { "type": "string" }, "groupCode": { "type": "string" }, "imageData": { "type": "string", "description": "Base64/data URL payload. Max about 2.5MB characters." }, "clearImage": { "type": "boolean" } } }, "AutoGroupPlayersRequest": { "type": "object", "required": [ "groups" ], "properties": { "groups": { "type": "array", "items": { "type": "string" }, "minItems": 1 } } }, "ScoreUpdateRequest": { "type": "object", "required": [ "score" ], "properties": { "score": { "type": "integer", "minimum": 0, "maximum": 100 } } }, "ScoreProofUpdateRequest": { "type": "object", "required": [ "imageData" ], "properties": { "imageData": { "type": "string", "description": "Base64/data URL payload. Max about 5MB characters." } } }, "ResetStageRequest": { "type": "object", "properties": { "resetProofs": { "type": "boolean" } } }, "ScoreAdviceResponse": { "type": "object", "required": [ "stage", "playerId", "advisedScore", "reason", "generatedAt" ], "properties": { "stage": { "type": "string" }, "playerId": { "type": "integer" }, "advisedScore": { "type": "integer", "minimum": 0, "maximum": 100 }, "reason": { "type": "string" }, "generatedAt": { "type": "string", "format": "date-time" } } } } } }