prop/lib/microwaveprop/radio.ex
Graham McIntire a66d3094ca Add contact edit approval system with admin review queue
Registered users can suggest edits to any contact's core fields
(callsigns, grids, band, mode, timestamp). Edits enter an admin
approval queue with field-by-field diff view. On approve, changes
are applied and enrichment re-enqueued if grids/band changed.
Users receive email notification on approve or reject.

Also updates dependabot.yml for mix ecosystem.
2026-04-11 16:15:49 -05:00

569 lines
18 KiB
Elixir

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