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