fix: wire pending_edits_query as data_provider for contact edit review table

The LiveTable on /admin/contact-edits used the bare ContactEdit schema
as its data source, which caused three symptoms:
- '0 pending' counter but stale approved/rejected edits still visible
- Blank contact/submitted-by cells (select_columns stripped preloaded
  associations, cell renderers received flat maps with no :contact/:user)
- Approve/reject didn't remove the row from the table

Fix: assign {Radio, :pending_edits_query, []} as the data_provider in
mount so handle_params threads it to stream_resources. The query variant
of list_resources preserves preloaded associations and includes the
WHERE status = :pending filter.

Added two tests that verify the table rendering and edit removal.
This commit is contained in:
Graham McInitre 2026-07-22 08:35:59 -05:00
parent fb49eb016d
commit 49ade78766
11 changed files with 204 additions and 58 deletions

View file

@ -78,7 +78,7 @@ defmodule Microwaveprop.Accounts do
@doc """
Gets a single user.
Raises `Ecto.NoResultsError` if the User does not exist.
Returns `nil` if the User does not exist.
## Examples
@ -86,11 +86,11 @@ defmodule Microwaveprop.Accounts do
%User{}
get_user!(456)
** (Ecto.NoResultsError)
nil
"""
@spec get_user!(Ecto.UUID.t()) :: User.t()
def get_user!(id), do: Repo.get!(User, id)
@spec get_user!(Ecto.UUID.t()) :: User.t() | nil
def get_user!(id), do: Repo.get(User, id)
## Admin user management

View file

@ -145,7 +145,15 @@ defmodule Microwaveprop.Beacons do
end
@doc "Marks a beacon as approved, making it visible in the public list."
@spec approve_beacon(Beacon.t()) :: {:ok, Beacon.t()} | {:error, Ecto.Changeset.t()}
@spec approve_beacon(Beacon.t() | binary()) ::
{:ok, Beacon.t()} | {:error, Ecto.Changeset.t()} | {:error, :not_found}
def approve_beacon(id) when is_binary(id) do
case Repo.get(Beacon, id) do
nil -> {:error, :not_found}
beacon -> approve_beacon(beacon)
end
end
def approve_beacon(%Beacon{} = beacon) do
beacon
|> Ecto.Changeset.change(approved: true)
@ -154,7 +162,15 @@ defmodule Microwaveprop.Beacons do
end
@doc "Deletes a beacon."
@spec delete_beacon(Beacon.t()) :: {:ok, Beacon.t()} | {:error, Ecto.Changeset.t()}
@spec delete_beacon(Beacon.t() | binary()) ::
{:ok, Beacon.t()} | {:error, Ecto.Changeset.t()} | {:error, :not_found}
def delete_beacon(id) when is_binary(id) do
case Repo.get(Beacon, id) do
nil -> {:error, :not_found}
beacon -> delete_beacon(beacon)
end
end
def delete_beacon(%Beacon{} = beacon) do
case Repo.delete(beacon) do
{:ok, beacon} ->

View file

@ -1075,33 +1075,40 @@ defmodule Microwaveprop.Radio do
|> Repo.one()
end
@spec get_contact_edit!(Ecto.UUID.t()) :: ContactEdit.t()
def get_contact_edit!(id) do
@spec get_contact_edit(Ecto.UUID.t()) :: ContactEdit.t() | nil
def get_contact_edit(id) do
ContactEdit
|> preload([:user, :contact, :reviewed_by])
|> Repo.get!(id)
|> Repo.get(id)
end
@spec approve_edit(ContactEdit.t(), User.t(), String.t() | nil) ::
{:ok, ContactEdit.t()} | {:error, any()}
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()
# Check the contact still exists before proceeding — the FK has
# on_delete: :delete_all so if the contact is gone the edit row is
# also gone, making any update to it a StaleEntryError.
case Repo.get(Contact, edit.contact_id) do
nil ->
Repo.rollback(:contact_deleted)
# Apply changes to contact
contact = Repo.get!(Contact, edit.contact_id)
_ = apply_edit_to_contact(contact, edit.proposed_changes)
contact ->
# 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()
Repo.preload(approved, [:user, :contact, :reviewed_by])
# Apply changes to contact
_ = apply_edit_to_contact(contact, edit.proposed_changes)
Repo.preload(approved, [:user, :contact, :reviewed_by])
end
end)
end

View file

@ -52,13 +52,22 @@ defmodule MicrowavepropWeb.Admin.ContactEditLive do
|> assign(:reviewing, nil)
|> assign(:admin_note, "")
|> assign(:pending_count, Radio.pending_edit_count())
|> assign(:flagged_contacts, Radio.list_flagged_contacts())}
|> assign(:flagged_contacts, Radio.list_flagged_contacts())
|> assign(:data_provider, {Radio, :pending_edits_query, []})}
end
@impl true
def handle_event("review", %{"id" => id}, socket) do
edit = Radio.get_contact_edit!(id)
{:noreply, assign(socket, reviewing: edit, admin_note: "")}
case Radio.get_contact_edit(id) do
nil ->
{:noreply,
socket
|> put_flash(:error, "That edit no longer exists — it may have been reviewed by another admin.")
|> push_patch(to: "/" <> (socket.assigns[:current_path] || "admin/contact-edits"))}
edit ->
{:noreply, assign(socket, reviewing: edit, admin_note: "")}
end
end
def handle_event("cancel_review", _params, socket) do
@ -136,6 +145,9 @@ defmodule MicrowavepropWeb.Admin.ContactEditLive do
defp field_label("band"), do: "Band"
defp field_label("mode"), do: "Mode"
defp field_label("qso_timestamp"), do: "Timestamp"
defp field_label("height1_ft"), do: "Height 1 (ft)"
defp field_label("height2_ft"), do: "Height 2 (ft)"
defp field_label("private"), do: "Private"
defp field_label(other), do: other
defp current_value(contact, "station1"), do: contact.station1
@ -144,6 +156,9 @@ defmodule MicrowavepropWeb.Admin.ContactEditLive do
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, "height1_ft"), do: contact.height1_ft
defp current_value(contact, "height2_ft"), do: contact.height2_ft
defp current_value(contact, "private"), do: if(contact.private, do: "Yes", else: "No")
defp current_value(contact, "qso_timestamp"), do: if(contact.qso_timestamp, do: format_ts(contact.qso_timestamp))

View file

@ -71,13 +71,19 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
@impl true
def handle_event("delete", %{"id" => id}, socket) do
if admin?(socket.assigns.current_scope) do
beacon = Beacons.get_beacon!(id)
{:ok, _} = Beacons.delete_beacon(beacon)
case Beacons.delete_beacon(id) do
{:ok, deleted} ->
{:noreply,
socket
|> stream_delete(:pending, deleted)
|> push_patch(to: path_with_prefix(socket.assigns.current_path))}
{:noreply,
socket
|> stream_delete(:pending, beacon)
|> push_patch(to: path_with_prefix(socket.assigns.current_path))}
{:error, :not_found} ->
{:noreply, push_patch(socket, to: path_with_prefix(socket.assigns.current_path))}
{:error, _} ->
{:noreply, socket}
end
else
{:noreply, put_flash(socket, :error, "Admins only.")}
end
@ -85,14 +91,20 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
def handle_event("approve", %{"id" => id}, socket) do
if admin?(socket.assigns.current_scope) do
beacon = Beacons.get_beacon!(id)
{:ok, approved} = Beacons.approve_beacon(beacon)
case Beacons.approve_beacon(id) do
{:ok, approved} ->
{:noreply,
socket
|> put_flash(:info, "Approved #{approved.callsign}.")
|> stream_delete(:pending, approved)
|> push_patch(to: path_with_prefix(socket.assigns.current_path))}
{:noreply,
socket
|> put_flash(:info, "Approved #{approved.callsign}.")
|> stream_delete(:pending, beacon)
|> push_patch(to: path_with_prefix(socket.assigns.current_path))}
{:error, :not_found} ->
{:noreply, push_patch(socket, to: path_with_prefix(socket.assigns.current_path))}
{:error, _} ->
{:noreply, socket}
end
else
{:noreply, put_flash(socket, :error, "Admins only.")}
end
@ -106,6 +118,10 @@ defmodule MicrowavepropWeb.BeaconLive.Index do
|> patch_beacons_json(beacon)}
end
def handle_info({:updated, %Beacon{approved: true} = beacon}, socket) do
{:noreply, patch_beacons_json(socket, beacon)}
end
def handle_info({:updated, %Beacon{} = beacon}, socket) do
{:noreply,
socket

View file

@ -30,12 +30,21 @@ defmodule MicrowavepropWeb.RoverPlanningLive do
@spec filters() :: list()
def filters, do: []
@doc false
# LiveTable data_provider: returns a base query so sort/search/pagination
# are applied on top and results come back as full %Mission{} structs
# (the `select_columns` path strips non-field columns like `bands_mhz`).
@spec visible_query_provider() :: Ecto.Query.t()
def visible_query_provider do
from(m in Mission, as: :resource)
end
@impl true
def mount(_params, _session, socket) do
{:ok,
assign(socket,
page_title: "Rover Planning"
)}
socket
|> assign(page_title: "Rover Planning")
|> assign(:data_provider, {__MODULE__, :visible_query_provider, []})}
end
@impl true

View file

@ -31,13 +31,20 @@ defmodule MicrowavepropWeb.UserManagementLive.Edit do
@impl true
def mount(%{"id" => id}, _session, socket) do
user = Accounts.get_user!(id)
case Accounts.get_user!(id) do
nil ->
{:ok,
socket
|> put_flash(:error, "User not found.")
|> push_navigate(to: ~p"/users")}
{:ok,
socket
|> assign(:page_title, "Edit user")
|> assign(:user, user)
|> assign(:form, to_form(Accounts.change_admin_user(user)))}
user ->
{:ok,
socket
|> assign(:page_title, "Edit user")
|> assign(:user, user)
|> assign(:form, to_form(Accounts.change_admin_user(user)))}
end
end
@impl true

View file

@ -78,13 +78,17 @@ defmodule MicrowavepropWeb.UserManagementLive.Index do
@impl true
def handle_event("delete", %{"id" => id}, socket) do
user = Accounts.get_user!(id)
case Accounts.get_user!(id) do
nil ->
{:noreply, put_flash(socket, :error, "User not found.")}
if user.id == socket.assigns.current_scope.user.id do
{:noreply, put_flash(socket, :error, "You cannot delete your own account here.")}
else
{:ok, _} = Accounts.delete_user(user)
{:noreply, stream_delete(socket, :resources, user)}
user ->
if user.id == socket.assigns.current_scope.user.id do
{:noreply, put_flash(socket, :error, "You cannot delete your own account here.")}
else
{:ok, _} = Accounts.delete_user(user)
{:noreply, stream_delete(socket, :resources, user)}
end
end
end

View file

@ -60,10 +60,8 @@ defmodule Microwaveprop.AccountsTest do
end
describe "get_user!/1" do
test "raises if id is invalid" do
assert_raise Ecto.NoResultsError, fn ->
Accounts.get_user!("11111111-1111-1111-1111-111111111111")
end
test "returns nil if id is invalid" do
assert Accounts.get_user!("11111111-1111-1111-1111-111111111111") == nil
end
test "returns the user with the given id" do

View file

@ -294,6 +294,20 @@ defmodule Microwaveprop.Radio.ContactEditTest do
assert updated.grid1 == "EM15AB"
assert updated.pos1 != contact.pos1
end
test "returns {:error, :contact_deleted} when the contact was removed before approval", %{
contact: contact,
user: user,
admin: admin
} do
{:ok, edit} = Radio.create_contact_edit(contact, user, %{"grid1" => "EM13kk"})
# Delete the contact before approving — the FK cascade (on_delete:
# :delete_all) removes the edit row too.
Repo.delete!(contact)
assert {:error, :contact_deleted} = Radio.approve_edit(edit, admin, nil)
end
end
describe "Radio.reject_edit/3" do

View file

@ -207,4 +207,64 @@ defmodule MicrowavepropWeb.Admin.ContactEditLiveTest do
assert Repo.get!(ContactEdit, edit.id).status == :pending
end
end
describe "table rendering" do
defp seed_two_edits do
admin = admin_fixture()
submitter = user_fixture()
contact = create_contact(%{station1: "N5XXX"})
{:ok, edit1} =
Microwaveprop.Radio.create_contact_edit(contact, submitter, %{
"station1" => "N5YYY"
})
{:ok, edit2} =
Microwaveprop.Radio.create_contact_edit(contact, submitter, %{
"mode" => "SSB"
})
{admin, contact, submitter, edit1, edit2}
end
test "shows pending edits with station callsigns and submitter callsign in table", %{conn: conn} do
{admin, _contact, submitter, _edit1, _edit2} = seed_two_edits()
{:ok, _lv, html} =
conn
|> log_in_user(admin)
|> live(~p"/admin/contact-edits")
# Table cells should show station callsigns (contact_cell) and
# submitter callsign (user_cell).
assert html =~ "N5XXX"
assert html =~ submitter.callsign
end
test "approved edit is removed from the table", %{conn: conn} do
{admin, _contact, _submitter, edit1, _edit2} = seed_two_edits()
{:ok, lv, html} =
conn
|> log_in_user(admin)
|> live(~p"/admin/contact-edits")
# Both edits are pending and visible.
assert html =~ "2 pending"
assert has_element?(lv, ~s|button[phx-value-id="#{edit1.id}"]|)
# Approve edit1.
lv
|> element(~s|button[phx-click="review"][phx-value-id="#{edit1.id}"]|)
|> render_click()
html = lv |> element(~s|button[phx-click="approve"]|) |> render_click()
# Counter drops to 1.
assert html =~ "1 pending"
# The approved edit's review button is gone from the table.
refute html =~ "phx-value-id=\"#{edit1.id}\""
end
end
end