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