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.
569 lines
18 KiB
Elixir
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
|