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

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