prop/lib/microwaveprop/rover.ex
Graham McIntire d052dcaa91
feat(rover): admins can edit/delete any rover location
Rover.update_location/3 and delete_location/2 now resolve any record
when the actor is_admin. The index + show LiveViews surface Edit/Delete
on every row for admin users, matching the existing owner UX.
2026-05-02 17:04:52 -05:00

211 lines
6.1 KiB
Elixir

defmodule Microwaveprop.Rover do
@moduledoc """
Context for managing a user's fixed stations and providing the
rover-planning defaults.
All mutations are scoped to a user — `update_station/3`, `delete_station/2`,
and `toggle_selected/2` return `{:error, :not_found}` when invoked on
a station belonging to a different user.
"""
import Ecto.Query
alias Microwaveprop.Accounts.User
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Repo
alias Microwaveprop.Rover.FixedStation
alias Microwaveprop.Rover.Location
@default_stations [
%{callsign: "W5LUA", grid: "EM13qc"},
%{callsign: "W5HN", grid: "EM13nf"},
%{callsign: "N5XU", grid: "EM10cl"}
]
@spec list_stations(User.t()) :: [FixedStation.t()]
def list_stations(%User{id: user_id}) do
FixedStation
|> where([s], s.user_id == ^user_id)
|> order_by([s], asc: s.position, asc: s.inserted_at)
|> Repo.all()
end
@spec create_station(User.t(), map()) :: {:ok, FixedStation.t()} | {:error, Ecto.Changeset.t()}
def create_station(%User{} = user, attrs) do
attrs_with_position = put_position_default(attrs, next_position(user))
%FixedStation{user_id: user.id}
|> FixedStation.changeset(attrs_with_position)
|> Repo.insert()
|> maybe_enqueue_elevation()
end
defp put_position_default(attrs, position) when is_map(attrs) do
cond do
Map.has_key?(attrs, :position) -> attrs
Map.has_key?(attrs, "position") -> attrs
string_keyed?(attrs) -> Map.put(attrs, "position", position)
true -> Map.put(attrs, :position, position)
end
end
defp string_keyed?(attrs) do
Enum.any?(attrs, fn {k, _v} -> is_binary(k) end)
end
@spec update_station(User.t(), Ecto.UUID.t(), map()) ::
{:ok, FixedStation.t()} | {:error, :not_found | Ecto.Changeset.t()}
def update_station(%User{} = user, id, attrs) do
case fetch_owned(user, id) do
{:ok, station} ->
station
|> FixedStation.changeset(attrs)
|> Repo.update()
{:error, :not_found} ->
{:error, :not_found}
end
end
@spec delete_station(User.t(), Ecto.UUID.t()) ::
{:ok, FixedStation.t()} | {:error, :not_found}
def delete_station(%User{} = user, id) do
case fetch_owned(user, id) do
{:ok, station} ->
Repo.delete(station)
{:error, :not_found} ->
{:error, :not_found}
end
end
@spec toggle_selected(User.t(), Ecto.UUID.t()) ::
{:ok, FixedStation.t()} | {:error, :not_found | Ecto.Changeset.t()}
def toggle_selected(%User{} = user, id) do
case fetch_owned(user, id) do
{:ok, station} ->
station
|> Ecto.Changeset.change(selected: not station.selected)
|> Repo.update()
{:error, :not_found} ->
{:error, :not_found}
end
end
@doc """
Three hardcoded NTMS stations used as the anonymous-user fallback in
the rover planner. Plain maps shaped like `FixedStation`, with lat/lon
derived from each grid via `Maidenhead.to_latlon/1`.
"""
@spec default_stations() :: [map()]
def default_stations do
@default_stations
|> Enum.with_index()
|> Enum.map(fn {%{callsign: callsign, grid: grid}, index} ->
{:ok, {lat, lon}} = Maidenhead.to_latlon(grid)
%{
callsign: callsign,
grid: grid,
lat: lat,
lon: lon,
elevation_m: nil,
selected: true,
position: index
}
end)
end
defp fetch_owned(%User{id: user_id}, id) do
case Repo.get(FixedStation, id) do
%FixedStation{user_id: ^user_id} = station -> {:ok, station}
_ -> {:error, :not_found}
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{is_admin: true}, id) do
case Repo.get(Location, id) do
%Location{} = loc -> {:ok, loc}
_ -> {: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,
where: s.user_id == ^user_id,
select: count(s.id)
Repo.one(query) || 0
end
defp maybe_enqueue_elevation({:ok, %FixedStation{elevation_m: nil, id: id}} = result) do
enqueue_station_elevation(id)
result
end
defp maybe_enqueue_elevation(other), do: other
# Wrapped behind a function-exported guard so this module compiles
# cleanly while Task 8's worker is still being authored — the worker
# is referenced by atom and resolved at runtime once it's loaded.
defp enqueue_station_elevation(id) do
worker = Microwaveprop.Workers.StationElevationWorker
if Code.ensure_loaded?(worker) and function_exported?(worker, :new, 1) do
%{id: id} |> worker.new() |> Oban.insert()
end
end
end