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