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 %> + +