diff --git a/lib/microwaveprop/beacons.ex b/lib/microwaveprop/beacons.ex index 7574eb2f..e0f917af 100644 --- a/lib/microwaveprop/beacons.ex +++ b/lib/microwaveprop/beacons.ex @@ -1,8 +1,8 @@ defmodule Microwaveprop.Beacons do @moduledoc """ - The Beacons context. Beacon records are public (anyone can read) - but only admin users can create/update/delete them — that gating - is enforced by the router's live_session for the form views. + The Beacons context. Any authenticated user can submit a beacon, + but submissions are held as unapproved until an admin approves + them. Only approved beacons appear in the public list. """ import Ecto.Query, warn: false @@ -28,9 +28,22 @@ defmodule Microwaveprop.Beacons do Phoenix.PubSub.broadcast(Microwaveprop.PubSub, @topic, message) end - @doc "Returns all beacons ordered by frequency." + @doc "Returns approved beacons ordered by frequency." def list_beacons do - Repo.all(from b in Beacon, order_by: [asc: b.frequency_mhz, asc: b.callsign]) + Repo.all( + from b in Beacon, + where: b.approved == true, + order_by: [asc: b.frequency_mhz, asc: b.callsign] + ) + end + + @doc "Returns unapproved beacons awaiting admin review." + def list_pending_beacons do + Repo.all( + from b in Beacon, + where: b.approved == false, + order_by: [asc: b.inserted_at] + ) end @doc "Gets a single beacon. Raises if not found." @@ -54,6 +67,14 @@ defmodule Microwaveprop.Beacons do |> broadcast_if_ok(:updated) end + @doc "Marks a beacon as approved, making it visible in the public list." + def approve_beacon(%Beacon{} = beacon) do + beacon + |> Ecto.Changeset.change(approved: true) + |> Repo.update() + |> broadcast_if_ok(:updated) + end + @doc "Deletes a beacon." def delete_beacon(%Beacon{} = beacon) do case Repo.delete(beacon) do diff --git a/lib/microwaveprop/beacons/beacon.ex b/lib/microwaveprop/beacons/beacon.ex index a7a4b355..8672bdd9 100644 --- a/lib/microwaveprop/beacons/beacon.ex +++ b/lib/microwaveprop/beacons/beacon.ex @@ -22,6 +22,7 @@ defmodule Microwaveprop.Beacons.Beacon do field :power_mw, :float field :height_ft, :float field :on_the_air, :boolean, default: true + field :approved, :boolean, default: false field :user_id, :binary_id timestamps(type: :utc_datetime) diff --git a/lib/microwaveprop/beacons/range_estimate.ex b/lib/microwaveprop/beacons/range_estimate.ex index edb59bf4..56f63831 100644 --- a/lib/microwaveprop/beacons/range_estimate.ex +++ b/lib/microwaveprop/beacons/range_estimate.ex @@ -48,8 +48,7 @@ defmodule Microwaveprop.Beacons.RangeEstimate do """ @spec nearest_band_mhz(number()) :: integer() def nearest_band_mhz(freq_mhz) when is_number(freq_mhz) do - BandConfig.all_freqs() - |> Enum.min_by(fn b -> abs(b - freq_mhz) end) + Enum.min_by(BandConfig.all_freqs(), fn b -> abs(b - freq_mhz) end) end @doc """ diff --git a/lib/microwaveprop_web/live/beacon_live/index.ex b/lib/microwaveprop_web/live/beacon_live/index.ex index c160f681..8997adc0 100644 --- a/lib/microwaveprop_web/live/beacon_live/index.ex +++ b/lib/microwaveprop_web/live/beacon_live/index.ex @@ -12,8 +12,12 @@ defmodule MicrowavepropWeb.BeaconLive.Index do Beacons <:subtitle>Microwave beacons tracked by NTMS. <:actions> - <.button :if={admin?(@current_scope)} variant="primary" navigate={~p"/beacons/new"}> - <.icon name="hero-plus" /> New Beacon + <.button + :if={authenticated?(@current_scope)} + variant="primary" + navigate={~p"/beacons/new"} + > + <.icon name="hero-plus" /> Submit Beacon @@ -28,10 +32,10 @@ defmodule MicrowavepropWeb.BeaconLive.Index do <:col :let={{_id, beacon}} label="Grid">{beacon.grid} <:col :let={{_id, beacon}} label="Lat">{beacon.lat} <:col :let={{_id, beacon}} label="Lon">{beacon.lon} - <:col :let={{_id, beacon}} label="EIRP (mW)">{beacon.power_mw} + <:col :let={{_id, beacon}} label="EIRP (mW)">{format_mw(beacon.power_mw)} <:col :let={{_id, beacon}} label="Height AGL (ft)">{beacon.height_ft} <:col :let={{_id, beacon}} label="On air"> - + {if beacon.on_the_air, do: "Yes", else: "No"} @@ -51,6 +55,44 @@ defmodule MicrowavepropWeb.BeaconLive.Index do + +
+ <.header> + Pending approval + <:subtitle>Submitted beacons waiting for admin review. + + + <.table + id="pending-beacons" + rows={@streams.pending} + row_click={fn {_id, beacon} -> JS.navigate(~p"/beacons/#{beacon}") end} + > + <:col :let={{_id, beacon}} label="Frequency (MHz)">{beacon.frequency_mhz} + <:col :let={{_id, beacon}} label="Call">{beacon.callsign} + <:col :let={{_id, beacon}} label="Grid">{beacon.grid} + <:col :let={{_id, beacon}} label="EIRP (mW)">{format_mw(beacon.power_mw)} + <:col :let={{_id, beacon}} label="Height AGL (ft)">{beacon.height_ft} + <:col :let={{_id, beacon}} label="Submitted"> + {Calendar.strftime(beacon.inserted_at, "%Y-%m-%d %H:%M UTC")} + + <:action :let={{_id, beacon}}> + <.link + phx-click={JS.push("approve", value: %{id: beacon.id})} + data-confirm="Approve this beacon?" + > + Approve + + + <:action :let={{id, beacon}}> + <.link + phx-click={JS.push("delete", value: %{id: beacon.id}) |> hide("##{id}")} + data-confirm="Delete this beacon?" + > + Delete + + + +
""" end @@ -59,10 +101,19 @@ defmodule MicrowavepropWeb.BeaconLive.Index do def mount(_params, _session, socket) do if connected?(socket), do: Beacons.subscribe_beacons() + pending = + if admin?(socket.assigns.current_scope) do + Beacons.list_pending_beacons() + else + [] + end + {:ok, socket |> assign(:page_title, "Beacons") - |> stream(:beacons, Beacons.list_beacons())} + |> assign(:pending, pending) + |> stream(:beacons, Beacons.list_beacons()) + |> stream(:pending, pending)} end @impl true @@ -70,7 +121,26 @@ defmodule MicrowavepropWeb.BeaconLive.Index do if admin?(socket.assigns.current_scope) do beacon = Beacons.get_beacon!(id) {:ok, _} = Beacons.delete_beacon(beacon) - {:noreply, stream_delete(socket, :beacons, beacon)} + + {:noreply, + socket + |> stream_delete(:beacons, beacon) + |> stream_delete(:pending, beacon)} + else + {:noreply, put_flash(socket, :error, "Admins only.")} + end + end + + def handle_event("approve", %{"id" => id}, socket) do + if admin?(socket.assigns.current_scope) do + beacon = Beacons.get_beacon!(id) + {:ok, approved} = Beacons.approve_beacon(beacon) + + {:noreply, + socket + |> put_flash(:info, "Approved #{approved.callsign}.") + |> stream_delete(:pending, beacon) + |> stream_insert(:beacons, approved)} else {:noreply, put_flash(socket, :error, "Admins only.")} end @@ -78,9 +148,45 @@ defmodule MicrowavepropWeb.BeaconLive.Index do @impl true def handle_info({type, %Microwaveprop.Beacons.Beacon{}}, socket) when type in [:created, :updated, :deleted] do - {:noreply, stream(socket, :beacons, Beacons.list_beacons(), reset: true)} + pending = + if admin?(socket.assigns.current_scope) do + Beacons.list_pending_beacons() + else + [] + end + + {:noreply, + socket + |> assign(:pending, pending) + |> stream(:beacons, Beacons.list_beacons(), reset: true) + |> stream(:pending, pending, reset: true)} end defp admin?(%{user: %{is_admin: true}}), do: true defp admin?(_), do: false + + defp authenticated?(%{user: %{}}), do: true + defp authenticated?(_), do: false + + # Format a milliwatt power value without scientific notation. + # Keeps up to 3 decimal places, drops trailing zeros. + defp format_mw(nil), do: "" + + defp format_mw(mw) when is_float(mw) do + if mw == trunc(mw) do + Integer.to_string(trunc(mw)) + else + mw |> :erlang.float_to_binary(decimals: 3) |> trim_trailing_zeros() + end + end + + defp format_mw(mw), do: to_string(mw) + + defp trim_trailing_zeros(str) do + if String.contains?(str, ".") do + str |> String.trim_trailing("0") |> String.trim_trailing(".") + else + str + end + end end diff --git a/lib/microwaveprop_web/live/beacon_live/show.ex b/lib/microwaveprop_web/live/beacon_live/show.ex index 7b097ab1..1ecb6ab3 100644 --- a/lib/microwaveprop_web/live/beacon_live/show.ex +++ b/lib/microwaveprop_web/live/beacon_live/show.ex @@ -12,8 +12,20 @@ defmodule MicrowavepropWeb.BeaconLive.Show do <.header> {@beacon.callsign} - <:subtitle>{@beacon.frequency_mhz} MHz · {@beacon.grid} + <:subtitle> + {@beacon.frequency_mhz} MHz · {@beacon.grid} + + Pending approval + + <:actions> + <.button + :if={admin?(@current_scope) and not @beacon.approved} + variant="primary" + phx-click="approve" + > + <.icon name="hero-check" /> Approve + <.button navigate={~p"/beacons"}> <.icon name="hero-arrow-left" /> @@ -54,7 +66,8 @@ defmodule MicrowavepropWeb.BeaconLive.Show do <% else %> (no HRRR data — defaulting cells to 50) <% end %> - · {length(@estimate.cells)} cells rendered + · {length(@estimate.cells)} + cells rendered
@@ -67,8 +80,7 @@ defmodule MicrowavepropWeb.BeaconLive.Show do > - {tier.label} - (≥ {tier.min_dbm} dBm) + {tier.label} (≥ {tier.min_dbm} dBm) <% end %> @@ -101,6 +113,20 @@ defmodule MicrowavepropWeb.BeaconLive.Show do |> assign(:estimate, RangeEstimate.estimate(beacon))} end + @impl true + def handle_event("approve", _params, socket) do + if admin?(socket.assigns.current_scope) do + {:ok, approved} = Beacons.approve_beacon(socket.assigns.beacon) + + {:noreply, + socket + |> assign(:beacon, approved) + |> put_flash(:info, "Beacon approved.")} + else + {:noreply, put_flash(socket, :error, "Admins only.")} + end + end + @impl true def handle_info({:updated, %Beacon{id: id} = beacon}, %{assigns: %{beacon: %{id: id}}} = socket) do {:noreply, diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex index c0686ec1..f872abd1 100644 --- a/lib/microwaveprop_web/router.ex +++ b/lib/microwaveprop_web/router.ex @@ -27,13 +27,17 @@ defmodule MicrowavepropWeb.Router do # Health check — no pipeline, minimal overhead get "/health", MicrowavepropWeb.HealthController, :check, log: false - # Admin-only routes must come first so that literal segments like - # `/beacons/new` are registered before the public `/beacons/:id`. + # Authenticated (non-admin) and admin-only routes must come first so that + # literal segments like `/beacons/new` are registered before the public + # `/beacons/:id`. scope "/", MicrowavepropWeb do pipe_through [:browser, :require_authenticated_user] - live_session :admin, on_mount: [{MicrowavepropWeb.UserAuth, :require_admin}] do + live_session :authenticated, on_mount: [{MicrowavepropWeb.UserAuth, :default}] do live "/beacons/new", BeaconLive.Form, :new + end + + live_session :admin, on_mount: [{MicrowavepropWeb.UserAuth, :require_admin}] do live "/beacons/:id/edit", BeaconLive.Form, :edit live "/users", UserManagementLive.Index, :index diff --git a/priv/repo/migrations/20260408192634_add_approved_to_beacons.exs b/priv/repo/migrations/20260408192634_add_approved_to_beacons.exs new file mode 100644 index 00000000..32619c10 --- /dev/null +++ b/priv/repo/migrations/20260408192634_add_approved_to_beacons.exs @@ -0,0 +1,12 @@ +defmodule Microwaveprop.Repo.Migrations.AddApprovedToBeacons do + use Ecto.Migration + + def change do + alter table(:beacons) do + add :approved, :boolean, null: false, default: false + end + + # Existing beacons were admin-created, so mark them approved. + execute "UPDATE beacons SET approved = true", "" + end +end diff --git a/test/microwaveprop/beacons_test.exs b/test/microwaveprop/beacons_test.exs index 4d35f8d4..4c8b8d6c 100644 --- a/test/microwaveprop/beacons_test.exs +++ b/test/microwaveprop/beacons_test.exs @@ -18,12 +18,36 @@ defmodule Microwaveprop.BeaconsTest do } describe "list_beacons/0" do - test "returns all beacons regardless of creator" do + test "returns only approved beacons" do u1 = user_fixture() u2 = user_fixture() - b1 = beacon_fixture(u1) - b2 = beacon_fixture(u2, callsign: "W5TX") - assert [^b1, ^b2] = Enum.sort_by(Beacons.list_beacons(), & &1.callsign) + {:ok, b1} = Beacons.create_beacon(u1, valid_beacon_attrs()) + {:ok, b1} = Beacons.approve_beacon(b1) + _pending = beacon_fixture(u2, callsign: "W5TX") + assert [^b1] = Beacons.list_beacons() + end + end + + describe "list_pending_beacons/0" do + test "returns only unapproved beacons" do + u1 = user_fixture() + u2 = user_fixture() + {:ok, approved} = Beacons.create_beacon(u1, valid_beacon_attrs()) + {:ok, _approved} = Beacons.approve_beacon(approved) + pending = beacon_fixture(u2, callsign: "W5TX") + assert [^pending] = Beacons.list_pending_beacons() + end + end + + describe "approve_beacon/1" do + test "marks the beacon as approved and broadcasts an update" do + user = user_fixture() + beacon = beacon_fixture(user) + refute beacon.approved + Beacons.subscribe_beacons() + assert {:ok, approved} = Beacons.approve_beacon(beacon) + assert approved.approved == true + assert_receive {:updated, %Beacon{approved: true}} end end @@ -50,6 +74,19 @@ defmodule Microwaveprop.BeaconsTest do assert beacon.user_id == user.id end + test "new beacons default to unapproved" do + user = user_fixture() + assert {:ok, beacon} = Beacons.create_beacon(user, valid_beacon_attrs()) + assert beacon.approved == false + end + + test "ignores client-supplied approved value" do + user = user_fixture() + attrs = Map.put(valid_beacon_attrs(), :approved, true) + assert {:ok, beacon} = Beacons.create_beacon(user, attrs) + assert beacon.approved == false + end + test "derives grid from lat/lon when grid is omitted" do user = user_fixture() # DFW is EM12 @@ -61,7 +98,8 @@ defmodule Microwaveprop.BeaconsTest do user = user_fixture() attrs = - valid_beacon_attrs(grid: "EM12kp") + [grid: "EM12kp"] + |> valid_beacon_attrs() |> Map.drop([:lat, :lon]) assert {:ok, beacon} = Beacons.create_beacon(user, attrs) diff --git a/test/microwaveprop_web/live/beacon_live_test.exs b/test/microwaveprop_web/live/beacon_live_test.exs index 9cc5ff0c..5c9868a1 100644 --- a/test/microwaveprop_web/live/beacon_live_test.exs +++ b/test/microwaveprop_web/live/beacon_live_test.exs @@ -16,63 +16,112 @@ defmodule MicrowavepropWeb.BeaconLiveTest do promote_to_admin(user_fixture()) end + defp approved_beacon_fixture(user, attrs \\ %{}) do + beacon = beacon_fixture(user, attrs) + {:ok, beacon} = Microwaveprop.Beacons.approve_beacon(beacon) + beacon + end + describe "Index (public)" do - test "lists beacons without logging in", %{conn: conn} do - beacon = beacon_fixture(user_fixture()) + test "lists approved beacons without logging in", %{conn: conn} do + beacon = approved_beacon_fixture(user_fixture()) {:ok, _view, html} = live(conn, ~p"/beacons") assert html =~ "Beacons" assert html =~ beacon.callsign end - test "non-admin does not see the New Beacon button", %{conn: conn} do + test "hides unapproved beacons from the public list", %{conn: conn} do + _pending = beacon_fixture(user_fixture(), callsign: "W5PEN") + {:ok, _view, html} = live(conn, ~p"/beacons") + refute html =~ "W5PEN" + end + + test "anonymous does not see the Submit Beacon button", %{conn: conn} do + {:ok, _view, html} = live(conn, ~p"/beacons") + refute html =~ "Submit Beacon" + end + + test "authenticated user sees the Submit Beacon button", %{conn: conn} do user = user_fixture() conn = log_in_user(conn, user) {:ok, _view, html} = live(conn, ~p"/beacons") - refute html =~ "New Beacon" + assert html =~ "Submit Beacon" end - test "admin sees the New Beacon button", %{conn: conn} do + test "admin sees pending beacons section", %{conn: conn} do + pending = beacon_fixture(user_fixture(), callsign: "W5PEN") conn = log_in_user(conn, admin_user_fixture()) {:ok, _view, html} = live(conn, ~p"/beacons") - assert html =~ "New Beacon" + assert html =~ "Pending approval" + assert html =~ pending.callsign + end + + test "non-admin does not see pending beacons section", %{conn: conn} do + _pending = beacon_fixture(user_fixture(), callsign: "W5PEN") + conn = log_in_user(conn, user_fixture()) + {:ok, _view, html} = live(conn, ~p"/beacons") + refute html =~ "Pending approval" + refute html =~ "W5PEN" + end + + test "admin can approve a pending beacon", %{conn: conn} do + pending = beacon_fixture(user_fixture(), callsign: "W5PEN") + conn = log_in_user(conn, admin_user_fixture()) + {:ok, view, _html} = live(conn, ~p"/beacons") + + html = + view + |> element("a[phx-click][data-confirm='Approve this beacon?']") + |> render_click() + + assert html =~ "Approved #{pending.callsign}" + assert Microwaveprop.Beacons.get_beacon!(pending.id).approved == true end end describe "Show (public)" do test "renders beacon details without logging in", %{conn: conn} do - beacon = beacon_fixture(user_fixture()) + beacon = approved_beacon_fixture(user_fixture()) {:ok, _view, html} = live(conn, ~p"/beacons/#{beacon}") assert html =~ beacon.callsign assert html =~ "#{beacon.frequency_mhz}" end + + test "shows pending badge on unapproved beacons", %{conn: conn} do + beacon = beacon_fixture(user_fixture()) + {:ok, _view, html} = live(conn, ~p"/beacons/#{beacon}") + assert html =~ "Pending approval" + end end - describe "Form (admin only)" do - test "redirects non-admin away from /beacons/new", %{conn: conn} do + describe "Form" do + test "authenticated non-admin can access /beacons/new", %{conn: conn} do user = user_fixture() conn = log_in_user(conn, user) - - assert {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/beacons/new") + {:ok, _view, html} = live(conn, ~p"/beacons/new") + assert html =~ "New beacon" end test "redirects anonymous user away from /beacons/new", %{conn: conn} do assert {:error, {:redirect, %{to: "/users/log-in"}}} = live(conn, ~p"/beacons/new") end - test "admin creates a beacon", %{conn: conn} do - conn = log_in_user(conn, admin_user_fixture()) + test "non-admin submission creates an unapproved beacon", %{conn: conn} do + user = user_fixture() + conn = log_in_user(conn, user) {:ok, form_live, _html} = live(conn, ~p"/beacons/new") attrs = valid_beacon_attrs() - assert {:ok, _view, html} = + assert {:ok, _view, _html} = form_live |> form("#beacon-form", beacon: attrs) |> render_submit() |> follow_redirect(conn, ~p"/beacons") - assert html =~ "Beacon created" - assert html =~ attrs.callsign + beacon = List.first(Microwaveprop.Beacons.list_pending_beacons()) + assert beacon.callsign == attrs.callsign + assert beacon.approved == false end test "admin edits a beacon", %{conn: conn} do @@ -90,5 +139,11 @@ defmodule MicrowavepropWeb.BeaconLiveTest do assert html =~ "Beacon updated" end + + test "non-admin cannot access /beacons/:id/edit", %{conn: conn} do + beacon = beacon_fixture(user_fixture()) + conn = log_in_user(conn, user_fixture()) + assert {:error, {:redirect, %{to: "/"}}} = live(conn, ~p"/beacons/#{beacon}/edit") + end end end