diff --git a/lib/microwaveprop/radio.ex b/lib/microwaveprop/radio.ex
index 7e21833d..93bde5cf 100644
--- a/lib/microwaveprop/radio.ex
+++ b/lib/microwaveprop/radio.ex
@@ -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
diff --git a/lib/microwaveprop/radio/contact.ex b/lib/microwaveprop/radio/contact.ex
index c2a83011..6378a78f 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 :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
diff --git a/lib/microwaveprop/radio/contact_edit.ex b/lib/microwaveprop/radio/contact_edit.ex
index 55f58b49..df226129 100644
--- a/lib/microwaveprop/radio/contact_edit.ex
+++ b/lib/microwaveprop/radio/contact_edit.ex
@@ -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
diff --git a/lib/microwaveprop/radio/csv_import.ex b/lib/microwaveprop/radio/csv_import.ex
index 90d616ab..6776984f 100644
--- a/lib/microwaveprop/radio/csv_import.ex
+++ b/lib/microwaveprop/radio/csv_import.ex
@@ -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",
diff --git a/lib/microwaveprop_web/live/contact_live/index.ex b/lib/microwaveprop_web/live/contact_live/index.ex
index 67ffe483..8cb15e1a 100644
--- a/lib/microwaveprop_web/live/contact_live/index.ex
+++ b/lib/microwaveprop_web/live/contact_live/index.ex
@@ -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"""
+ Yes
+ """
+ end
+
+ defp private_cell(_), do: ""
+
@enrichment_fields [
{:hrrr_status, "HRRR"},
{:weather_status, "Weather"},
diff --git a/lib/microwaveprop_web/live/contact_live/show.ex b/lib/microwaveprop_web/live/contact_live/show.ex
index b71b8d31..8bd08404 100644
--- a/lib/microwaveprop_web/live/contact_live/show.ex
+++ b/lib/microwaveprop_web/live/contact_live/show.ex
@@ -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 %>
Flagged Invalid
<% end %>
+ <%= if @contact.private do %>
+
+ <.icon name="hero-lock-closed" class="w-3 h-3 mr-1" /> Private
+
+ <% end %>
<: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?"
/>
+
+ <.input
+ field={@edit_form[:private]}
+ type="checkbox"
+ label="Private — only visible to me and administrators"
+ />
+
<.button type="submit" class="btn btn-primary btn-sm">
<.icon name="hero-paper-airplane" class="w-4 h-4" />
diff --git a/lib/microwaveprop_web/live/submit_live.ex b/lib/microwaveprop_web/live/submit_live.ex
index 77d37be2..0eb85370 100644
--- a/lib/microwaveprop_web/live/submit_live.ex
+++ b/lib/microwaveprop_web/live/submit_live.ex
@@ -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 %>
+
+ <.input
+ field={@form[:private]}
+ type="checkbox"
+ label="Private — only visible to me and administrators"
+ />
+
+
<.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
<% end %>
+
+
<% end %>
+
+