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:
parent
69f4c81899
commit
6c652ef2d4
12 changed files with 417 additions and 26 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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{}, %{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue