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.
240 lines
8 KiB
Elixir
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>
|
|
· {format_band(@reviewing.contact.band)} · {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
|