feat(contacts): private flag with scope-aware visibility

Contacts can be marked private at submit time (single form, CSV
import, ADIF import) and edit time. Private contacts are hidden
from the public map, the public /u/callsign profile, and the
browse table for anonymous and non-owning viewers. The original
submitter and admins see them inline on the browse table with a
"Yes" badge, and the detail page shows a lock icon. Non-authorized
viewers get a 404 on the detail page.
This commit is contained in:
Graham McIntire 2026-04-18 17:48:44 -05:00
parent 69f4c81899
commit 6c652ef2d4
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
12 changed files with 417 additions and 26 deletions

View file

@ -3,6 +3,7 @@ defmodule Microwaveprop.Radio do
import Ecto.Query
alias Microwaveprop.Accounts.Scope
alias Microwaveprop.Accounts.User
alias Microwaveprop.Cache
alias Microwaveprop.Radio.BandResolver
@ -57,7 +58,7 @@ defmodule Microwaveprop.Radio do
defp load_contacts_for_map do
from(c in Contact,
where: not is_nil(c.pos1) and not is_nil(c.pos2),
where: not is_nil(c.pos1) and not is_nil(c.pos2) and c.private == false,
select: %{
id: c.id,
pos1: c.pos1,
@ -142,6 +143,7 @@ defmodule Microwaveprop.Radio do
Contact
|> where([c], fragment("upper(?)", c.station1) == ^upcased or fragment("upper(?)", c.station2) == ^upcased)
|> where([c], c.private == false)
|> order_by([c], desc: c.qso_timestamp, desc: c.id)
|> limit(100)
|> Repo.all()
@ -152,12 +154,16 @@ defmodule Microwaveprop.Radio do
page = max(Keyword.get(opts, :page, 1), 1)
offset = (page - 1) * @per_page
search = Keyword.get(opts, :search)
scope = Keyword.get(opts, :scope)
{sort_field, sort_dir} = sort_opts(opts)
base_query = maybe_search(Contact, search)
base_query =
Contact
|> maybe_search(search)
|> filter_private(scope)
total_entries = total_entries_for(search, base_query)
total_entries = total_entries_for(search, scope, base_query)
total_pages = max(ceil(total_entries / @per_page), 1)
entries =
@ -222,7 +228,9 @@ defmodule Microwaveprop.Radio do
t2 |> NaiveDateTime.truncate(:second) |> Map.take([:year, :month, :day, :hour])
end
defp total_entries_for(search, query) when search in [nil, ""] do
# Only cache the unscoped, unsearched total — scope-dependent counts
# differ per-viewer and must not share a cache.
defp total_entries_for(search, nil, query) when search in [nil, ""] do
if Application.get_env(:microwaveprop, :cache_contact_count, true) do
Cache.fetch_or_store(@count_cache_key, @count_cache_ttl_ms, fn ->
Repo.aggregate(query, :count)
@ -232,7 +240,18 @@ defmodule Microwaveprop.Radio do
end
end
defp total_entries_for(_search, query), do: Repo.aggregate(query, :count)
defp total_entries_for(_search, _scope, query), do: Repo.aggregate(query, :count)
# Visibility filter for scope-aware queries. Admins see everything;
# logged-in users see non-private + their own private; others see only
# non-private.
defp filter_private(query, %Scope{user: %User{is_admin: true}}), do: query
defp filter_private(query, %Scope{user: %User{id: user_id}}) do
where(query, [c], c.private == false or c.user_id == ^user_id)
end
defp filter_private(query, _), do: where(query, [c], c.private == false)
defp maybe_search(query, nil), do: query
defp maybe_search(query, ""), do: query
@ -417,6 +436,17 @@ defmodule Microwaveprop.Radio do
Repo.get!(Contact, id)
end
@doc """
True when the viewer (per `Scope`) is allowed to see `contact`.
Public contacts are always viewable; private contacts only by the
submitter or an admin.
"""
@spec can_view?(Contact.t(), Scope.t() | nil) :: boolean()
def can_view?(%Contact{private: false}, _scope), do: true
def can_view?(%Contact{}, %Scope{user: %User{is_admin: true}}), 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
@spec toggle_flagged_invalid!(Contact.t()) :: Contact.t()
def toggle_flagged_invalid!(contact) do
contact
@ -775,6 +805,20 @@ defmodule Microwaveprop.Radio do
|> normalize_string_field("mode")
|> normalize_integer_field("height1_ft")
|> normalize_integer_field("height2_ft")
|> normalize_boolean_field("private")
end
# HTML checkboxes arrive as "true"/"false" strings; Ecto won't coerce
# those inside a validate_inclusion path, so normalize at the boundary.
defp normalize_boolean_field(map, key) do
case Map.get(map, key, :not_provided) do
:not_provided -> map
nil -> map
val when is_boolean(val) -> Map.put(map, key, val)
"true" -> Map.put(map, key, true)
"false" -> Map.put(map, key, false)
_ -> map
end
end
defp normalize_string_field(map, key) do

View file

@ -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 :private, :boolean, default: false
# Antenna height above ground level (feet). Optional — when set, the
# elevation profile and terrain analysis use the actual heights
@ -74,7 +75,7 @@ defmodule Microwaveprop.Radio.Contact do
@type t :: %__MODULE__{}
@required_fields ~w(station1 station2 qso_timestamp band)a
@optional_fields ~w(grid1 grid2 pos1 pos2 distance_km user_id mode user_declared_prop_mode height1_ft height2_ft)a
@optional_fields ~w(grid1 grid2 pos1 pos2 distance_km user_id mode user_declared_prop_mode height1_ft height2_ft private)a
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(contact, attrs) do
@ -83,7 +84,7 @@ defmodule Microwaveprop.Radio.Contact do
|> validate_required(@required_fields)
end
@submission_fields ~w(station1 station2 qso_timestamp mode band grid1 grid2 submitter_email user_declared_prop_mode height1_ft height2_ft)a
@submission_fields ~w(station1 station2 qso_timestamp mode band grid1 grid2 submitter_email user_declared_prop_mode height1_ft height2_ft private)a
@submission_required ~w(station1 station2 qso_timestamp band grid1 grid2)a
@allowed_modes ~w(CW SSB FM FT8 FT4 Q65)
# Keep in sync with Microwaveprop.Propagation.BandConfig.all_bands/0 and

View file

@ -29,7 +29,7 @@ defmodule Microwaveprop.Radio.ContactEdit do
@type t :: %__MODULE__{}
@editable_fields ~w(station1 station2 grid1 grid2 band mode qso_timestamp height1_ft height2_ft)
@editable_fields ~w(station1 station2 grid1 grid2 band mode qso_timestamp height1_ft height2_ft private)
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(edit, attrs) do
@ -121,6 +121,16 @@ defmodule Microwaveprop.Radio.ContactEdit do
defp validate_field_value("height1_ft", cs), do: validate_height_value(cs, "height1_ft")
defp validate_field_value("height2_ft", cs), do: validate_height_value(cs, "height2_ft")
defp validate_field_value("private", changeset) do
val = get_field(changeset, :proposed_changes)["private"]
if is_boolean(val) do
changeset
else
add_error(changeset, :proposed_changes, "private must be a boolean")
end
end
defp validate_field_value(_, changeset), do: changeset
defp validate_height_value(changeset, field) do

View file

@ -196,11 +196,12 @@ defmodule Microwaveprop.Radio.CsvImport do
the `row_num` values of the refinements so the worker can route each
row to the correct pipeline without re-classifying.
"""
@spec enqueue(preview_result() | %{valid: [map()], refinements: [map()]}, String.t()) ::
@spec enqueue(preview_result() | %{valid: [map()], refinements: [map()]}, String.t(), keyword()) ::
{:ok, Ecto.UUID.t()}
def enqueue(preview, submitter_email) do
valid = Map.get(preview, :valid, [])
refinements = Map.get(preview, :refinements, [])
def enqueue(preview, submitter_email, opts \\ []) do
private = Keyword.get(opts, :private, false)
valid = preview |> Map.get(:valid, []) |> Enum.map(&stamp_private(&1, private))
refinements = preview |> Map.get(:refinements, []) |> Enum.map(&stamp_private(&1, private))
serialized_rows = Enum.map(valid, &serialize_valid_row/1) ++ Enum.map(refinements, &serialize_refinement_row/1)
refinement_ids = Enum.map(refinements, & &1.row_num)
@ -237,6 +238,9 @@ defmodule Microwaveprop.Radio.CsvImport do
end)
end
defp stamp_private(%{attrs: attrs} = row, true), do: %{row | attrs: Map.put(attrs, "private", true)}
defp stamp_private(row, _), do: row
defp serialize_valid_row(row) do
%{
"kind" => "insert",

View file

@ -3,6 +3,10 @@ defmodule MicrowavepropWeb.ContactLive.Index do
use MicrowavepropWeb, :live_view
use LiveTable.LiveResource, schema: Microwaveprop.Radio.Contact
import Ecto.Query
alias Microwaveprop.Accounts.Scope
alias Microwaveprop.Accounts.User
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Repo
@ -26,7 +30,8 @@ defmodule MicrowavepropWeb.ContactLive.Index do
hrrr_status: %{label: "Enriched", renderer: &enrichment_cell/2},
flagged_invalid: %{label: "Flag", renderer: &flag_cell/1},
qso_timestamp: %{label: "QSO (UTC)", sortable: true, renderer: &format_ts/1},
inserted_at: %{label: "Added (UTC)", sortable: true, renderer: &format_ts/1}
inserted_at: %{label: "Added (UTC)", sortable: true, renderer: &format_ts/1},
private: %{label: "Private", sortable: true, renderer: &private_cell/1}
]
end
@ -48,14 +53,55 @@ defmodule MicrowavepropWeb.ContactLive.Index do
@impl true
def mount(_params, _session, socket) do
total = Repo.aggregate(Contact, :count, :id)
scope = socket.assigns[:current_scope]
base_query = visible_query(scope)
total = Repo.aggregate(base_query, :count, :id)
{:ok,
socket
|> assign(:page_title, "Contacts")
|> assign(:total_contacts, total)}
|> assign(:total_contacts, total)
|> assign(:data_provider, {__MODULE__, :visible_query_provider, [scope_token(scope)]})}
end
@doc false
# Called by LiveTable to get the base queryable. Must be a
# named function because the helper persists MFA across renders.
def visible_query_provider(scope_token) do
scope_token
|> scope_from_token()
|> visible_query()
|> from(as: :resource)
end
defp visible_query(%Scope{user: %User{is_admin: true}}), do: Contact
defp visible_query(%Scope{user: %User{id: user_id}}) do
from(c in Contact, where: c.private == false or c.user_id == ^user_id)
end
defp visible_query(_), do: from(c in Contact, where: c.private == false)
# The data_provider MFA is serialized into the socket; a raw %Scope{}
# with associations isn't safe to store there. Collapse to the minimum
# needed to rebuild visibility and rehydrate on each invocation.
defp scope_token(%Scope{user: %User{id: id, is_admin: is_admin}}), do: {id, is_admin}
defp scope_token(_), do: nil
defp scope_from_token({id, true}), do: %Scope{user: %User{id: id, is_admin: true}}
defp scope_from_token({id, false}), do: %Scope{user: %User{id: id, is_admin: false}}
defp scope_from_token(nil), do: nil
defp private_cell(true) do
assigns = %{}
~H"""
<span class="badge badge-sm badge-warning">Yes</span>
"""
end
defp private_cell(_), do: ""
@enrichment_fields [
{:hrrr_status, "HRRR"},
{:weather_status, "Weather"},

View file

@ -32,7 +32,13 @@ defmodule MicrowavepropWeb.ContactLive.Show do
@impl true
def mount(%{"id" => id}, session, socket) do
contact = id |> Radio.get_contact!() |> Radio.ensure_positions!()
contact = Radio.get_contact!(id)
if !Radio.can_view?(contact, socket.assigns[:current_scope]) do
raise Ecto.NoResultsError, queryable: Microwaveprop.Radio.Contact
end
contact = Radio.ensure_positions!(contact)
can_enqueue = internal_network?(session)
socket =
@ -266,7 +272,8 @@ defmodule MicrowavepropWeb.ContactLive.Show do
"qso_timestamp" =>
if(contact.qso_timestamp, do: Calendar.strftime(contact.qso_timestamp, "%Y-%m-%d %H:%M"), else: ""),
"height1_ft" => contact.height1_ft,
"height2_ft" => contact.height2_ft
"height2_ft" => contact.height2_ft,
"private" => contact.private
}
{:noreply, assign(socket, editing: true, edit_form: to_form(form_data, as: "edit"))}
@ -861,6 +868,14 @@ defmodule MicrowavepropWeb.ContactLive.Show do
<%= if @contact.flagged_invalid do %>
<span class="badge badge-error badge-sm ml-2">Flagged Invalid</span>
<% end %>
<%= if @contact.private do %>
<span
class="badge badge-warning badge-sm ml-2"
title="Only visible to you and administrators"
>
<.icon name="hero-lock-closed" class="w-3 h-3 mr-1" /> Private
</span>
<% end %>
</:subtitle>
<:actions>
<%= if current_user(assigns) do %>
@ -957,6 +972,13 @@ defmodule MicrowavepropWeb.ContactLive.Show do
pattern="\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?Z?"
/>
</div>
<div class="mt-2">
<.input
field={@edit_form[:private]}
type="checkbox"
label="Private — only visible to me and administrators"
/>
</div>
<div class="flex gap-2">
<.button type="submit" class="btn btn-primary btn-sm">
<.icon name="hero-paper-airplane" class="w-4 h-4" />

View file

@ -119,6 +119,8 @@ defmodule MicrowavepropWeb.SubmitLive do
_ -> params |> Map.get("submitter_email", "") |> String.trim()
end
private = Map.get(params, "private") == "true"
if email == "" do
{:noreply, put_flash(socket, :error, "Email is required for CSV upload")}
else
@ -129,7 +131,7 @@ defmodule MicrowavepropWeb.SubmitLive do
case uploaded_contents do
[content] ->
handle_csv_preview(content, email, socket)
handle_csv_preview(content, email, private, socket)
[] ->
{:noreply, put_flash(socket, :error, "Please select a CSV file")}
@ -148,6 +150,8 @@ defmodule MicrowavepropWeb.SubmitLive do
_ -> params |> Map.get("submitter_email", "") |> String.trim()
end
private = Map.get(params, "private") == "true"
if email == "" do
{:noreply, put_flash(socket, :error, "Email is required for ADIF upload")}
else
@ -158,7 +162,7 @@ defmodule MicrowavepropWeb.SubmitLive do
case uploaded_contents do
[content] ->
handle_adif_preview(content, email, socket)
handle_adif_preview(content, email, private, socket)
[] ->
{:noreply, put_flash(socket, :error, "Please select an ADIF file")}
@ -174,11 +178,12 @@ defmodule MicrowavepropWeb.SubmitLive do
case socket.assigns.csv_preview do
%{valid: valid_rows, refinements: refinements} = preview
when valid_rows != [] or refinements != [] ->
{:ok, run_id} = CsvImport.enqueue(preview, socket.assigns.csv_email)
private = socket.assigns[:csv_private] || false
{:ok, run_id} = CsvImport.enqueue(preview, socket.assigns.csv_email, private: private)
{:noreply,
socket
|> assign(csv_preview: nil)
|> assign(csv_preview: nil, csv_private: false)
|> push_navigate(to: ~p"/imports/#{run_id}")}
_ ->
@ -187,23 +192,23 @@ defmodule MicrowavepropWeb.SubmitLive do
end
def handle_event("cancel_csv", _params, socket) do
{:noreply, assign(socket, csv_preview: nil)}
{:noreply, assign(socket, csv_preview: nil, csv_private: false)}
end
defp handle_adif_preview(content, email, socket) do
defp handle_adif_preview(content, email, private, socket) do
case AdifImport.preview(content, email) do
{:ok, preview} ->
{:noreply, assign(socket, csv_preview: preview, csv_email: email)}
{:noreply, assign(socket, csv_preview: preview, csv_email: email, csv_private: private)}
{:error, :no_records} ->
{:noreply, put_flash(socket, :error, "ADIF file contains no records")}
end
end
defp handle_csv_preview(content, email, socket) do
defp handle_csv_preview(content, email, private, socket) do
case CsvImport.preview(content, email) do
{:ok, preview} ->
{:noreply, assign(socket, csv_preview: preview, csv_email: email)}
{:noreply, assign(socket, csv_preview: preview, csv_email: email, csv_private: private)}
{:error, :empty_csv} ->
{:noreply, put_flash(socket, :error, "CSV file is empty")}
@ -408,6 +413,14 @@ defmodule MicrowavepropWeb.SubmitLive do
/>
<% end %>
<div class="mt-4">
<.input
field={@form[:private]}
type="checkbox"
label="Private — only visible to me and administrators"
/>
</div>
<div class="mt-6">
<.button phx-disable-with="Submitting..." class="btn btn-primary btn-lg w-full sm:w-auto">
<.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Submit Contact
@ -502,6 +515,11 @@ defmodule MicrowavepropWeb.SubmitLive do
</div>
<% end %>
<label class="flex items-center gap-2 cursor-pointer mt-2">
<input type="checkbox" name="private" value="true" class="checkbox checkbox-sm" />
<span class="text-sm">Private only visible to me and administrators</span>
</label>
<div class="mt-6">
<button type="submit" class="btn btn-primary btn-lg w-full sm:w-auto">
<.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Upload Contacts
@ -622,6 +640,11 @@ defmodule MicrowavepropWeb.SubmitLive do
</div>
<% end %>
<label class="flex items-center gap-2 cursor-pointer mt-2">
<input type="checkbox" name="private" value="true" class="checkbox checkbox-sm" />
<span class="text-sm">Private only visible to me and administrators</span>
</label>
<div class="mt-6">
<button type="submit" class="btn btn-primary btn-lg w-full sm:w-auto">
<.icon name="hero-arrow-up-tray" class="w-5 h-5" /> Upload Contacts

View file

@ -0,0 +1,14 @@
defmodule Microwaveprop.Repo.Migrations.AddPrivateToContacts do
use Ecto.Migration
def change do
alter table(:contacts) do
add :private, :boolean, null: false, default: false
end
create index(:contacts, [:user_id],
where: "private = true",
name: :contacts_private_user_id_idx
)
end
end

View file

@ -177,6 +177,28 @@ defmodule Microwaveprop.Radio.ContactEditTest do
refute changeset.valid?
end
test "accepts boolean private flag", %{contact: contact, user: user} do
changeset =
ContactEdit.changeset(%ContactEdit{}, %{
contact_id: contact.id,
user_id: user.id,
proposed_changes: %{"private" => true}
})
assert changeset.valid?
end
test "rejects non-boolean private", %{contact: contact, user: user} do
changeset =
ContactEdit.changeset(%ContactEdit{}, %{
contact_id: contact.id,
user_id: user.id,
proposed_changes: %{"private" => "yes"}
})
refute changeset.valid?
end
test "validates mode value", %{contact: contact, user: user} do
changeset =
ContactEdit.changeset(%ContactEdit{}, %{

View file

@ -97,6 +97,17 @@ defmodule Microwaveprop.Radio.ContactSubmissionTest do
assert errors_on(changeset).submitter_email
end
test "accepts private flag and defaults to false" do
changeset = Contact.submission_changeset(%Contact{}, @valid_attrs)
assert changeset.valid?
assert Ecto.Changeset.get_field(changeset, :private) == false
attrs = Map.put(@valid_attrs, :private, true)
changeset = Contact.submission_changeset(%Contact{}, attrs)
assert changeset.valid?
assert Ecto.Changeset.get_field(changeset, :private) == true
end
test "does not cast user_submitted" do
attrs = Map.put(@valid_attrs, :user_submitted, true)
changeset = Contact.submission_changeset(%Contact{}, attrs)

View file

@ -3,6 +3,7 @@ defmodule Microwaveprop.RadioTest do
import Microwaveprop.AccountsFixtures
alias Microwaveprop.Accounts.Scope
alias Microwaveprop.Radio
alias Microwaveprop.Radio.Contact
@ -497,6 +498,99 @@ defmodule Microwaveprop.RadioTest do
user = user_fixture()
assert Radio.list_contacts_for_user(user) == []
end
test "includes private contacts owned by the user" do
user = user_fixture()
private = create_contact(%{station1: "W5PRV", user_id: user.id, private: true})
assert [found] = Radio.list_contacts_for_user(user)
assert found.id == private.id
end
end
describe "contact_map_payload/0 visibility" do
test "excludes private contacts from the cached map payload" do
_private = create_contact(%{station1: "W5PRV", private: true})
public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
%{json: json, count: count} = Radio.contact_map_payload()
assert count == 1
decoded = json |> IO.iodata_to_binary() |> Jason.decode!()
assert [[_, _, _, _, _, "W5PUB" | _]] = decoded
refute to_string(IO.iodata_to_binary(json)) =~ "W5PRV"
_ = public
end
end
describe "can_view?/2" do
test "public contact is viewable by anyone" do
contact = create_contact(%{private: false})
assert Radio.can_view?(contact, nil) == true
assert Radio.can_view?(contact, %Scope{user: user_fixture()}) == true
end
test "private contact hidden from anonymous and non-owner" do
owner = user_fixture()
contact = create_contact(%{private: true, user_id: owner.id})
assert Radio.can_view?(contact, nil) == false
other = user_fixture()
assert Radio.can_view?(contact, %Scope{user: other}) == false
end
test "private contact visible to owner and admin" do
owner = user_fixture()
admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
contact = create_contact(%{private: true, user_id: owner.id})
assert Radio.can_view?(contact, %Scope{user: owner}) == true
assert Radio.can_view?(contact, %Scope{user: admin}) == true
end
end
describe "list_contacts/2 scope-aware visibility" do
test "anonymous viewer sees only non-private" do
_private = create_contact(%{station1: "W5PRV", private: true})
public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
%{entries: entries} = Radio.list_contacts(scope: nil)
assert Enum.map(entries, & &1.id) == [public.id]
end
test "owner sees their own private contacts" do
owner = user_fixture()
scope = %Scope{user: owner}
private = create_contact(%{station1: "W5PRV", private: true, user_id: owner.id})
public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
%{entries: entries} = Radio.list_contacts(scope: scope)
ids = entries |> Enum.map(& &1.id) |> Enum.sort()
assert Enum.sort([private.id, public.id]) == ids
end
test "admin sees all private contacts" do
admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
scope = %Scope{user: admin}
_other_user = user_fixture()
private = create_contact(%{station1: "W5PRV", private: true})
public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
%{entries: entries} = Radio.list_contacts(scope: scope)
ids = entries |> Enum.map(& &1.id) |> Enum.sort()
assert Enum.sort([private.id, public.id]) == ids
end
test "non-owning non-admin cannot see other users' private" do
someone_else = user_fixture()
viewer = user_fixture()
scope = %Scope{user: viewer}
_private = create_contact(%{station1: "W5PRV", private: true, user_id: someone_else.id})
public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
%{entries: entries} = Radio.list_contacts(scope: scope)
assert Enum.map(entries, & &1.id) == [public.id]
end
end
describe "list_contacts_involving_callsign/1" do
@ -553,6 +647,14 @@ defmodule Microwaveprop.RadioTest do
assert Radio.list_contacts_involving_callsign(nil) == []
end
test "excludes private contacts" do
_private = create_contact(%{station1: "W5ISP", station2: "K5OTR", private: true})
visible = create_contact(%{station1: "W5ISP", station2: "K5OTR", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
assert [found] = Radio.list_contacts_involving_callsign("W5ISP")
assert found.id == visible.id
end
test "caps the result at the 100 most recent contacts" do
base = ~U[2020-01-01 00:00:00Z]

View file

@ -117,6 +117,98 @@ defmodule MicrowavepropWeb.ContactLiveTest do
end
end
describe "Index private visibility" do
import Microwaveprop.AccountsFixtures
test "anonymous viewer does not see private contacts in the table", %{conn: conn} do
_private = create_contact(%{station1: "W5PRV", private: true})
_public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
{:ok, _lv, html} = live(conn, ~p"/contacts")
assert html =~ "W5PUB"
refute html =~ "W5PRV"
end
test "owner sees their own private inline with Yes badge", %{conn: conn} do
owner = user_fixture()
_private = create_contact(%{station1: "W5PRV", private: true, user_id: owner.id})
_public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
conn = log_in_user(conn, owner)
{:ok, _lv, html} = live(conn, ~p"/contacts")
assert html =~ "W5PRV"
assert html =~ "W5PUB"
assert html =~ "badge-warning"
end
test "admin sees all private contacts", %{conn: conn} do
admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
_private = create_contact(%{station1: "W5PRV", private: true})
_public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
conn = log_in_user(conn, admin)
{:ok, _lv, html} = live(conn, ~p"/contacts")
assert html =~ "W5PRV"
assert html =~ "W5PUB"
end
test "non-owning user does not see others' private", %{conn: conn} do
owner = user_fixture()
viewer = user_fixture()
_private = create_contact(%{station1: "W5PRV", private: true, user_id: owner.id})
_public = create_contact(%{station1: "W5PUB", qso_timestamp: ~U[2026-03-28 19:00:00Z]})
conn = log_in_user(conn, viewer)
{:ok, _lv, html} = live(conn, ~p"/contacts")
assert html =~ "W5PUB"
refute html =~ "W5PRV"
end
end
describe "Show private visibility" do
import Microwaveprop.AccountsFixtures
test "anonymous viewer 404s on a private contact", %{conn: conn} do
contact = create_contact(%{private: true})
assert_raise Ecto.NoResultsError, fn ->
live(conn, ~p"/contacts/#{contact.id}")
end
end
test "non-owning user 404s on a private contact", %{conn: conn} do
owner = user_fixture()
other = user_fixture()
contact = create_contact(%{private: true, user_id: owner.id})
conn = log_in_user(conn, other)
assert_raise Ecto.NoResultsError, fn ->
live(conn, ~p"/contacts/#{contact.id}")
end
end
test "owner can view their own private contact", %{conn: conn} do
owner = user_fixture()
contact = create_contact(%{private: true, user_id: owner.id})
conn = log_in_user(conn, owner)
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
assert html =~ "W5XD"
end
test "admin can view anyone's private contact", %{conn: conn} do
owner = user_fixture()
admin = user_fixture() |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
contact = create_contact(%{private: true, user_id: owner.id})
conn = log_in_user(conn, admin)
{:ok, _lv, html} = live(conn, ~p"/contacts/#{contact.id}")
assert html =~ "W5XD"
end
end
describe "Show" do
test "renders contact detail fields", %{conn: conn} do
contact = create_contact()