defmodule Microwaveprop.Radio do @moduledoc false import Ecto.Query alias Microwaveprop.Radio.Contact alias Microwaveprop.Radio.ContactEdit alias Microwaveprop.Radio.Maidenhead alias Microwaveprop.Repo @per_page 20 @sortable_fields ~w(station1 station2 band mode distance_km qso_timestamp inserted_at)a def list_contacts(opts \\ []) do page = max(Keyword.get(opts, :page, 1), 1) offset = (page - 1) * @per_page search = Keyword.get(opts, :search) {sort_field, sort_dir} = sort_opts(opts) base_query = maybe_search(Contact, search) total_entries = Repo.aggregate(base_query, :count) total_pages = max(ceil(total_entries / @per_page), 1) entries = base_query |> order_by([q], [{^sort_dir, field(q, ^sort_field)}, {^sort_dir, q.id}]) |> limit(^@per_page) |> offset(^offset) |> Repo.all() |> Repo.preload(:user) %{ entries: entries, grouped_entries: group_reciprocals(entries), page: page, total_pages: total_pages, total_entries: total_entries } end @doc """ Groups reciprocal contacts (same station pair swapped, same band, same timestamp). Returns a list of `{primary, reciprocals}` tuples where reciprocals is a (possibly empty) list of matching contacts. """ def group_reciprocals(contacts) do {groups, _seen_ids} = Enum.reduce(contacts, {[], MapSet.new()}, fn contact, {groups, seen} -> if MapSet.member?(seen, contact.id) do {groups, seen} else reciprocals = Enum.filter(contacts, fn other -> other.id != contact.id and not MapSet.member?(seen, other.id) and other.band == contact.band and reciprocal_pair?(contact, other) end) new_seen = Enum.reduce(reciprocals, MapSet.put(seen, contact.id), &MapSet.put(&2, &1.id)) {[{contact, reciprocals} | groups], new_seen} end end) Enum.reverse(groups) end defp reciprocal_pair?(a, b) do same_stations = (a.station1 == b.station2 and a.station2 == b.station1) or (a.station1 == b.station1 and a.station2 == b.station2) same_stations and same_hour?(a.qso_timestamp, b.qso_timestamp) end defp same_hour?(t1, t2) do t1 |> NaiveDateTime.truncate(:second) |> Map.take([:year, :month, :day, :hour]) == t2 |> NaiveDateTime.truncate(:second) |> Map.take([:year, :month, :day, :hour]) end defp maybe_search(query, nil), do: query defp maybe_search(query, ""), do: query defp maybe_search(query, search) do terms = search |> String.split() |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")) case terms do [a, b] -> pa = "%" <> String.upcase(a) <> "%" pb = "%" <> String.upcase(b) <> "%" where( query, [q], (ilike(q.station1, ^pa) and ilike(q.station2, ^pb)) or (ilike(q.station1, ^pb) and ilike(q.station2, ^pa)) ) _ -> pattern = "%" <> String.upcase(search) <> "%" where(query, [q], ilike(q.station1, ^pattern) or ilike(q.station2, ^pattern)) end end defp sort_opts(opts) do sort_by = Keyword.get(opts, :sort_by, :qso_timestamp) sort_order = Keyword.get(opts, :sort_order, :desc) field = if sort_by in @sortable_fields, do: sort_by, else: :qso_timestamp dir = if sort_order in [:asc, :desc], do: sort_order, else: :desc {field, dir} end # ── Enrichment status queries ──────────────────────────────────── @enrichable_statuses [:pending, :failed] def contacts_needing_enrichment(field, limit \\ 500, extra_filter \\ nil) do query = Contact |> where([q], field(q, ^field) in ^@enrichable_statuses and not is_nil(q.pos1)) |> order_by([q], asc: q.qso_timestamp) |> limit(^limit) query = if extra_filter, do: extra_filter.(query), else: query Repo.all(query) end def unprocessed_contacts(limit \\ 500), do: contacts_needing_enrichment(:weather_status, limit) def unprocessed_hrrr_contacts(limit \\ 500), do: contacts_needing_enrichment(:hrrr_status, limit) def unprocessed_terrain_contacts(limit \\ 500), do: contacts_needing_enrichment(:terrain_status, limit, &where(&1, [q], not is_nil(q.pos2))) def unprocessed_iemre_contacts(limit \\ 500), do: contacts_needing_enrichment(:iemre_status, limit) def set_enrichment_status!(ids, field, status) do Contact |> where([q], q.id in ^ids and field(q, ^field) != ^status) |> Repo.update_all(set: [{field, status}]) end def mark_weather_queued!(ids), do: set_enrichment_status!(ids, :weather_status, :queued) def mark_hrrr_queued!(ids), do: set_enrichment_status!(ids, :hrrr_status, :queued) def mark_terrain_queued!(ids), do: set_enrichment_status!(ids, :terrain_status, :queued) def mark_iemre_queued!(ids), do: set_enrichment_status!(ids, :iemre_status, :queued) @doc """ Returns a list of {lat, lon} points along the contact path: pos1, midpoint, pos2. Returns [pos1] if pos2 is nil, or [] if pos1 is nil. """ def contact_path_points(%{pos1: nil}), do: [] def contact_path_points(%{pos1: pos1, pos2: nil}) do lat = pos1["lat"] lon = pos1["lon"] || pos1["lng"] if lat && lon, do: [{lat, lon}], else: [] end def contact_path_points(%{pos1: pos1, pos2: pos2}) do lat1 = pos1["lat"] lon1 = pos1["lon"] || pos1["lng"] lat2 = pos2["lat"] lon2 = pos2["lon"] || pos2["lng"] cond do lat1 && lon1 && lat2 && lon2 -> mid_lat = (lat1 + lat2) / 2 mid_lon = (lon1 + lon2) / 2 [{lat1, lon1}, {mid_lat, mid_lon}, {lat2, lon2}] lat1 && lon1 -> [{lat1, lon1}] true -> [] end end @earth_radius_km 6371.0 def haversine_km(lat1, lon1, lat2, lon2) do dlat = deg_to_rad(lat2 - lat1) dlon = deg_to_rad(lon2 - lon1) rlat1 = deg_to_rad(lat1) rlat2 = deg_to_rad(lat2) a = :math.sin(dlat / 2) ** 2 + :math.cos(rlat1) * :math.cos(rlat2) * :math.sin(dlon / 2) ** 2 2 * @earth_radius_km * :math.asin(:math.sqrt(a)) end def backfill_distances(contacts) do updates = for contact <- contacts, is_nil(contact.distance_km) && contact.pos1 && contact.pos2, lat1 = contact.pos1["lat"], lon1 = contact.pos1["lon"] || contact.pos1["lng"], lat2 = contact.pos2["lat"], lon2 = contact.pos2["lon"] || contact.pos2["lng"], lat1 && lon1 && lat2 && lon2 do dist = lat1 |> haversine_km(lon1, lat2, lon2) |> round() {contact.id, dist} end if updates != [] do # Build a single UPDATE ... FROM (VALUES ...) statement # Ecto.UUID.dump!/1 converts string UUIDs to the 16-byte binary Postgrex expects {values_sql, params} = updates |> Enum.with_index() |> Enum.reduce({"", []}, fn {{id, dist}, i}, {sql, params} -> frag = "($#{i * 2 + 1}::uuid, $#{i * 2 + 2}::numeric)" sep = if sql == "", do: "", else: ", " {sql <> sep <> frag, params ++ [Ecto.UUID.dump!(id), dist]} end) Repo.query!( "UPDATE contacts AS c SET distance_km = v.dist FROM (VALUES #{values_sql}) AS v(id, dist) WHERE c.id = v.id", params ) end end defp deg_to_rad(deg), do: deg * :math.pi() / 180 def get_contact!(id) do Repo.get!(Contact, id) end def toggle_flagged_invalid!(contact) do contact |> Ecto.Changeset.change(flagged_invalid: !contact.flagged_invalid) |> Repo.update!() end def change_contact(contact, attrs \\ %{}) do Contact.submission_changeset(contact, attrs) end @doc """ Computes pos1/pos2/distance_km from grid squares if missing. Returns the updated contact, or the original if already computed or grids are missing. """ def ensure_positions!(contact) do needs_pos1 = is_nil(contact.pos1) && contact.grid1 needs_pos2 = is_nil(contact.pos2) && contact.grid2 if needs_pos1 || needs_pos2 do pos1 = contact.pos1 || latlon_from_grid(contact.grid1) pos2 = contact.pos2 || latlon_from_grid(contact.grid2) distance = if pos1 && pos2 do lon1 = pos1["lon"] || pos1["lng"] lon2 = pos2["lon"] || pos2["lng"] pos1["lat"] |> haversine_km(lon1, pos2["lat"], lon2) |> round() |> Decimal.new() else contact.distance_km end changes = %{} |> then(fn c -> if pos1 && is_nil(contact.pos1), do: Map.put(c, :pos1, pos1), else: c end) |> then(fn c -> if pos2 && is_nil(contact.pos2), do: Map.put(c, :pos2, pos2), else: c end) |> then(fn c -> if distance && is_nil(contact.distance_km), do: Map.put(c, :distance_km, distance), else: c end) if changes == %{} do contact else # Reset unavailable statuses to pending now that positions exist new_pos1 = Map.get(changes, :pos1) new_pos2 = Map.get(changes, :pos2) changes = if new_pos1 do changes |> maybe_reset_status(:hrrr_status, contact.hrrr_status) |> maybe_reset_status(:weather_status, contact.weather_status) |> maybe_reset_status(:iemre_status, contact.iemre_status) else changes end changes = if new_pos1 && (new_pos2 || contact.pos2) do maybe_reset_status(changes, :terrain_status, contact.terrain_status) else changes end contact |> Ecto.Changeset.change(changes) |> Repo.update!() end else contact end end defp maybe_reset_status(changes, field, :unavailable), do: Map.put(changes, field, :pending) defp maybe_reset_status(changes, _field, _status), do: changes defp latlon_from_grid(nil), do: nil defp latlon_from_grid(grid) do case Maidenhead.to_latlon(grid) do {:ok, {lat, lon}} -> %{"lat" => lat, "lon" => lon} _ -> # Try truncating to nearest valid even length (e.g. "FN03c" → "FN03") truncated = String.slice(grid, 0, div(String.length(grid), 2) * 2) case Maidenhead.to_latlon(truncated) do {:ok, {lat, lon}} -> %{"lat" => lat, "lon" => lon} _ -> nil end end end def create_contact(attrs) do changeset = Contact.submission_changeset(%Contact{}, attrs) if changeset.valid? do grid1 = Ecto.Changeset.get_change(changeset, :grid1) grid2 = Ecto.Changeset.get_change(changeset, :grid2) case {Maidenhead.to_latlon(grid1), Maidenhead.to_latlon(grid2)} do {{:ok, {lat1, lon1}}, {:ok, {lat2, lon2}}} -> distance = lat1 |> haversine_km(lon1, lat2, lon2) |> round() |> Decimal.new() changeset |> Ecto.Changeset.put_change(:pos1, %{"lat" => lat1, "lon" => lon1}) |> Ecto.Changeset.put_change(:pos2, %{"lat" => lat2, "lon" => lon2}) |> Ecto.Changeset.put_change(:distance_km, distance) |> Ecto.Changeset.put_change(:user_submitted, true) |> Repo.insert() _ -> changeset = Ecto.Changeset.add_error(changeset, :grid1, "could not resolve grid to coordinates") {:error, %{changeset | action: :insert}} end else {:error, %{changeset | action: :insert}} end end # ── Contact Edits ─────────────────────────────────────────────── @doc """ Create a proposed edit for a contact. Only stores fields that actually differ from the contact's current values. Normalizes callsigns and grids to uppercase. """ def create_contact_edit(%Contact{} = contact, user, proposed_changes) when is_map(proposed_changes) do # Normalize and diff against current values 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 defp normalize_proposed(changes) do changes |> normalize_string_field("station1") |> normalize_string_field("station2") |> normalize_string_field("grid1") |> normalize_string_field("grid2") |> normalize_string_field("mode") 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 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) 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) end defp current_value(contact, "qso_timestamp"), do: contact.qso_timestamp 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) end 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 def list_pending_edits do ContactEdit |> where([e], e.status == :pending) |> order_by([e], desc: e.inserted_at) |> preload([:user, :contact]) |> Repo.all() end 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 def pending_edit_count do ContactEdit |> where([e], e.status == :pending) |> Repo.aggregate(:count) end 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) |> Repo.one() end def get_contact_edit!(id) do ContactEdit |> preload([:user, :contact, :reviewed_by]) |> Repo.get!(id) end def approve_edit(%ContactEdit{status: :pending} = edit, admin, note) do Repo.transaction(fn -> # Mark edit as approved {: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 changes to contact contact = Repo.get!(Contact, edit.contact_id) apply_edit_to_contact(contact, edit.proposed_changes) Repo.preload(approved, [:user, :contact, :reviewed_by]) end) end 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 defp apply_edit_to_contact(contact, proposed_changes) do changes = build_contact_changes(contact, proposed_changes) grids_or_band_changed = Map.has_key?(proposed_changes, "grid1") or Map.has_key?(proposed_changes, "grid2") or Map.has_key?(proposed_changes, "band") changes = if grids_or_band_changed do # Recompute positions from grids new_grid1 = Map.get(proposed_changes, "grid1", contact.grid1) new_grid2 = Map.get(proposed_changes, "grid2", contact.grid2) pos1 = resolve_grid_position(new_grid1) || contact.pos1 pos2 = resolve_grid_position(new_grid2) || contact.pos2 distance = if pos1 && pos2 do lon1 = pos1["lon"] || pos1["lng"] lon2 = pos2["lon"] || pos2["lng"] pos1["lat"] |> haversine_km(lon1, pos2["lat"], lon2) |> round() |> Decimal.new() end 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) else changes end contact |> Ecto.Changeset.change(changes) |> Repo.update!() end 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