From fb503ed2e712b6f30aa1e4444f33cddfee0707a0 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Mon, 4 May 2026 10:15:13 -0500 Subject: [PATCH] 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. --- lib/microwaveprop/radio.ex | 40 +++++++++++++-- lib/microwaveprop/radio/contact.ex | 2 + .../live/contact_live/show.ex | 51 ++++++++++++++++++- ...151127_add_flagged_by_user_to_contacts.exs | 12 +++++ .../live/contact_live_test.exs | 4 +- 5 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 priv/repo/migrations/20260504151127_add_flagged_by_user_to_contacts.exs diff --git a/lib/microwaveprop/radio.ex b/lib/microwaveprop/radio.ex index 01197a51..1b50de27 100644 --- a/lib/microwaveprop/radio.ex +++ b/lib/microwaveprop/radio.ex @@ -449,7 +449,9 @@ defmodule Microwaveprop.Radio do @spec get_contact!(Ecto.UUID.t()) :: Contact.t() def get_contact!(id) do - Repo.get!(Contact, id) + Contact + |> Repo.get!(id) + |> Repo.preload(:flagged_by_user) end @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{}, _scope), do: false - @spec toggle_flagged_invalid!(Contact.t()) :: Contact.t() - def toggle_flagged_invalid!(contact) do + @doc """ + 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 - |> Ecto.Changeset.change(flagged_invalid: !contact.flagged_invalid) + |> Ecto.Changeset.change(attrs) |> Repo.update!() end diff --git a/lib/microwaveprop/radio/contact.ex b/lib/microwaveprop/radio/contact.ex index aa5bc16b..db6d6bc9 100644 --- a/lib/microwaveprop/radio/contact.ex +++ b/lib/microwaveprop/radio/contact.ex @@ -59,6 +59,7 @@ defmodule Microwaveprop.Radio.Contact do field :user_submitted, :boolean, default: false field :submitter_email, :string field :flagged_invalid, :boolean, default: false + field :flagged_at, :utc_datetime field :private, :boolean, default: false # Antenna height above ground level (feet). Optional — when set, the @@ -73,6 +74,7 @@ defmodule Microwaveprop.Radio.Contact do field :notes, :string belongs_to :user, User + belongs_to :flagged_by_user, User, foreign_key: :flagged_by_user_id timestamps(type: :utc_datetime) end diff --git a/lib/microwaveprop_web/live/contact_live/show.ex b/lib/microwaveprop_web/live/contact_live/show.ex index 0fa817f9..34e979a6 100644 --- a/lib/microwaveprop_web/live/contact_live/show.ex +++ b/lib/microwaveprop_web/live/contact_live/show.ex @@ -223,9 +223,24 @@ defmodule MicrowavepropWeb.ContactLive.Show do {:noreply, assign(socket, terrain_expanded: !socket.assigns.terrain_expanded)} 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 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)} else {:noreply, put_flash(socket, :error, "Admins only.")} @@ -892,7 +907,12 @@ defmodule MicrowavepropWeb.ContactLive.Show do <% end %> · {Calendar.strftime(@contact.qso_timestamp, "%Y-%m-%d %H:%M UTC")} <%= if @contact.flagged_invalid do %> - Flagged Invalid + + Flagged invalid{flag_attribution(@contact)} + <% end %> <%= if @contact.private do %> {if @contact.flagged_invalid, do: "Unflag", else: "Flag as Invalid"} + <.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 @@ -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, _), 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 defp grid_link(assigns) do diff --git a/priv/repo/migrations/20260504151127_add_flagged_by_user_to_contacts.exs b/priv/repo/migrations/20260504151127_add_flagged_by_user_to_contacts.exs new file mode 100644 index 00000000..a8d1782d --- /dev/null +++ b/priv/repo/migrations/20260504151127_add_flagged_by_user_to_contacts.exs @@ -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 diff --git a/test/microwaveprop_web/live/contact_live_test.exs b/test/microwaveprop_web/live/contact_live_test.exs index 720ea081..dc57ffac 100644 --- a/test/microwaveprop_web/live/contact_live_test.exs +++ b/test/microwaveprop_web/live/contact_live_test.exs @@ -974,7 +974,7 @@ defmodule MicrowavepropWeb.ContactLiveTest do assert Process.alive?(lv.pid) 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() {:ok, contact} = @@ -983,7 +983,7 @@ defmodule MicrowavepropWeb.ContactLiveTest do |> Repo.update() {: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. assert html =~ "line-through" end