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.
This commit is contained in:
Graham McIntire 2026-04-11 16:14:49 -05:00
parent 0fc4f37e86
commit a66d3094ca
17 changed files with 1189 additions and 16 deletions

View file

@ -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"

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -54,6 +54,13 @@ defmodule MicrowavepropWeb.Layouts do
>
Users
</.link>
<.link
:if={@current_scope && @current_scope.user && @current_scope.user.is_admin}
navigate="/admin/contact-edits"
class="btn btn-ghost btn-sm"
>
Edits
</.link>
<.link
:if={@current_scope && @current_scope.user && @current_scope.user.is_admin}
href="/admin/oban"

View file

@ -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"""
<Layouts.app flash={@flash} current_scope={@current_scope}>
<.header>
Review Contact Edits
<:subtitle>{length(@edits)} pending</:subtitle>
<:actions>
<.link navigate={~p"/admin/backfill"} class="btn btn-sm btn-ghost">
<.icon name="hero-arrow-left" class="w-4 h-4" /> Admin
</.link>
</:actions>
</.header>
<%= if @reviewing do %>
<div class="bg-base-200 rounded-box p-6 mb-6">
<div class="flex items-center justify-between mb-4">
<h3 class="font-semibold text-lg">
Review Edit from {@reviewing.user.callsign}
</h3>
<button phx-click="cancel_review" class="btn btn-ghost btn-sm">
<.icon name="hero-x-mark" class="w-4 h-4" /> Close
</button>
</div>
<div class="text-sm opacity-70 mb-4">
Contact:
<.link navigate={~p"/contacts/#{@reviewing.contact.id}"} class="link link-primary">
{@reviewing.contact.station1} / {@reviewing.contact.station2}
</.link>
&middot; {format_band(@reviewing.contact.band)} &middot; {format_ts(
@reviewing.contact.qso_timestamp
)}
</div>
<div class="overflow-x-auto rounded-box border border-base-300 mb-4">
<table class="table table-sm">
<thead>
<tr>
<th>Field</th>
<th>Current</th>
<th>Proposed</th>
</tr>
</thead>
<tbody>
<%= for {field, proposed_val} <- Enum.sort(@reviewing.proposed_changes) do %>
<tr>
<td class="font-semibold">{field_label(field)}</td>
<td class="opacity-70">{current_value(@reviewing.contact, field)}</td>
<td class="text-primary font-semibold">{format_proposed(field, proposed_val)}</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<div class="mb-4">
<label class="label text-sm">Note (optional)</label>
<textarea
class="textarea textarea-bordered w-full"
rows="2"
placeholder="Reason for decision..."
phx-change="update_note"
name="note"
>{@admin_note}</textarea>
</div>
<div class="flex gap-2">
<button phx-click="approve" class="btn btn-success btn-sm">
<.icon name="hero-check" class="w-4 h-4" /> Approve
</button>
<button phx-click="reject" class="btn btn-error btn-sm">
<.icon name="hero-x-mark" class="w-4 h-4" /> Reject
</button>
</div>
</div>
<% end %>
<%= if @edits == [] do %>
<div class="text-center py-12 opacity-50">
<.icon name="hero-check-circle" class="w-12 h-12 mx-auto mb-2" />
<p>No pending edits to review.</p>
</div>
<% else %>
<div class="overflow-x-auto rounded-box border border-base-300">
<table class="table">
<thead>
<tr>
<th>Contact</th>
<th>Submitted By</th>
<th>Fields Changed</th>
<th>Submitted</th>
<th></th>
</tr>
</thead>
<tbody>
<%= for edit <- @edits do %>
<tr>
<td>
<.link navigate={~p"/contacts/#{edit.contact.id}"} class="link link-primary">
{edit.contact.station1} / {edit.contact.station2}
</.link>
<div class="text-xs opacity-60">
{format_band(edit.contact.band)}
</div>
</td>
<td>{edit.user.callsign}</td>
<td class="text-sm">{changed_fields_summary(edit.proposed_changes)}</td>
<td class="text-sm">{format_ts(edit.inserted_at)}</td>
<td>
<button phx-click="review" phx-value-id={edit.id} class="btn btn-primary btn-xs">
Review
</button>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>
<% end %>
</Layouts.app>
"""
end
end

View file

@ -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

View file

@ -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>
<:col :let={{_id, beacon}} label="Frequency (MHz)">
{Beacon.format_freq(beacon.frequency_mhz)}
</:col>
<:col :let={{_id, beacon}} label="Call">{beacon.callsign}</:col>
<:col :let={{_id, beacon}} label="Grid">{beacon.grid}</:col>
<:col :let={{_id, beacon}} label="Lat">{format_coord(beacon.lat)}</:col>
@ -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>
<:col :let={{_id, beacon}} label="Frequency (MHz)">
{Beacon.format_freq(beacon.frequency_mhz)}
</:col>
<:col :let={{_id, beacon}} label="Call">{beacon.callsign}</:col>
<:col :let={{_id, beacon}} label="Grid">{beacon.grid}</:col>
<:col :let={{_id, beacon}} label="EIRP (mW)">{Beacon.format_mw(beacon.power_mw)}</:col>

View file

@ -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 %>
</:subtitle>
<:actions>
<%= if current_user(assigns) do %>
<button
phx-click="toggle_edit"
class={["btn btn-sm", if(@editing, do: "btn-ghost", else: "btn-outline")]}
>
<.icon
name={if @editing, do: "hero-x-mark", else: "hero-pencil-square"}
class="w-4 h-4"
/>
{if @editing, do: "Cancel Edit", else: "Suggest Edit"}
</button>
<% end %>
<button
phx-click="toggle_flag"
class={[
@ -463,6 +548,53 @@ defmodule MicrowavepropWeb.ContactLive.Show do
</:actions>
</.header>
<%= if @pending_edit && !@editing do %>
<div class="bg-info/10 border border-info/30 rounded-box px-4 py-2 text-sm mb-4">
<.icon name="hero-clock" class="w-4 h-4 inline" />
You have a pending edit for this contact awaiting admin review.
</div>
<% end %>
<%= if @editing do %>
<div class="bg-base-200 rounded-box p-4 mb-4">
<h3 class="font-semibold mb-3">Suggest Edit</h3>
<p class="text-sm opacity-70 mb-4">
Change any fields below. Only fields you modify will be submitted for review.
</p>
<.form
for={@edit_form}
id="edit-form"
phx-change="validate_edit"
phx-submit="submit_edit"
class="space-y-4"
>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<.input field={@edit_form[:station1]} type="text" label="Station 1" />
<.input field={@edit_form[:grid1]} type="text" label="Grid 1" />
<.input field={@edit_form[:station2]} type="text" label="Station 2" />
<.input field={@edit_form[:grid2]} type="text" label="Grid 2" />
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<.input field={@edit_form[:band]} type="select" label="Band" options={@band_options} />
<.input field={@edit_form[:mode]} type="select" label="Mode" options={@mode_options} />
<.input
field={@edit_form[:qso_timestamp]}
type="datetime-local"
label="Timestamp (UTC)"
/>
</div>
<div class="flex gap-2">
<.button type="submit" class="btn btn-primary btn-sm">
<.icon name="hero-paper-airplane" class="w-4 h-4" /> Submit for Review
</.button>
<button type="button" phx-click="toggle_edit" class="btn btn-ghost btn-sm">
Cancel
</button>
</div>
</.form>
</div>
<% end %>
<%= if @contact.pos1 && @contact.pos2 do %>
<p class="text-xs text-base-content/50 italic mb-1">
Locations approximate, in the center of the grid squares

View file

@ -74,7 +74,9 @@ defmodule MicrowavepropWeb.ContactMapLive do
</div>
<div class="fixed top-3 left-14 z-[1000] bg-base-100 shadow rounded-box border border-base-300 px-3 py-2 flex flex-col gap-1">
<div class="font-bold text-sm">Contact Map</div>
<div id="contact-count" class="text-xs opacity-70">{Integer.to_string(@contact_count)} contacts</div>
<div id="contact-count" class="text-xs opacity-70">
{Integer.to_string(@contact_count)} contacts
</div>
</div>
"""
end

View file

@ -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)]"
>
<div data-theme="dark" class="bg-neutral text-neutral-content shadow rounded-box border border-base-300 p-2 flex flex-col gap-2">
<div
data-theme="dark"
class="bg-neutral text-neutral-content shadow rounded-box border border-base-300 p-2 flex flex-col gap-2"
>
<div class="font-bold text-sm leading-tight px-1 flex items-center justify-between gap-2">
<div class="min-w-0">
<span>NTMS</span>

View file

@ -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

View file

@ -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

View file

@ -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