From a66d3094cacbf7e28885a5ad807fcfab3715caad Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 11 Apr 2026 16:14:49 -0500 Subject: [PATCH] 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. --- .github/dependabot.yml | 10 +- ...2026-04-11-contact-edit-approval-design.md | 64 +++++ lib/microwaveprop/propagation/rain_scatter.ex | 4 +- lib/microwaveprop/radio.ex | 212 +++++++++++++++ lib/microwaveprop/radio/contact_edit.ex | 140 ++++++++++ lib/microwaveprop/radio/edit_notifier.ex | 98 +++++++ .../weather/hrrr_native_client.ex | 3 +- lib/microwaveprop_web/components/layouts.ex | 7 + .../live/admin/contact_edit_live.ex | 240 +++++++++++++++++ .../live/beacon_live/form.ex | 2 +- .../live/beacon_live/index.ex | 8 +- .../live/contact_live/show.ex | 134 +++++++++- .../live/contact_map_live.ex | 4 +- lib/microwaveprop_web/live/map_live.ex | 5 +- lib/microwaveprop_web/router.ex | 1 + .../20260411210308_create_contact_edits.exs | 25 ++ .../microwaveprop/radio/contact_edit_test.exs | 248 ++++++++++++++++++ 17 files changed, 1189 insertions(+), 16 deletions(-) create mode 100644 docs/plans/2026-04-11-contact-edit-approval-design.md create mode 100644 lib/microwaveprop/radio/contact_edit.ex create mode 100644 lib/microwaveprop/radio/edit_notifier.ex create mode 100644 lib/microwaveprop_web/live/admin/contact_edit_live.ex create mode 100644 priv/repo/migrations/20260411210308_create_contact_edits.exs create mode 100644 test/microwaveprop/radio/contact_edit_test.exs diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c77d0079..1878f9f8 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,12 +1,6 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file - version: 2 updates: - - package-ecosystem: "" # See documentation for possible values - directory: "/" # Location of package manifests + - package-ecosystem: "mix" + directory: "/" schedule: interval: "weekly" - diff --git a/docs/plans/2026-04-11-contact-edit-approval-design.md b/docs/plans/2026-04-11-contact-edit-approval-design.md new file mode 100644 index 00000000..a59b4cae --- /dev/null +++ b/docs/plans/2026-04-11-contact-edit-approval-design.md @@ -0,0 +1,64 @@ +# Contact Edit Approval System — Design + +## Goal + +Allow registered users to suggest corrections to any contact's core fields. Edits enter an admin approval queue with field-by-field diff view. Users receive email notification when their edit is approved or rejected. + +## Data Model + +New `contact_edits` table: + +| Field | Type | Purpose | +|-------|------|---------| +| id | binary_id | PK | +| contact_id | references contacts | Target contact | +| user_id | references users | Submitter | +| proposed_changes | :map (JSONB) | Only changed fields, e.g. `%{"grid1" => "EM13kk"}` | +| status | Ecto.Enum (:pending, :approved, :rejected) | Review state | +| admin_note | :string | Optional admin explanation | +| reviewed_by_id | references users | Admin who acted | +| reviewed_at | :utc_datetime | When reviewed | +| timestamps | | inserted_at = submission time | + +Schema: `ContactEdit` in `lib/microwaveprop/radio/contact_edit.ex`. + +## Editable Fields + +station1, station2, grid1, grid2, band, mode, qso_timestamp. + +Not editable: pos1/pos2, distance_km, enrichment statuses (computed fields). + +## User Flow + +- Contact detail page (`/contacts/:id`): logged-in users see "Suggest Edit" button. +- Clicking opens inline form pre-filled with current values. +- On submit: backend diffs against current contact, stores only changed fields. +- Validation: same rules as submission_changeset (callsign format, grid format, valid band/mode). +- Reject if nothing changed. +- Flash confirmation on success. Badge shows "pending edit" if user has one. + +## Admin Flow + +- `/admin/contact-edits`: pending edits, newest first. +- Columns: contact link, submitter callsign, submitted at, fields changed. +- Review view: field-by-field diff table (current vs proposed), only changed fields. +- Optional note textarea, Approve/Reject buttons. +- Approve: update contact fields, re-enqueue enrichment if grid1/grid2/band changed. +- Reject: no contact changes. +- Nav badge shows pending count. + +## Multiple Edits + +Independent queue — multiple edits per contact allowed. Admin reviews each separately. + +## Email Notifications + +Plain text via Swoosh (existing mailer). Sent on approve or reject. Includes: +- Contact identifier (station1 <-> station2, band, date) +- What was proposed +- Decision (approved/rejected) +- Admin note if provided + +## Re-enrichment + +On approve, if any of grid1/grid2/band changed: recalculate pos1/pos2 from grids, clear distance_km (will be recomputed), re-enqueue weather/HRRR/terrain/IEMRE jobs. diff --git a/lib/microwaveprop/propagation/rain_scatter.ex b/lib/microwaveprop/propagation/rain_scatter.ex index ffcda330..510a7f54 100644 --- a/lib/microwaveprop/propagation/rain_scatter.ex +++ b/lib/microwaveprop/propagation/rain_scatter.ex @@ -68,7 +68,9 @@ defmodule Microwaveprop.Propagation.RainScatter do """ def classify(scatter_cells) do case scatter_cells do - [] -> :none + [] -> + :none + [best | _] -> cond do best.scatter_db >= -10 -> :excellent diff --git a/lib/microwaveprop/radio.ex b/lib/microwaveprop/radio.ex index b7ecdb7f..a7ab195b 100644 --- a/lib/microwaveprop/radio.ex +++ b/lib/microwaveprop/radio.ex @@ -4,6 +4,7 @@ defmodule Microwaveprop.Radio do import Ecto.Query alias Microwaveprop.Radio.Contact + alias Microwaveprop.Radio.ContactEdit alias Microwaveprop.Radio.Maidenhead alias Microwaveprop.Repo @@ -354,4 +355,215 @@ defmodule Microwaveprop.Radio do {: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 diff --git a/lib/microwaveprop/radio/contact_edit.ex b/lib/microwaveprop/radio/contact_edit.ex new file mode 100644 index 00000000..a5794bad --- /dev/null +++ b/lib/microwaveprop/radio/contact_edit.ex @@ -0,0 +1,140 @@ +defmodule Microwaveprop.Radio.ContactEdit do + @moduledoc false + use Ecto.Schema + + import Ecto.Changeset + + alias Microwaveprop.Accounts.User + alias Microwaveprop.Radio.Contact + alias Microwaveprop.Radio.Maidenhead + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + + @allowed_modes ~w(CW SSB FM FT8 FT4) + @allowed_bands ~w(1296 2304 3456 5760 10000 24000 47000 68000 75000 122000 134000 241000) + + schema "contact_edits" do + belongs_to :contact, Contact + belongs_to :user, User + belongs_to :reviewed_by, User + + field :proposed_changes, :map + field :status, Ecto.Enum, values: [:pending, :approved, :rejected], default: :pending + field :admin_note, :string + field :reviewed_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + @editable_fields ~w(station1 station2 grid1 grid2 band mode qso_timestamp) + + def changeset(edit, attrs) do + edit + |> cast(attrs, [:contact_id, :user_id, :proposed_changes]) + |> validate_required([:contact_id, :user_id, :proposed_changes]) + |> validate_proposed_changes() + |> foreign_key_constraint(:contact_id) + |> foreign_key_constraint(:user_id) + end + + def review_changeset(edit, attrs) do + edit + |> cast(attrs, [:status, :admin_note, :reviewed_by_id, :reviewed_at]) + |> validate_required([:status, :reviewed_by_id, :reviewed_at]) + |> validate_inclusion(:status, [:approved, :rejected]) + end + + defp validate_proposed_changes(changeset) do + case get_field(changeset, :proposed_changes) do + nil -> + changeset + + changes when changes == %{} -> + add_error(changeset, :proposed_changes, "must contain at least one change") + + changes when is_map(changes) -> + keys = Map.keys(changes) + invalid_keys = Enum.reject(keys, &(&1 in @editable_fields)) + + changeset = + if invalid_keys == [] do + changeset + else + add_error(changeset, :proposed_changes, "contains invalid fields: #{Enum.join(invalid_keys, ", ")}") + end + + Enum.reduce(keys, changeset, &validate_field_value/2) + + _ -> + add_error(changeset, :proposed_changes, "must be a map") + end + end + + defp validate_field_value("station1", cs), do: validate_callsign_value(cs, "station1") + defp validate_field_value("station2", cs), do: validate_callsign_value(cs, "station2") + defp validate_field_value("grid1", cs), do: validate_grid_value(cs, "grid1") + defp validate_field_value("grid2", cs), do: validate_grid_value(cs, "grid2") + + defp validate_field_value("band", changeset) do + val = get_field(changeset, :proposed_changes)["band"] + + if to_string(val) in @allowed_bands do + changeset + else + add_error(changeset, :proposed_changes, "band is not a valid frequency") + end + end + + defp validate_field_value("mode", changeset) do + val = get_field(changeset, :proposed_changes)["mode"] + + if val in @allowed_modes do + changeset + else + add_error(changeset, :proposed_changes, "mode must be one of: #{Enum.join(@allowed_modes, ", ")}") + end + end + + defp validate_field_value("qso_timestamp", changeset) do + val = get_field(changeset, :proposed_changes)["qso_timestamp"] + + case val do + %DateTime{} -> + changeset + + s when is_binary(s) -> + case DateTime.from_iso8601(s) do + {:ok, _, _} -> changeset + _ -> add_error(changeset, :proposed_changes, "qso_timestamp is not a valid datetime") + end + + _ -> + add_error(changeset, :proposed_changes, "qso_timestamp is not a valid datetime") + end + end + + defp validate_field_value(_, changeset), do: changeset + + defp validate_callsign_value(changeset, field) do + val = get_field(changeset, :proposed_changes)[field] || "" + val = val |> String.trim() |> String.upcase() + + if Regex.match?(~r/^[A-Z0-9\/]+$/, val) do + changeset + else + add_error(changeset, :proposed_changes, "#{field} must contain only letters, digits, and /") + end + end + + defp validate_grid_value(changeset, field) do + val = get_field(changeset, :proposed_changes)[field] || "" + val = val |> String.trim() |> String.upcase() + + if Maidenhead.valid?(val) do + changeset + else + add_error(changeset, :proposed_changes, "#{field} is not a valid Maidenhead grid square") + end + end +end diff --git a/lib/microwaveprop/radio/edit_notifier.ex b/lib/microwaveprop/radio/edit_notifier.ex new file mode 100644 index 00000000..14648718 --- /dev/null +++ b/lib/microwaveprop/radio/edit_notifier.ex @@ -0,0 +1,98 @@ +defmodule Microwaveprop.Radio.EditNotifier do + @moduledoc false + import Swoosh.Email + + alias Microwaveprop.Mailer + + @default_from {"NTMS Propagation", "noreply@mcintire.me"} + + def deliver_edit_approved(edit) do + edit = Microwaveprop.Repo.preload(edit, [:user, :contact]) + contact = edit.contact + + body = """ + ============================== + + Hi #{edit.user.callsign}, + + Your suggested edit to the contact #{contact.station1} <-> #{contact.station2} + (#{format_band(contact.band)}, #{format_ts(contact.qso_timestamp)}) has been APPROVED + and applied. + + Changes applied: + #{format_changes(edit.proposed_changes)} + #{format_note(edit.admin_note)} + View the contact: #{url()}/contacts/#{contact.id} + + ============================== + """ + + deliver(edit.user.email, "Your contact edit was approved", body) + end + + def deliver_edit_rejected(edit) do + edit = Microwaveprop.Repo.preload(edit, [:user, :contact]) + contact = edit.contact + + body = """ + ============================== + + Hi #{edit.user.callsign}, + + Your suggested edit to the contact #{contact.station1} <-> #{contact.station2} + (#{format_band(contact.band)}, #{format_ts(contact.qso_timestamp)}) has been REJECTED. + + Proposed changes: + #{format_changes(edit.proposed_changes)} + #{format_note(edit.admin_note)} + View the contact: #{url()}/contacts/#{contact.id} + + ============================== + """ + + deliver(edit.user.email, "Your contact edit was rejected", body) + end + + defp deliver(recipient, subject, body) do + email = + new() + |> to(recipient) + |> from(from_address()) + |> subject(subject) + |> text_body(body) + + with {:ok, _metadata} <- Mailer.deliver(email) do + {:ok, email} + end + end + + defp from_address do + case System.get_env("EMAIL_FROM") do + nil -> @default_from + "" -> @default_from + addr -> {"NTMS Propagation", addr} + end + end + + defp url do + MicrowavepropWeb.Endpoint.url() + end + + defp format_band(nil), do: "?" + + defp format_band(band) do + mhz = Decimal.to_integer(band) + if mhz >= 1000, do: "#{div(mhz, 1000)} GHz", else: "#{mhz} MHz" + end + + defp format_ts(nil), do: "?" + defp format_ts(ts), do: Calendar.strftime(ts, "%Y-%m-%d %H:%M UTC") + + defp format_changes(changes) do + Enum.map_join(changes, "\n", fn {field, value} -> " - #{field}: #{value}" end) + end + + defp format_note(nil), do: "" + defp format_note(""), do: "" + defp format_note(note), do: "\nAdmin note: #{note}\n" +end diff --git a/lib/microwaveprop/weather/hrrr_native_client.ex b/lib/microwaveprop/weather/hrrr_native_client.ex index 2373a498..228adab5 100644 --- a/lib/microwaveprop/weather/hrrr_native_client.ex +++ b/lib/microwaveprop/weather/hrrr_native_client.ex @@ -221,7 +221,8 @@ defmodule Microwaveprop.Weather.HrrrNativeClient do defp min_m_gradient(%{heights_m: heights, temp_k: temps, spfh: spfhs, pressure_pa: pressures}) do # Compute modified refractivity M at each level, find minimum dM/dh ms = - Enum.zip([heights, temps, spfhs, pressures]) + [heights, temps, spfhs, pressures] + |> Enum.zip() |> Enum.map(fn {h, t, q, p} -> # N = 77.6 * P/T + 3.73e5 * e/T^2, where e = q*P/(0.622 + 0.378*q) q = max(q || 0.0, 1.0e-8) diff --git a/lib/microwaveprop_web/components/layouts.ex b/lib/microwaveprop_web/components/layouts.ex index 6320e82d..65ef6450 100644 --- a/lib/microwaveprop_web/components/layouts.ex +++ b/lib/microwaveprop_web/components/layouts.ex @@ -54,6 +54,13 @@ defmodule MicrowavepropWeb.Layouts do > Users + <.link + :if={@current_scope && @current_scope.user && @current_scope.user.is_admin} + navigate="/admin/contact-edits" + class="btn btn-ghost btn-sm" + > + Edits + <.link :if={@current_scope && @current_scope.user && @current_scope.user.is_admin} href="/admin/oban" diff --git a/lib/microwaveprop_web/live/admin/contact_edit_live.ex b/lib/microwaveprop_web/live/admin/contact_edit_live.ex new file mode 100644 index 00000000..d54a4e92 --- /dev/null +++ b/lib/microwaveprop_web/live/admin/contact_edit_live.ex @@ -0,0 +1,240 @@ +defmodule MicrowavepropWeb.Admin.ContactEditLive do + @moduledoc false + use MicrowavepropWeb, :live_view + + alias Microwaveprop.Radio + alias Microwaveprop.Radio.EditNotifier + + @impl true + def mount(_params, _session, socket) do + edits = Radio.list_pending_edits() + + {:ok, + assign(socket, + page_title: "Review Contact Edits", + edits: edits, + reviewing: nil, + admin_note: "" + )} + end + + @impl true + def handle_event("review", %{"id" => id}, socket) do + edit = Radio.get_contact_edit!(id) + {:noreply, assign(socket, reviewing: edit, admin_note: "")} + end + + def handle_event("cancel_review", _params, socket) do + {:noreply, assign(socket, reviewing: nil, admin_note: "")} + end + + def handle_event("update_note", %{"note" => note}, socket) do + {:noreply, assign(socket, admin_note: note)} + end + + def handle_event("approve", _params, socket) do + edit = socket.assigns.reviewing + admin = socket.assigns.current_scope.user + note = normalize_note(socket.assigns.admin_note) + + case Radio.approve_edit(edit, admin, note) do + {:ok, approved} -> + EditNotifier.deliver_edit_approved(approved) + + {:noreply, + socket + |> assign(reviewing: nil, admin_note: "", edits: Radio.list_pending_edits()) + |> put_flash(:info, "Edit approved and applied.")} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Failed to approve edit.")} + end + end + + def handle_event("reject", _params, socket) do + edit = socket.assigns.reviewing + admin = socket.assigns.current_scope.user + note = normalize_note(socket.assigns.admin_note) + + case Radio.reject_edit(edit, admin, note) do + {:ok, rejected} -> + EditNotifier.deliver_edit_rejected(rejected) + + {:noreply, + socket + |> assign(reviewing: nil, admin_note: "", edits: Radio.list_pending_edits()) + |> put_flash(:info, "Edit rejected.")} + + {:error, _} -> + {:noreply, put_flash(socket, :error, "Failed to reject edit.")} + end + end + + defp normalize_note(""), do: nil + defp normalize_note(note), do: String.trim(note) + + defp format_band(nil), do: "?" + + defp format_band(band) do + mhz = Decimal.to_integer(band) + if mhz >= 1000, do: "#{div(mhz, 1000)} GHz", else: "#{mhz} MHz" + end + + defp format_ts(nil), do: "?" + defp format_ts(ts), do: Calendar.strftime(ts, "%Y-%m-%d %H:%M UTC") + + defp changed_fields_summary(proposed_changes) do + proposed_changes + |> Map.keys() + |> Enum.sort() + |> Enum.join(", ") + end + + defp field_label("station1"), do: "Station 1" + defp field_label("station2"), do: "Station 2" + defp field_label("grid1"), do: "Grid 1" + defp field_label("grid2"), do: "Grid 2" + defp field_label("band"), do: "Band" + defp field_label("mode"), do: "Mode" + defp field_label("qso_timestamp"), do: "Timestamp" + defp field_label(other), do: other + + 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, "band"), do: if(contact.band, do: Decimal.to_string(contact.band)) + defp current_value(contact, "mode"), do: contact.mode + + defp current_value(contact, "qso_timestamp"), do: if(contact.qso_timestamp, do: format_ts(contact.qso_timestamp)) + + defp current_value(_contact, _), do: "?" + + defp format_proposed("band", val), do: to_string(val) + defp format_proposed("qso_timestamp", val) when is_binary(val), do: val + defp format_proposed(_field, val), do: to_string(val) + + @impl true + def render(assigns) do + ~H""" + + <.header> + Review Contact Edits + <:subtitle>{length(@edits)} pending + <:actions> + <.link navigate={~p"/admin/backfill"} class="btn btn-sm btn-ghost"> + <.icon name="hero-arrow-left" class="w-4 h-4" /> Admin + + + + + <%= if @reviewing do %> +
+
+

+ Review Edit from {@reviewing.user.callsign} +

+ +
+ +
+ Contact: + <.link navigate={~p"/contacts/#{@reviewing.contact.id}"} class="link link-primary"> + {@reviewing.contact.station1} / {@reviewing.contact.station2} + + · {format_band(@reviewing.contact.band)} · {format_ts( + @reviewing.contact.qso_timestamp + )} +
+ +
+ + + + + + + + + + <%= for {field, proposed_val} <- Enum.sort(@reviewing.proposed_changes) do %> + + + + + + <% end %> + +
FieldCurrentProposed
{field_label(field)}{current_value(@reviewing.contact, field)}{format_proposed(field, proposed_val)}
+
+ +
+ + +
+ +
+ + +
+
+ <% end %> + + <%= if @edits == [] do %> +
+ <.icon name="hero-check-circle" class="w-12 h-12 mx-auto mb-2" /> +

No pending edits to review.

+
+ <% else %> +
+ + + + + + + + + + + + <%= for edit <- @edits do %> + + + + + + + + <% end %> + +
ContactSubmitted ByFields ChangedSubmitted
+ <.link navigate={~p"/contacts/#{edit.contact.id}"} class="link link-primary"> + {edit.contact.station1} / {edit.contact.station2} + +
+ {format_band(edit.contact.band)} +
+
{edit.user.callsign}{changed_fields_summary(edit.proposed_changes)}{format_ts(edit.inserted_at)} + +
+
+ <% end %> +
+ """ + end +end diff --git a/lib/microwaveprop_web/live/beacon_live/form.ex b/lib/microwaveprop_web/live/beacon_live/form.ex index e6fe0541..10406e63 100644 --- a/lib/microwaveprop_web/live/beacon_live/form.ex +++ b/lib/microwaveprop_web/live/beacon_live/form.ex @@ -120,7 +120,7 @@ defmodule MicrowavepropWeb.BeaconLive.Form do v -> v end) |> Map.update("height_ft", nil, fn - v when is_binary(v) -> v |> String.replace(~r/\.0*$/, "") + v when is_binary(v) -> String.replace(v, ~r/\.0*$/, "") v -> v end) end diff --git a/lib/microwaveprop_web/live/beacon_live/index.ex b/lib/microwaveprop_web/live/beacon_live/index.ex index fb18aacc..16c57952 100644 --- a/lib/microwaveprop_web/live/beacon_live/index.ex +++ b/lib/microwaveprop_web/live/beacon_live/index.ex @@ -24,7 +24,9 @@ defmodule MicrowavepropWeb.BeaconLive.Index do rows={@streams.beacons} row_click={fn {_id, beacon} -> JS.navigate(~p"/beacons/#{beacon}") end} > - <:col :let={{_id, beacon}} label="Frequency (MHz)">{Beacon.format_freq(beacon.frequency_mhz)} + <:col :let={{_id, beacon}} label="Frequency (MHz)"> + {Beacon.format_freq(beacon.frequency_mhz)} + <:col :let={{_id, beacon}} label="Call">{beacon.callsign} <:col :let={{_id, beacon}} label="Grid">{beacon.grid} <:col :let={{_id, beacon}} label="Lat">{format_coord(beacon.lat)} @@ -65,7 +67,9 @@ defmodule MicrowavepropWeb.BeaconLive.Index do rows={@streams.pending} row_click={fn {_id, beacon} -> JS.navigate(~p"/beacons/#{beacon}") end} > - <:col :let={{_id, beacon}} label="Frequency (MHz)">{Beacon.format_freq(beacon.frequency_mhz)} + <:col :let={{_id, beacon}} label="Frequency (MHz)"> + {Beacon.format_freq(beacon.frequency_mhz)} + <:col :let={{_id, beacon}} label="Call">{beacon.callsign} <:col :let={{_id, beacon}} label="Grid">{beacon.grid} <:col :let={{_id, beacon}} label="EIRP (mW)">{Beacon.format_mw(beacon.power_mw)} diff --git a/lib/microwaveprop_web/live/contact_live/show.ex b/lib/microwaveprop_web/live/contact_live/show.ex index 7069fdf2..cb15aa8a 100644 --- a/lib/microwaveprop_web/live/contact_live/show.ex +++ b/lib/microwaveprop_web/live/contact_live/show.ex @@ -22,6 +22,9 @@ defmodule MicrowavepropWeb.ContactLive.Show do @enqueue_subnet {172, 56, 0, 0} @enqueue_mask 13 + @band_options BandConfig.band_options() + @mode_options ~w(CW SSB FM FT8 FT4) + @impl true def mount(%{"id" => id}, session, socket) do contact = id |> Radio.get_contact!() |> Radio.ensure_positions!() @@ -70,10 +73,28 @@ defmodule MicrowavepropWeb.ContactLive.Show do sounding_sort_by: "station_name", sounding_sort_order: "asc", queue_counts: fetch_queue_counts(), - expanded_soundings: MapSet.new() + expanded_soundings: MapSet.new(), + editing: false, + edit_form: nil, + pending_edit: load_pending_edit(contact, socket), + band_options: @band_options, + mode_options: @mode_options )} end + defp load_pending_edit(contact, socket) do + case socket.assigns do + %{current_scope: %{user: %{id: user_id}}} -> + Radio.pending_edit_for_user(contact.id, user_id) + + _ -> + nil + end + end + + defp current_user(%{current_scope: %{user: %{} = user}}), do: user + defp current_user(_), do: nil + @impl true def handle_event("sort", %{"field" => field, "table" => "obs"}, socket) do {sort_by, sort_order} = @@ -127,6 +148,58 @@ defmodule MicrowavepropWeb.ContactLive.Show do )} end + def handle_event("toggle_edit", _params, socket) do + if socket.assigns.editing do + {:noreply, assign(socket, editing: false, edit_form: nil)} + else + contact = socket.assigns.contact + + form_data = %{ + "station1" => contact.station1, + "station2" => contact.station2, + "grid1" => contact.grid1, + "grid2" => contact.grid2, + "band" => if(contact.band, do: Decimal.to_string(contact.band), else: ""), + "mode" => contact.mode, + "qso_timestamp" => + if(contact.qso_timestamp, do: Calendar.strftime(contact.qso_timestamp, "%Y-%m-%dT%H:%M"), else: "") + } + + {:noreply, assign(socket, editing: true, edit_form: to_form(form_data, as: "edit"))} + end + end + + def handle_event("validate_edit", %{"edit" => _params}, socket) do + {:noreply, socket} + end + + def handle_event("submit_edit", %{"edit" => params}, socket) do + user = current_user(socket.assigns) + + if is_nil(user) do + {:noreply, put_flash(socket, :error, "You must be logged in to suggest edits.")} + else + contact = socket.assigns.contact + + case Radio.create_contact_edit(contact, user, params) do + {:ok, _edit} -> + {:noreply, + socket + |> assign(editing: false, edit_form: nil, pending_edit: Radio.pending_edit_for_user(contact.id, user.id)) + |> put_flash(:info, "Edit submitted for review.")} + + {:error, changeset} -> + messages = + changeset + |> Ecto.Changeset.traverse_errors(fn {msg, _opts} -> msg end) + |> Enum.flat_map(fn {_field, msgs} -> msgs end) + |> Enum.join(", ") + + {:noreply, put_flash(socket, :error, "Could not submit edit: #{messages}")} + end + end + end + @impl true def handle_info({:terrain_ready, _contact_id}, socket) do contact = %{socket.assigns.contact | terrain_status: :complete} @@ -447,6 +520,18 @@ defmodule MicrowavepropWeb.ContactLive.Show do <% end %> <:actions> + <%= if current_user(assigns) do %> + + <% end %> + + + + <% end %> + <%= if @contact.pos1 && @contact.pos2 do %>

Locations approximate, in the center of the grid squares diff --git a/lib/microwaveprop_web/live/contact_map_live.ex b/lib/microwaveprop_web/live/contact_map_live.ex index 531e3c2b..2faf2feb 100644 --- a/lib/microwaveprop_web/live/contact_map_live.ex +++ b/lib/microwaveprop_web/live/contact_map_live.ex @@ -74,7 +74,9 @@ defmodule MicrowavepropWeb.ContactMapLive do

Contact Map
-
{Integer.to_string(@contact_count)} contacts
+
+ {Integer.to_string(@contact_count)} contacts +
""" end diff --git a/lib/microwaveprop_web/live/map_live.ex b/lib/microwaveprop_web/live/map_live.ex index cd34e2e1..7d2cb4e4 100644 --- a/lib/microwaveprop_web/live/map_live.ex +++ b/lib/microwaveprop_web/live/map_live.ex @@ -325,7 +325,10 @@ defmodule MicrowavepropWeb.MapLive do id="map-controls" class="md:hidden absolute top-2 left-12 z-[1000] flex flex-col gap-2 max-w-[calc(100vw-4rem)]" > -
+
NTMS diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex index fe526f6d..71aaf28c 100644 --- a/lib/microwaveprop_web/router.ex +++ b/lib/microwaveprop_web/router.ex @@ -55,6 +55,7 @@ defmodule MicrowavepropWeb.Router do live "/beacons/:id/edit", BeaconLive.Form, :edit live "/admin/backfill", BackfillLive + live "/admin/contact-edits", Admin.ContactEditLive live "/users", UserManagementLive.Index, :index live "/users/:id/edit", UserManagementLive.Edit, :edit diff --git a/priv/repo/migrations/20260411210308_create_contact_edits.exs b/priv/repo/migrations/20260411210308_create_contact_edits.exs new file mode 100644 index 00000000..6b9e98b4 --- /dev/null +++ b/priv/repo/migrations/20260411210308_create_contact_edits.exs @@ -0,0 +1,25 @@ +defmodule Microwaveprop.Repo.Migrations.CreateContactEdits do + use Ecto.Migration + + def change do + create table(:contact_edits, primary_key: false) do + add :id, :binary_id, primary_key: true + + add :contact_id, references(:contacts, type: :binary_id, on_delete: :delete_all), + null: false + + add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false + add :proposed_changes, :map, null: false + add :status, :string, null: false, default: "pending" + add :admin_note, :string + add :reviewed_by_id, references(:users, type: :binary_id, on_delete: :nilify_all) + add :reviewed_at, :utc_datetime + + timestamps(type: :utc_datetime) + end + + create index(:contact_edits, [:contact_id]) + create index(:contact_edits, [:user_id]) + create index(:contact_edits, [:status]) + end +end diff --git a/test/microwaveprop/radio/contact_edit_test.exs b/test/microwaveprop/radio/contact_edit_test.exs new file mode 100644 index 00000000..7f44337e --- /dev/null +++ b/test/microwaveprop/radio/contact_edit_test.exs @@ -0,0 +1,248 @@ +defmodule Microwaveprop.Radio.ContactEditTest do + use Microwaveprop.DataCase, async: true + + alias Microwaveprop.Accounts + alias Microwaveprop.Radio + alias Microwaveprop.Radio.Contact + alias Microwaveprop.Radio.ContactEdit + + @contact_attrs %{ + station1: "W5ISP", + station2: "K5TR", + qso_timestamp: ~U[2026-04-01 14:00:00Z], + grid1: "EM13", + grid2: "EL29", + pos1: %{"lat" => 33.5, "lon" => -97.0}, + pos2: %{"lat" => 29.5, "lon" => -98.5}, + mode: "CW", + band: Decimal.new("10000"), + distance_km: Decimal.new("450") + } + + @user_attrs %{ + callsign: "W5TEST", + name: "Test User", + email: "test@example.com", + password: "testpassword123" + } + + @admin_attrs %{ + callsign: "N5ADM", + name: "Admin User", + email: "admin@example.com", + password: "adminpassword123" + } + + defp create_contact(_) do + {:ok, contact} = %Contact{} |> Contact.changeset(@contact_attrs) |> Repo.insert() + %{contact: contact} + end + + defp create_user(_) do + {:ok, user} = Accounts.register_user(@user_attrs) + %{user: user} + end + + defp create_admin(_) do + {:ok, admin} = Accounts.register_user(@admin_attrs) + admin = admin |> Ecto.Changeset.change(is_admin: true) |> Repo.update!() + %{admin: admin} + end + + describe "ContactEdit.changeset/2" do + setup [:create_contact, :create_user] + + test "valid with proper proposed changes", %{contact: contact, user: user} do + changeset = + ContactEdit.changeset(%ContactEdit{}, %{ + contact_id: contact.id, + user_id: user.id, + proposed_changes: %{"grid1" => "EM13kk"} + }) + + assert changeset.valid? + end + + test "rejects empty proposed_changes", %{contact: contact, user: user} do + changeset = + ContactEdit.changeset(%ContactEdit{}, %{ + contact_id: contact.id, + user_id: user.id, + proposed_changes: %{} + }) + + refute changeset.valid? + assert "must contain at least one change" in errors_on(changeset).proposed_changes + end + + test "rejects invalid fields in proposed_changes", %{contact: contact, user: user} do + changeset = + ContactEdit.changeset(%ContactEdit{}, %{ + contact_id: contact.id, + user_id: user.id, + proposed_changes: %{"distance_km" => 100} + }) + + refute changeset.valid? + assert Enum.any?(errors_on(changeset).proposed_changes, &String.contains?(&1, "invalid fields")) + end + + test "validates callsign format", %{contact: contact, user: user} do + changeset = + ContactEdit.changeset(%ContactEdit{}, %{ + contact_id: contact.id, + user_id: user.id, + proposed_changes: %{"station1" => "bad call!"} + }) + + refute changeset.valid? + end + + test "validates grid format", %{contact: contact, user: user} do + changeset = + ContactEdit.changeset(%ContactEdit{}, %{ + contact_id: contact.id, + user_id: user.id, + proposed_changes: %{"grid1" => "ZZZZZZ"} + }) + + refute changeset.valid? + end + + test "validates band value", %{contact: contact, user: user} do + changeset = + ContactEdit.changeset(%ContactEdit{}, %{ + contact_id: contact.id, + user_id: user.id, + proposed_changes: %{"band" => 99_999} + }) + + refute changeset.valid? + end + + test "validates mode value", %{contact: contact, user: user} do + changeset = + ContactEdit.changeset(%ContactEdit{}, %{ + contact_id: contact.id, + user_id: user.id, + proposed_changes: %{"mode" => "INVALID"} + }) + + refute changeset.valid? + end + + test "requires contact_id, user_id, proposed_changes" do + changeset = ContactEdit.changeset(%ContactEdit{}, %{}) + + assert %{ + contact_id: ["can't be blank"], + user_id: ["can't be blank"], + proposed_changes: ["can't be blank"] + } = errors_on(changeset) + end + end + + describe "Radio.create_contact_edit/3" do + setup [:create_contact, :create_user] + + test "creates a pending edit with only changed fields", %{contact: contact, user: user} do + proposed = %{"grid1" => "EM13kk", "station1" => "W5ISP"} + + assert {:ok, edit} = Radio.create_contact_edit(contact, user, proposed) + assert edit.status == :pending + assert edit.proposed_changes == %{"grid1" => "EM13KK"} + assert edit.contact_id == contact.id + assert edit.user_id == user.id + end + + test "rejects when nothing actually changed", %{contact: contact, user: user} do + proposed = %{"station1" => "W5ISP", "mode" => "CW"} + + assert {:error, changeset} = Radio.create_contact_edit(contact, user, proposed) + assert "must contain at least one change" in errors_on(changeset).proposed_changes + end + end + + describe "Radio.list_pending_edits/0" do + setup [:create_contact, :create_user] + + test "returns pending edits", %{contact: contact, user: user} do + {:ok, _e1} = + Radio.create_contact_edit(contact, user, %{"grid1" => "EM13kk"}) + + {:ok, _e2} = + Radio.create_contact_edit(contact, user, %{"grid2" => "EL29ab"}) + + edits = Radio.list_pending_edits() + assert length(edits) == 2 + + fields = Enum.flat_map(edits, &Map.keys(&1.proposed_changes)) + assert "grid1" in fields + assert "grid2" in fields + end + end + + describe "Radio.approve_edit/3" do + setup [:create_contact, :create_user, :create_admin] + + test "applies changes to the contact and marks approved", %{ + contact: contact, + user: user, + admin: admin + } do + {:ok, edit} = Radio.create_contact_edit(contact, user, %{"grid1" => "EM13kk"}) + + assert {:ok, approved_edit} = Radio.approve_edit(edit, admin, "Confirmed via LOTW") + assert approved_edit.status == :approved + assert approved_edit.admin_note == "Confirmed via LOTW" + assert approved_edit.reviewed_by_id == admin.id + assert approved_edit.reviewed_at + + updated_contact = Radio.get_contact!(contact.id) + assert updated_contact.grid1 == "EM13KK" + end + + test "re-enqueues enrichment when grid changes", %{ + contact: contact, + user: user, + admin: admin + } do + {:ok, edit} = Radio.create_contact_edit(contact, user, %{"grid1" => "EM15ab"}) + {:ok, _approved} = Radio.approve_edit(edit, admin, nil) + + updated = Radio.get_contact!(contact.id) + assert updated.grid1 == "EM15AB" + assert updated.pos1 != contact.pos1 + end + end + + describe "Radio.reject_edit/3" do + setup [:create_contact, :create_user, :create_admin] + + test "marks edit as rejected without changing contact", %{ + contact: contact, + user: user, + admin: admin + } do + {:ok, edit} = Radio.create_contact_edit(contact, user, %{"grid1" => "EM13kk"}) + + assert {:ok, rejected_edit} = Radio.reject_edit(edit, admin, "Original data correct") + assert rejected_edit.status == :rejected + assert rejected_edit.admin_note == "Original data correct" + + unchanged_contact = Radio.get_contact!(contact.id) + assert unchanged_contact.grid1 == contact.grid1 + end + end + + describe "Radio.pending_edit_count/0" do + setup [:create_contact, :create_user] + + test "counts only pending edits", %{contact: contact, user: user} do + {:ok, _} = Radio.create_contact_edit(contact, user, %{"grid1" => "EM13kk"}) + {:ok, _} = Radio.create_contact_edit(contact, user, %{"grid2" => "EL29ab"}) + + assert Radio.pending_edit_count() == 2 + end + end +end