defmodule Microwaveprop.Radio.Import do @moduledoc false import Ecto.Query alias Microwaveprop.Accounts.User alias Microwaveprop.Radio.Contact alias Microwaveprop.Radio.ContactEdit alias Microwaveprop.Radio.Contacts alias Microwaveprop.Radio.Maidenhead alias Microwaveprop.Repo alias Microwaveprop.Workers.ContactWeatherEnqueueWorker @refinement_fields ~w(grid1 grid2 mode)a @refinement_allowed_modes ~w(CW SSB FM FT8 FT4 Q65) @editable_proposed_keys ~w(station1 station2 grid1 grid2 band mode qso_timestamp height1_ft height2_ft private) # ── Import refinement ── @spec apply_contact_refinement(Contact.t(), map()) :: {:ok, Contact.t()} | {:error, Ecto.Changeset.t()} def apply_contact_refinement(%Contact{} = contact, changes) when is_map(changes) do cleaned = Map.take(changes, @refinement_fields) if cleaned == %{}, do: {:ok, contact}, else: do_apply_contact_refinement(contact, cleaned) end defp do_apply_contact_refinement(contact, changes) do changeset = contact |> Ecto.Changeset.cast(changes, @refinement_fields) |> validate_refinement_mode() |> maybe_recompute_refinement_positions(contact, changes) if changeset.valid?, do: Repo.update(changeset), else: {:error, %{changeset | action: :update}} end defp validate_refinement_mode(changeset), do: Ecto.Changeset.validate_inclusion(changeset, :mode, @refinement_allowed_modes) defp maybe_recompute_refinement_positions(changeset, contact, changes) do new_grid1 = Map.get(changes, :grid1) new_grid2 = Map.get(changes, :grid2) cond do new_grid1 && new_grid2 -> put_pos_changes(changeset, new_grid1, new_grid2, :both) new_grid1 -> put_pos_changes(changeset, new_grid1, contact.grid2, :side1) new_grid2 -> put_pos_changes(changeset, contact.grid1, new_grid2, :side2) true -> changeset end end defp put_pos_changes(changeset, grid1, grid2, sides) do case {Maidenhead.to_latlon(grid1), Maidenhead.to_latlon(grid2)} do {{:ok, {lat1, lon1}}, {:ok, {lat2, lon2}}} -> distance = lat1 |> Contacts.haversine_km(lon1, lat2, lon2) |> round() |> Decimal.new() changeset |> maybe_put_pos_for_side(:pos1, %{"lat" => lat1, "lon" => lon1}, sides) |> maybe_put_pos_for_side(:pos2, %{"lat" => lat2, "lon" => lon2}, sides) |> Ecto.Changeset.put_change(:distance_km, distance) |> reset_all_enrichment_statuses() _ -> Ecto.Changeset.add_error(changeset, :grid1, "could not resolve grid to coordinates") end end defp maybe_put_pos_for_side(changeset, :pos1, pos, sides) when sides in [:side1, :both], do: Ecto.Changeset.put_change(changeset, :pos1, pos) defp maybe_put_pos_for_side(changeset, :pos2, pos, sides) when sides in [:side2, :both], do: Ecto.Changeset.put_change(changeset, :pos2, pos) defp maybe_put_pos_for_side(changeset, _field, _pos, _sides), do: changeset defp reset_all_enrichment_statuses(changeset) do changeset |> Ecto.Changeset.put_change(:hrrr_status, :pending) |> Ecto.Changeset.put_change(:weather_status, :pending) |> Ecto.Changeset.put_change(:terrain_status, :pending) |> Ecto.Changeset.put_change(:iemre_status, :pending) end # ── Contact edits ── @spec create_contact_edit(Contact.t(), User.t(), map()) :: {:ok, ContactEdit.t()} | {:error, Ecto.Changeset.t()} def create_contact_edit(%Contact{} = contact, user, proposed_changes) when is_map(proposed_changes) do normalized = normalize_proposed(proposed_changes) diffed = diff_against_contact(contact, normalized) attrs = %{contact_id: contact.id, user_id: user.id, proposed_changes: diffed} %ContactEdit{} |> ContactEdit.changeset(attrs) |> Repo.insert() end @doc false @spec normalize_proposed(map()) :: map() def normalize_proposed(changes) do changes |> Map.take(@editable_proposed_keys) |> normalize_string_field("station1") |> normalize_string_field("station2") |> normalize_string_field("grid1") |> normalize_string_field("grid2") |> normalize_string_field("mode") |> normalize_integer_field("height1_ft") |> normalize_integer_field("height2_ft") |> normalize_boolean_field("private") |> normalize_timestamp_field("qso_timestamp") end defp normalize_string_field(map, key) do case Map.get(map, key) do nil -> map val when is_binary(val) -> Map.put(map, key, val |> String.trim() |> String.upcase()) val -> Map.put(map, key, val) end end defp normalize_integer_field(map, key) do case Map.get(map, key, :not_provided) do :not_provided -> map nil -> map val when is_integer(val) -> map "" -> Map.delete(map, key) val when is_binary(val) -> case Integer.parse(String.trim(val)) do {int, ""} -> Map.put(map, key, int) _ -> map end _ -> map end end defp normalize_boolean_field(map, key) do case Map.get(map, key, :not_provided) do :not_provided -> map nil -> map val when is_boolean(val) -> Map.put(map, key, val) "true" -> Map.put(map, key, true) "false" -> Map.put(map, key, false) _ -> map end end defp normalize_timestamp_field(map, key) do case Map.get(map, key, :not_provided) do :not_provided -> map nil -> Map.delete(map, key) "" -> Map.delete(map, key) %DateTime{} = dt -> Map.put(map, key, dt) val when is_binary(val) -> put_or_drop_timestamp(map, key, parse_timestamp(val)) _ -> Map.delete(map, key) end end defp put_or_drop_timestamp(map, key, nil), do: Map.delete(map, key) defp put_or_drop_timestamp(map, key, %DateTime{} = dt), do: Map.put(map, key, dt) defp parse_timestamp(val) do trimmed = String.trim(val) iso = trimmed |> String.replace(" ", "T") |> ensure_seconds() |> String.trim_trailing("Z") case NaiveDateTime.from_iso8601(iso) do {:ok, ndt} -> DateTime.from_naive!(ndt, "Etc/UTC") _ -> nil end end defp ensure_seconds(s) do case Regex.run(~r/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})(Z?)$/, s) do [_, prefix, tail] -> prefix <> ":00" <> tail _ -> s end end @doc false @spec diff_against_contact(Contact.t(), map()) :: map() def diff_against_contact(contact, proposed) do Enum.reduce(proposed, %{}, fn {key, new_val}, acc -> current = current_value(contact, key) if values_equal?(current, new_val), do: acc, else: Map.put(acc, key, new_val) end) end defp current_value(contact, "station1"), do: contact.station1 defp current_value(contact, "station2"), do: contact.station2 defp current_value(contact, "grid1"), do: contact.grid1 defp current_value(contact, "grid2"), do: contact.grid2 defp current_value(contact, "mode"), do: contact.mode defp current_value(contact, "band"), do: if(contact.band, do: Decimal.to_integer(contact.band)) defp current_value(contact, "qso_timestamp"), do: contact.qso_timestamp defp current_value(contact, "height1_ft"), do: contact.height1_ft defp current_value(contact, "height2_ft"), do: contact.height2_ft defp current_value(contact, "private"), do: contact.private defp current_value(_contact, _key), do: nil defp values_equal?(a, b) when is_binary(a) and is_binary(b), do: String.upcase(a) == String.upcase(b) defp values_equal?(a, b) when is_integer(a), do: a == to_integer(b) defp values_equal?(a, b) when is_integer(b), do: to_integer(a) == b defp values_equal?(a, b), do: a == b defp to_integer(v) when is_integer(v), do: v defp to_integer(v) when is_binary(v), do: String.to_integer(v) defp to_integer(%Decimal{} = v), do: Decimal.to_integer(v) defp to_integer(v), do: v # ── Pending edits ── @spec list_pending_edits() :: [ContactEdit.t()] def list_pending_edits do ContactEdit |> where([e], e.status == :pending) |> order_by([e], desc: e.inserted_at) |> preload([:user, :contact]) |> Repo.all() end @spec pending_edits_query() :: Ecto.Query.t() def pending_edits_query do from e in ContactEdit, as: :resource, where: e.status == :pending, preload: [:user, :contact] end @spec list_contact_edits(Ecto.UUID.t()) :: [ContactEdit.t()] def list_contact_edits(contact_id) do ContactEdit |> where([e], e.contact_id == ^contact_id) |> order_by([e], desc: e.inserted_at) |> preload([:user, :reviewed_by]) |> Repo.all() end @spec pending_edit_count() :: non_neg_integer() def pending_edit_count, do: ContactEdit |> where([e], e.status == :pending) |> Repo.aggregate(:count) @spec pending_edit_for_user(Ecto.UUID.t(), Ecto.UUID.t()) :: ContactEdit.t() | nil def pending_edit_for_user(contact_id, user_id) do ContactEdit |> where([e], e.contact_id == ^contact_id and e.user_id == ^user_id and e.status == :pending) |> preload([:user, :contact]) |> Repo.one() end @spec get_contact_edit(Ecto.UUID.t()) :: ContactEdit.t() | nil def get_contact_edit(id), do: ContactEdit |> preload([:user, :contact, :reviewed_by]) |> Repo.get(id) # ── Edit approval ── @spec approve_edit(ContactEdit.t(), User.t(), String.t() | nil) :: {:ok, ContactEdit.t()} | {:error, any()} def approve_edit(%ContactEdit{status: :pending} = edit, admin, note) do Repo.transaction(fn -> case Repo.get(Contact, edit.contact_id) do nil -> Repo.rollback(:contact_deleted) contact -> {:ok, approved} = edit |> ContactEdit.review_changeset(%{ status: :approved, admin_note: note, reviewed_by_id: admin.id, reviewed_at: DateTime.truncate(DateTime.utc_now(), :second) }) |> Repo.update() _ = apply_edit_to_contact(contact, edit.proposed_changes) Repo.preload(approved, [:user, :contact, :reviewed_by]) end end) end @spec reject_edit(ContactEdit.t(), User.t(), String.t() | nil) :: {:ok, ContactEdit.t()} | {:error, Ecto.Changeset.t()} def reject_edit(%ContactEdit{status: :pending} = edit, admin, note) do edit |> ContactEdit.review_changeset(%{ status: :rejected, admin_note: note, reviewed_by_id: admin.id, reviewed_at: DateTime.truncate(DateTime.utc_now(), :second) }) |> Repo.update() end # ── Owner edit ── @spec apply_owner_edit(Contact.t(), User.t(), map()) :: {:ok, Contact.t()} | {:error, :not_owner | :no_changes} def apply_owner_edit(%Contact{} = contact, %User{} = user, proposed_changes) when is_map(proposed_changes) do if Contacts.owner?(contact, user) do diffed = diff_against_contact(contact, normalize_proposed(proposed_changes)) if diffed == %{}, do: {:error, :no_changes}, else: {:ok, apply_edit_to_contact(contact, diffed)} else {:error, :not_owner} end end @doc "Apply proposed changes directly to a contact (admin use)." @spec apply_edit_to_contact(Contact.t(), map()) :: Contact.t() def apply_edit_to_contact(contact, proposed_changes) do changes = build_contact_changes(contact, proposed_changes) changes = maybe_recompute_positions(changes, contact, proposed_changes) changes = maybe_reset_terrain_for_heights(changes, proposed_changes) updated = contact |> Ecto.Changeset.change(changes) |> Repo.update!() maybe_enqueue_terrain_for_heights(updated, proposed_changes) Contacts.invalidate_contact_map_caches() updated end defp maybe_enqueue_terrain_for_heights(contact, proposed) do if Map.has_key?(proposed, "height1_ft") or Map.has_key?(proposed, "height2_ft") do ContactWeatherEnqueueWorker.enqueue_for_contact(contact, [:terrain]) end :ok end defp maybe_reset_terrain_for_heights(changes, proposed) do if Map.has_key?(proposed, "height1_ft") or Map.has_key?(proposed, "height2_ft"), do: Map.put(changes, :terrain_status, :pending), else: changes end defp maybe_recompute_positions(changes, contact, proposed) do needs_recompute = Map.has_key?(proposed, "grid1") or Map.has_key?(proposed, "grid2") or Map.has_key?(proposed, "band") or Map.has_key?(proposed, "qso_timestamp") if needs_recompute, do: recompute_positions(changes, contact, proposed), else: changes end defp recompute_positions(changes, contact, proposed) do pos1 = resolve_grid_position(Map.get(proposed, "grid1", contact.grid1)) || contact.pos1 pos2 = resolve_grid_position(Map.get(proposed, "grid2", contact.grid2)) || contact.pos2 distance = compute_distance(pos1, pos2) changes |> Map.put(:pos1, pos1) |> Map.put(:pos2, pos2) |> Map.put(:distance_km, distance) |> Map.put(:hrrr_status, :pending) |> Map.put(:weather_status, :pending) |> Map.put(:terrain_status, :pending) |> Map.put(:iemre_status, :pending) end defp compute_distance(pos1, pos2) when not is_nil(pos1) and not is_nil(pos2), do: pos1["lat"] |> Contacts.haversine_km(pos1["lon"], pos2["lat"], pos2["lon"]) |> round() |> Decimal.new() defp compute_distance(_, _), do: nil defp build_contact_changes(_contact, proposed) do Enum.reduce(proposed, %{}, fn {"band", val}, acc -> Map.put(acc, :band, Decimal.new(to_string(val))) {"qso_timestamp", val}, acc when is_binary(val) -> {:ok, dt, _} = DateTime.from_iso8601(val) Map.put(acc, :qso_timestamp, dt) {"qso_timestamp", %DateTime{} = dt}, acc -> Map.put(acc, :qso_timestamp, dt) {key, val}, acc -> Map.put(acc, String.to_existing_atom(key), val) end) end defp resolve_grid_position(nil), do: nil defp resolve_grid_position(grid) do case Maidenhead.to_latlon(grid) do {:ok, {lat, lon}} -> %{"lat" => lat, "lon" => lon} _ -> nil end end end