diff --git a/lib/microwaveprop/rover.ex b/lib/microwaveprop/rover.ex index 89e0c124..958a0171 100644 --- a/lib/microwaveprop/rover.ex +++ b/lib/microwaveprop/rover.ex @@ -14,6 +14,7 @@ defmodule Microwaveprop.Rover do alias Microwaveprop.Radio.Maidenhead alias Microwaveprop.Repo alias Microwaveprop.Rover.FixedStation + alias Microwaveprop.Rover.Location @default_stations [ %{callsign: "W5LUA", grid: "EM13qc"}, @@ -123,6 +124,57 @@ defmodule Microwaveprop.Rover do end end + # ── Rover locations (globally visible, logged-in to mutate) ────────── + + @doc """ + Returns every rover location across all users, newest first. + Anonymous visitors can read this list; mutations require a User. + """ + @spec list_locations() :: [Location.t()] + def list_locations do + Location + |> order_by([l], desc: l.inserted_at) + |> Repo.all() + end + + @spec create_location(User.t(), map()) :: + {:ok, Location.t()} | {:error, Ecto.Changeset.t()} + def create_location(%User{} = user, attrs) do + %Location{user_id: user.id} + |> Location.changeset(attrs) + |> Repo.insert() + end + + @spec update_location(User.t(), Ecto.UUID.t(), map()) :: + {:ok, Location.t()} | {:error, :not_found | Ecto.Changeset.t()} + def update_location(%User{} = user, id, attrs) do + case fetch_owned_location(user, id) do + {:ok, location} -> + location + |> Location.changeset(attrs) + |> Repo.update() + + {:error, :not_found} -> + {:error, :not_found} + end + end + + @spec delete_location(User.t(), Ecto.UUID.t()) :: + {:ok, Location.t()} | {:error, :not_found} + def delete_location(%User{} = user, id) do + case fetch_owned_location(user, id) do + {:ok, location} -> Repo.delete(location) + {:error, :not_found} -> {:error, :not_found} + end + end + + defp fetch_owned_location(%User{id: user_id}, id) do + case Repo.get(Location, id) do + %Location{user_id: ^user_id} = loc -> {:ok, loc} + _ -> {:error, :not_found} + end + end + defp next_position(%User{id: user_id}) do query = from s in FixedStation, diff --git a/lib/microwaveprop/rover/location.ex b/lib/microwaveprop/rover/location.ex new file mode 100644 index 00000000..083549d6 --- /dev/null +++ b/lib/microwaveprop/rover/location.ex @@ -0,0 +1,44 @@ +defmodule Microwaveprop.Rover.Location do + @moduledoc """ + A globally-visible rover parking location contributed by a logged-in + user. Everyone can view the list; only authenticated users can create + entries (creator tracked via `user_id`). + + `status` is `:ideal` (recommended spot) or `:off_limits` (avoid — + trespass, no-go, etc.). + """ + use Ecto.Schema + + import Ecto.Changeset + + @statuses [:ideal, :off_limits] + + @primary_key {:id, :binary_id, autogenerate: true} + @foreign_key_type :binary_id + schema "rover_locations" do + field :lat, :float + field :lon, :float + field :status, Ecto.Enum, values: @statuses, default: :ideal + field :notes, :string + + belongs_to :user, Microwaveprop.Accounts.User + + timestamps(type: :utc_datetime) + end + + @type t :: %__MODULE__{} + + @spec statuses() :: [atom()] + def statuses, do: @statuses + + @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() + def changeset(location, attrs) do + location + |> cast(attrs, [:lat, :lon, :status, :notes]) + |> validate_required([:lat, :lon, :status]) + |> validate_number(:lat, greater_than_or_equal_to: -90.0, less_than_or_equal_to: 90.0) + |> validate_number(:lon, greater_than_or_equal_to: -180.0, less_than_or_equal_to: 180.0) + |> validate_inclusion(:status, @statuses) + |> validate_length(:notes, max: 4000) + end +end diff --git a/lib/microwaveprop_web/live/rover_locations_live.ex b/lib/microwaveprop_web/live/rover_locations_live.ex new file mode 100644 index 00000000..87b72693 --- /dev/null +++ b/lib/microwaveprop_web/live/rover_locations_live.ex @@ -0,0 +1,279 @@ +defmodule MicrowavepropWeb.RoverLocationsLive do + @moduledoc """ + Globally-shared list of rover-friendly (or off-limits) parking + locations. Everyone sees the list; only logged-in users can add or + edit entries (and only the creator can edit/delete their own). + """ + use MicrowavepropWeb, :live_view + + alias Microwaveprop.Accounts.User + alias Microwaveprop.Rover + alias Microwaveprop.Rover.Location + + @impl true + def mount(_params, _session, socket) do + {:ok, + assign(socket, + page_title: "Rover Locations", + locations: Rover.list_locations(), + form: nil, + editing_id: nil + )} + end + + @impl true + def handle_event("new", _params, socket) do + if user = current_user(socket) do + form = blank_form(user) + {:noreply, assign(socket, form: form, editing_id: nil)} + else + {:noreply, put_flash(socket, :error, "Sign in to add a location.")} + end + end + + def handle_event("edit", %{"id" => id}, socket) do + case current_user(socket) do + %User{id: user_id} -> + case Enum.find(socket.assigns.locations, &(&1.id == id)) do + %Location{user_id: ^user_id} = loc -> + cs = Location.changeset(loc, %{}) + {:noreply, assign(socket, form: to_form(cs), editing_id: id)} + + _ -> + {:noreply, put_flash(socket, :error, "You can only edit your own locations.")} + end + + _ -> + {:noreply, put_flash(socket, :error, "Sign in to edit a location.")} + end + end + + def handle_event("cancel", _, socket) do + {:noreply, assign(socket, form: nil, editing_id: nil)} + end + + def handle_event("validate", %{"location" => params}, socket) do + base = if socket.assigns.editing_id, do: location_for_edit(socket), else: %Location{} + cs = base |> Location.changeset(params) |> Map.put(:action, :validate) + {:noreply, assign(socket, form: to_form(cs))} + end + + def handle_event("save", %{"location" => params}, socket) do + user = current_user(socket) + + cond do + is_nil(user) -> + {:noreply, put_flash(socket, :error, "Sign in required.")} + + socket.assigns.editing_id -> + save_update(socket, user, params) + + true -> + save_create(socket, user, params) + end + end + + def handle_event("delete", %{"id" => id}, socket) do + case current_user(socket) do + %User{} = user -> + case Rover.delete_location(user, id) do + {:ok, _} -> + {:noreply, + socket + |> assign(locations: Rover.list_locations()) + |> put_flash(:info, "Location removed.")} + + {:error, :not_found} -> + {:noreply, put_flash(socket, :error, "You can only delete your own locations.")} + end + + _ -> + {:noreply, put_flash(socket, :error, "Sign in required.")} + end + end + + defp save_create(socket, user, params) do + case Rover.create_location(user, params) do + {:ok, _loc} -> + {:noreply, + socket + |> assign(form: nil, editing_id: nil, locations: Rover.list_locations()) + |> put_flash(:info, "Location added.")} + + {:error, cs} -> + {:noreply, assign(socket, form: to_form(cs))} + end + end + + defp save_update(socket, user, params) do + case Rover.update_location(user, socket.assigns.editing_id, params) do + {:ok, _loc} -> + {:noreply, + socket + |> assign(form: nil, editing_id: nil, locations: Rover.list_locations()) + |> put_flash(:info, "Location updated.")} + + {:error, :not_found} -> + {:noreply, put_flash(socket, :error, "You can only edit your own locations.")} + + {:error, cs} -> + {:noreply, assign(socket, form: to_form(cs))} + end + end + + defp location_for_edit(socket) do + Enum.find(socket.assigns.locations, &(&1.id == socket.assigns.editing_id)) || %Location{} + end + + defp blank_form(_user) do + %Location{} |> Location.changeset(%{}) |> to_form() + end + + defp current_user(%Phoenix.LiveView.Socket{assigns: assigns}), do: scope_user(assigns) + defp current_user(assigns) when is_map(assigns), do: scope_user(assigns) + + defp scope_user(assigns) do + case assigns[:current_scope] do + %{user: %User{} = user} -> user + _ -> nil + end + end + + defp can_edit?(_assigns, %Location{user_id: nil}), do: false + + defp can_edit?(assigns, %Location{user_id: uid}) do + case current_user(assigns) do + %User{id: ^uid} -> true + _ -> false + end + end + + defp status_label(:ideal), do: "Ideal" + defp status_label(:off_limits), do: "Off Limits" + + defp status_class(:ideal), do: "badge badge-success" + defp status_class(:off_limits), do: "badge badge-error" + + @impl true + def render(assigns) do + ~H""" + +
+
+
+

Rover Locations

+

+ Community-shared parking spots for portable rover ops. +

+
+ +
+ +

+ Sign in to add or edit locations. +

+ +
+

+ {if @editing_id, do: "Edit location", else: "New location"} +

+ <.form for={@form} phx-change="validate" phx-submit="save" id="location-form"> +
+ <.input + field={@form[:lat]} + type="number" + step="any" + label="Latitude" + placeholder="32.5" + /> + <.input + field={@form[:lon]} + type="number" + step="any" + label="Longitude" + placeholder="-97.5" + /> + <.input + field={@form[:status]} + type="select" + label="Status" + options={[{"Ideal", :ideal}, {"Off Limits", :off_limits}]} + /> +
+ <.input + field={@form[:notes]} + type="textarea" + label="Notes" + rows="3" + placeholder="Trail access, line-of-sight notes, contact info, etc." + /> +
+ + +
+ +
+ +
+ No locations yet. Be the first to share one. +
+ + +
+
+ """ + end +end diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex index 9d147487..380b7215 100644 --- a/lib/microwaveprop_web/router.ex +++ b/lib/microwaveprop_web/router.ex @@ -193,6 +193,7 @@ defmodule MicrowavepropWeb.Router do live "/eme", EmeLive live "/skewt", SkewtLive live "/rover", RoverLive + live "/rover-locations", RoverLocationsLive live "/algo", AlgoLive live "/about", AboutLive live "/privacy", PrivacyLive diff --git a/priv/repo/migrations/20260426180436_create_rover_locations.exs b/priv/repo/migrations/20260426180436_create_rover_locations.exs new file mode 100644 index 00000000..72150bbe --- /dev/null +++ b/priv/repo/migrations/20260426180436_create_rover_locations.exs @@ -0,0 +1,18 @@ +defmodule Microwaveprop.Repo.Migrations.CreateRoverLocations do + use Ecto.Migration + + def change do + create table(:rover_locations, primary_key: false) do + add :id, :binary_id, primary_key: true, null: false + add :user_id, references(:users, type: :binary_id, on_delete: :nilify_all), null: false + add :lat, :float, null: false + add :lon, :float, null: false + add :status, :string, null: false, default: "ideal" + add :notes, :text + timestamps(type: :utc_datetime) + end + + create index(:rover_locations, [:user_id]) + create index(:rover_locations, [:status]) + end +end diff --git a/test/microwaveprop/rover/location_test.exs b/test/microwaveprop/rover/location_test.exs new file mode 100644 index 00000000..185cf223 --- /dev/null +++ b/test/microwaveprop/rover/location_test.exs @@ -0,0 +1,85 @@ +defmodule Microwaveprop.RoverLocationsTest do + use Microwaveprop.DataCase, async: true + + alias Microwaveprop.Rover + alias Microwaveprop.Rover.Location + + defp user_fixture do + Microwaveprop.AccountsFixtures.user_fixture() + end + + describe "list_locations/0" do + test "returns all locations across all users, newest first" do + a = user_fixture() + b = user_fixture() + + {:ok, _l1} = Rover.create_location(a, %{lat: 32.5, lon: -97.5, status: :ideal}) + {:ok, _l2} = Rover.create_location(b, %{lat: 33.0, lon: -96.5, status: :off_limits}) + + result = Rover.list_locations() + assert length(result) == 2 + assert Enum.all?(result, &match?(%Location{}, &1)) + end + end + + describe "create_location/2" do + test "stores the creator's user_id" do + user = user_fixture() + {:ok, loc} = Rover.create_location(user, %{lat: 32.5, lon: -97.5, status: :ideal}) + assert loc.user_id == user.id + end + + test "supports notes and the off_limits status" do + user = user_fixture() + + {:ok, loc} = + Rover.create_location(user, %{ + lat: 32.0, + lon: -97.0, + status: :off_limits, + notes: "private property" + }) + + assert loc.status == :off_limits + assert loc.notes == "private property" + end + + test "rejects out-of-range coordinates" do + user = user_fixture() + assert {:error, %Ecto.Changeset{}} = Rover.create_location(user, %{lat: 100.0, lon: 0.0, status: :ideal}) + end + + test "requires lat and lon" do + user = user_fixture() + assert {:error, cs} = Rover.create_location(user, %{status: :ideal}) + assert "can't be blank" in errors_on(cs).lat + end + end + + describe "update_location/3 and delete_location/2" do + test "creator can update their location" do + user = user_fixture() + {:ok, loc} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :ideal}) + + assert {:ok, updated} = Rover.update_location(user, loc.id, %{notes: "updated"}) + assert updated.notes == "updated" + end + + test "non-creator cannot update or delete" do + user = user_fixture() + other = user_fixture() + {:ok, loc} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :ideal}) + + assert {:error, :not_found} = Rover.update_location(other, loc.id, %{notes: "x"}) + assert {:error, :not_found} = Rover.delete_location(other, loc.id) + end + + test "creator can delete their location" do + user = user_fixture() + {:ok, loc} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :ideal}) + + assert {:ok, _} = Rover.delete_location(user, loc.id) + refute Enum.any?(Rover.list_locations(), &(&1.id == loc.id)) + end + end +end diff --git a/test/microwaveprop_web/live/rover_locations_live_test.exs b/test/microwaveprop_web/live/rover_locations_live_test.exs new file mode 100644 index 00000000..82771733 --- /dev/null +++ b/test/microwaveprop_web/live/rover_locations_live_test.exs @@ -0,0 +1,71 @@ +defmodule MicrowavepropWeb.RoverLocationsLiveTest do + use MicrowavepropWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + alias Microwaveprop.Rover + + describe "anonymous visitor" do + test "can view the locations page", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/rover-locations") + assert html =~ "Rover Locations" + assert html =~ "Sign in" + end + + test "shows existing locations from any user", %{conn: conn} do + user = Microwaveprop.AccountsFixtures.user_fixture() + + {:ok, _} = + Rover.create_location(user, %{ + lat: 32.5, + lon: -97.5, + status: :ideal, + notes: "great hill" + }) + + {:ok, _lv, html} = live(conn, ~p"/rover-locations") + assert html =~ "Ideal" + assert html =~ "great hill" + end + + test "Add button is disabled for anonymous users", %{conn: conn} do + {:ok, _lv, html} = live(conn, ~p"/rover-locations") + assert html =~ ~s(disabled) + assert html =~ "Add location" + end + end + + describe "logged-in user" do + setup :register_and_log_in_user + + test "can add a new location", %{conn: conn} do + {:ok, lv, _html} = live(conn, ~p"/rover-locations") + + lv |> element("button", "+ Add location") |> render_click() + + lv + |> form("#location-form", + location: %{lat: "32.5", lon: "-97.5", status: "ideal", notes: "test note"} + ) + |> render_submit() + + html = render(lv) + assert html =~ "test note" + end + + test "can edit and delete only their own location", %{conn: conn, user: user} do + other = Microwaveprop.AccountsFixtures.user_fixture() + {:ok, mine} = Rover.create_location(user, %{lat: 32.0, lon: -97.0, status: :ideal}) + {:ok, theirs} = Rover.create_location(other, %{lat: 33.0, lon: -98.0, status: :off_limits}) + + {:ok, lv, html} = live(conn, ~p"/rover-locations") + + assert html =~ "location-#{mine.id}" + assert html =~ "location-#{theirs.id}" + + # Edit/Delete buttons rendered for owned location only. + assert has_element?(lv, "#location-#{mine.id} button", "Edit") + refute has_element?(lv, "#location-#{theirs.id} button", "Edit") + end + end +end