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,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