api specs
This commit is contained in:
9
phoenix/lib/shooting_event_phx.ex
Normal file
9
phoenix/lib/shooting_event_phx.ex
Normal 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
|
||||
199
phoenix/lib/shooting_event_phx/ai/gemini.ex
Normal file
199
phoenix/lib/shooting_event_phx/ai/gemini.ex
Normal 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
|
||||
43
phoenix/lib/shooting_event_phx/application.ex
Normal file
43
phoenix/lib/shooting_event_phx/application.ex
Normal 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
|
||||
51
phoenix/lib/shooting_event_phx/auth.ex
Normal file
51
phoenix/lib/shooting_event_phx/auth.ex
Normal 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
|
||||
46
phoenix/lib/shooting_event_phx/auth/session_store.ex
Normal file
46
phoenix/lib/shooting_event_phx/auth/session_store.ex
Normal 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
|
||||
11
phoenix/lib/shooting_event_phx/domain/app_setting.ex
Normal file
11
phoenix/lib/shooting_event_phx/domain/app_setting.ex
Normal 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
|
||||
272
phoenix/lib/shooting_event_phx/domain/derived.ex
Normal file
272
phoenix/lib/shooting_event_phx/domain/derived.ex
Normal 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
|
||||
353
phoenix/lib/shooting_event_phx/domain/event_data.ex
Normal file
353
phoenix/lib/shooting_event_phx/domain/event_data.ex
Normal 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
|
||||
14
phoenix/lib/shooting_event_phx/domain/player.ex
Normal file
14
phoenix/lib/shooting_event_phx/domain/player.ex
Normal 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
|
||||
13
phoenix/lib/shooting_event_phx/domain/score.ex
Normal file
13
phoenix/lib/shooting_event_phx/domain/score.ex
Normal 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
|
||||
13
phoenix/lib/shooting_event_phx/domain/score_attachment.ex
Normal file
13
phoenix/lib/shooting_event_phx/domain/score_attachment.ex
Normal 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
|
||||
297
phoenix/lib/shooting_event_phx/domain/settings.ex
Normal file
297
phoenix/lib/shooting_event_phx/domain/settings.ex
Normal 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
|
||||
42
phoenix/lib/shooting_event_phx/domain/stages.ex
Normal file
42
phoenix/lib/shooting_event_phx/domain/stages.ex
Normal 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
|
||||
43
phoenix/lib/shooting_event_phx/domain/state_service.ex
Normal file
43
phoenix/lib/shooting_event_phx/domain/state_service.ex
Normal 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
|
||||
15
phoenix/lib/shooting_event_phx/events.ex
Normal file
15
phoenix/lib/shooting_event_phx/events.ex
Normal 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
|
||||
5
phoenix/lib/shooting_event_phx/repo.ex
Normal file
5
phoenix/lib/shooting_event_phx/repo.ex
Normal file
@@ -0,0 +1,5 @@
|
||||
defmodule ShootingEventPhx.Repo do
|
||||
use Ecto.Repo,
|
||||
otp_app: :shooting_event_phx,
|
||||
adapter: Ecto.Adapters.SQLite3
|
||||
end
|
||||
63
phoenix/lib/shooting_event_phx_web.ex
Normal file
63
phoenix/lib/shooting_event_phx_web.ex
Normal 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
|
||||
221
phoenix/lib/shooting_event_phx_web/controllers/api_controller.ex
Normal file
221
phoenix/lib/shooting_event_phx_web/controllers/api_controller.ex
Normal 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
|
||||
21
phoenix/lib/shooting_event_phx_web/controllers/error_json.ex
Normal file
21
phoenix/lib/shooting_event_phx_web/controllers/error_json.ex
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
50
phoenix/lib/shooting_event_phx_web/endpoint.ex
Normal file
50
phoenix/lib/shooting_event_phx_web/endpoint.ex
Normal 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
|
||||
18
phoenix/lib/shooting_event_phx_web/plugs/admin_only.ex
Normal file
18
phoenix/lib/shooting_event_phx_web/plugs/admin_only.ex
Normal 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
|
||||
20
phoenix/lib/shooting_event_phx_web/plugs/cors.ex
Normal file
20
phoenix/lib/shooting_event_phx_web/plugs/cors.ex
Normal 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
|
||||
49
phoenix/lib/shooting_event_phx_web/router.ex
Normal file
49
phoenix/lib/shooting_event_phx_web/router.ex
Normal 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
|
||||
93
phoenix/lib/shooting_event_phx_web/telemetry.ex
Normal file
93
phoenix/lib/shooting_event_phx_web/telemetry.ex
Normal 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
|
||||
Reference in New Issue
Block a user