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:
parent
58be26fe48
commit
fb503ed2e7
5 changed files with 101 additions and 8 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 %>
|
||||||
· {Calendar.strftime(@contact.qso_timestamp, "%Y-%m-%d %H:%M UTC")}
|
· {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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue