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

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

240 lines
8 KiB
Elixir

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