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