feat(contacts): record + show flagger attribution; admin delete

Two related changes to the contact detail page:

1. The "Flagged invalid" badge was wrapping to two lines on
   narrow widths because the daisyUI badge has no explicit
   no-wrap class. Add `whitespace-nowrap`, drop the redundant
   capitalization on "Invalid", and append the flagger's
   callsign so the badge reads "Flagged invalid by W5XYZ".
   Hover tooltip carries the timestamp.

2. New `flagged_by_user_id` + `flagged_at` columns on contacts.
   `Radio.toggle_flagged_invalid!/2` now takes the admin User
   doing the flagging; on unflag it clears both fields so a
   subsequent re-flag gets a fresh attribution and the badge
   never shows a stale flagger.

3. `Radio.delete_contact!/1` + a Delete button next to Flag.
   Admin-only, gated in both the event handler and the template.
   Uses LV's `data-confirm` for the destructive-action prompt.
   All FK references to `contacts` already declare
   `on_delete: :delete_all` (terrain_profiles, contact_edits,
   contact_common_volume_radar) so a single Repo.delete!
   cascades cleanly.
This commit is contained in:
Graham McIntire 2026-05-04 10:15:13 -05:00
parent 58be26fe48
commit fb503ed2e7
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
5 changed files with 101 additions and 8 deletions

View file

@ -449,7 +449,9 @@ defmodule Microwaveprop.Radio do
@spec get_contact!(Ecto.UUID.t()) :: Contact.t() @spec get_contact!(Ecto.UUID.t()) :: Contact.t()
def get_contact!(id) do def get_contact!(id) do
Repo.get!(Contact, id) Contact
|> Repo.get!(id)
|> Repo.preload(:flagged_by_user)
end end
@doc """ @doc """
@ -463,10 +465,40 @@ defmodule Microwaveprop.Radio do
def can_view?(%Contact{user_id: user_id}, %Scope{user: %User{id: user_id}}) when not is_nil(user_id), do: true def can_view?(%Contact{user_id: user_id}, %Scope{user: %User{id: user_id}}) when not is_nil(user_id), do: true
def can_view?(%Contact{}, _scope), do: false def can_view?(%Contact{}, _scope), do: false
@spec toggle_flagged_invalid!(Contact.t()) :: Contact.t() @doc """
def toggle_flagged_invalid!(contact) do Hard-delete a contact and everything attached to it. All FK
references (`terrain_profiles`, `contact_edits`,
`contact_common_volume_radar`) declare `on_delete: :delete_all`,
so a single `Repo.delete!` cascades through.
Admin-only operation callers must gate with `is_admin` before
invoking. Irreversible: prefer `toggle_flagged_invalid!/2` when
the data should be hidden but kept in the audit trail.
"""
@spec delete_contact!(Contact.t()) :: Contact.t()
def delete_contact!(%Contact{} = contact), do: Repo.delete!(contact)
@spec toggle_flagged_invalid!(Contact.t(), User.t() | nil) :: Contact.t()
def toggle_flagged_invalid!(contact, flagger \\ nil) do
new_state = !contact.flagged_invalid
attrs =
if new_state do
%{
flagged_invalid: true,
flagged_by_user_id: flagger && flagger.id,
flagged_at: DateTime.truncate(DateTime.utc_now(), :second)
}
else
# Clear attribution on unflag — the next flag (potentially
# by a different admin) gets a fresh record, and the badge
# stops claiming a stale flagger after the contact was
# restored to valid.
%{flagged_invalid: false, flagged_by_user_id: nil, flagged_at: nil}
end
contact contact
|> Ecto.Changeset.change(flagged_invalid: !contact.flagged_invalid) |> Ecto.Changeset.change(attrs)
|> Repo.update!() |> Repo.update!()
end end

View file

@ -59,6 +59,7 @@ defmodule Microwaveprop.Radio.Contact do
field :user_submitted, :boolean, default: false field :user_submitted, :boolean, default: false
field :submitter_email, :string field :submitter_email, :string
field :flagged_invalid, :boolean, default: false field :flagged_invalid, :boolean, default: false
field :flagged_at, :utc_datetime
field :private, :boolean, default: false field :private, :boolean, default: false
# Antenna height above ground level (feet). Optional — when set, the # Antenna height above ground level (feet). Optional — when set, the
@ -73,6 +74,7 @@ defmodule Microwaveprop.Radio.Contact do
field :notes, :string field :notes, :string
belongs_to :user, User belongs_to :user, User
belongs_to :flagged_by_user, User, foreign_key: :flagged_by_user_id
timestamps(type: :utc_datetime) timestamps(type: :utc_datetime)
end end

View file

@ -223,9 +223,24 @@ defmodule MicrowavepropWeb.ContactLive.Show do
{:noreply, assign(socket, terrain_expanded: !socket.assigns.terrain_expanded)} {:noreply, assign(socket, terrain_expanded: !socket.assigns.terrain_expanded)}
end end
def handle_event("delete_contact", _params, socket) do
if admin?(socket.assigns) do
Radio.delete_contact!(socket.assigns.contact)
{:noreply,
socket
|> put_flash(:info, "Contact deleted.")
|> push_navigate(to: ~p"/contacts")}
else
{:noreply, put_flash(socket, :error, "Admins only.")}
end
end
def handle_event("toggle_flag", _params, socket) do def handle_event("toggle_flag", _params, socket) do
if admin?(socket.assigns) do if admin?(socket.assigns) do
contact = Radio.toggle_flagged_invalid!(socket.assigns.contact) flagger = socket.assigns.current_scope && socket.assigns.current_scope.user
contact = Radio.toggle_flagged_invalid!(socket.assigns.contact, flagger)
contact = Microwaveprop.Repo.preload(contact, :flagged_by_user)
{:noreply, assign(socket, contact: contact)} {:noreply, assign(socket, contact: contact)}
else else
{:noreply, put_flash(socket, :error, "Admins only.")} {:noreply, put_flash(socket, :error, "Admins only.")}
@ -892,7 +907,12 @@ defmodule MicrowavepropWeb.ContactLive.Show do
<% end %> <% end %>
&middot; {Calendar.strftime(@contact.qso_timestamp, "%Y-%m-%d %H:%M UTC")} &middot; {Calendar.strftime(@contact.qso_timestamp, "%Y-%m-%d %H:%M UTC")}
<%= if @contact.flagged_invalid do %> <%= if @contact.flagged_invalid do %>
<span class="badge badge-error badge-sm ml-2">Flagged Invalid</span> <span
class="badge badge-error badge-sm ml-2 whitespace-nowrap"
title={flag_tooltip(@contact)}
>
Flagged invalid{flag_attribution(@contact)}
</span>
<% end %> <% end %>
<%= if @contact.private do %> <%= if @contact.private do %>
<span <span
@ -931,6 +951,14 @@ defmodule MicrowavepropWeb.ContactLive.Show do
<.icon name="hero-flag" class="w-4 h-4" /> <.icon name="hero-flag" class="w-4 h-4" />
{if @contact.flagged_invalid, do: "Unflag", else: "Flag as Invalid"} {if @contact.flagged_invalid, do: "Unflag", else: "Flag as Invalid"}
</button> </button>
<button
:if={admin?(assigns)}
phx-click="delete_contact"
data-confirm={"Permanently delete this contact (#{@contact.station1} / #{@contact.station2})? This cannot be undone."}
class="btn btn-sm btn-error btn-outline"
>
<.icon name="hero-trash" class="w-4 h-4" /> Delete
</button>
<.link navigate={path_calc_url(@contact)} class="btn btn-sm btn-outline"> <.link navigate={path_calc_url(@contact)} class="btn btn-sm btn-outline">
<.icon name="hero-calculator" class="w-4 h-4" /> Open in Path Calculator <.icon name="hero-calculator" class="w-4 h-4" /> Open in Path Calculator
</.link> </.link>
@ -2469,6 +2497,25 @@ defmodule MicrowavepropWeb.ContactLive.Show do
defp maybe_put_band(params, band) when is_binary(band), do: Map.put(params, "band", band) defp maybe_put_band(params, band) when is_binary(band), do: Map.put(params, "band", band)
defp maybe_put_band(params, _), do: params defp maybe_put_band(params, _), do: params
# " by W5XYZ" suffix when we know who flagged it; nothing
# otherwise (legacy flags from before the column was added have
# `flagged_by_user_id == nil`).
defp flag_attribution(%{flagged_by_user: %{callsign: callsign}}) when is_binary(callsign) do
" by " <> callsign
end
defp flag_attribution(_), do: ""
defp flag_tooltip(%{flagged_by_user: %{callsign: callsign}, flagged_at: %DateTime{} = at}) when is_binary(callsign) do
"Flagged by #{callsign} on #{Calendar.strftime(at, "%Y-%m-%d %H:%M UTC")}"
end
defp flag_tooltip(%{flagged_at: %DateTime{} = at}) do
"Flagged on #{Calendar.strftime(at, "%Y-%m-%d %H:%M UTC")}"
end
defp flag_tooltip(_), do: "Flagged invalid"
attr :grid, :string, default: nil attr :grid, :string, default: nil
defp grid_link(assigns) do defp grid_link(assigns) do

View file

@ -0,0 +1,12 @@
defmodule Microwaveprop.Repo.Migrations.AddFlaggedByUserToContacts do
use Ecto.Migration
def change do
alter table(:contacts) do
add :flagged_by_user_id, references(:users, type: :binary_id, on_delete: :nilify_all)
add :flagged_at, :utc_datetime
end
create index(:contacts, [:flagged_by_user_id])
end
end

View file

@ -974,7 +974,7 @@ defmodule MicrowavepropWeb.ContactLiveTest do
assert Process.alive?(lv.pid) assert Process.alive?(lv.pid)
end end
test "flagged_invalid contact shows the Flagged Invalid badge", %{conn: conn} do test "flagged_invalid contact shows the Flagged invalid badge", %{conn: conn} do
contact = create_contact() contact = create_contact()
{:ok, contact} = {:ok, contact} =
@ -983,7 +983,7 @@ defmodule MicrowavepropWeb.ContactLiveTest do
|> Repo.update() |> Repo.update()
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}") {:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
assert html =~ "Flagged Invalid" assert html =~ "Flagged invalid"
# Header station text gets line-through styling when flagged. # Header station text gets line-through styling when flagged.
assert html =~ "line-through" assert html =~ "line-through"
end end