prop/lib/microwaveprop/rover.ex
Graham McIntire b4b8d4ec47
Some checks failed
Build base image / Build and push base image (push) Successful in 12s
Build and Push / Build CI test image (push) Successful in 14s
Build and Push / Build and Push Docker Image (push) Failing after 14m7s
simplify: DRY up shared changesets, context helpers, LiveView helpers, and structural extraction
- Create MaidenheadChangesetHelpers: consolidate grid validation, callsign
  normalization, lat/lon validation, grid/latlon derivation across 6 schemas
- Create ContextHelpers: shared fetch_owned with admin bypass, safe_enqueue
  for Oban workers, UUID casting to replace CastError rescues
- Extend LiveHelpers: add current_user/1 (removes 7 duplicate definitions),
  subscribe/2 (replaces 13 inline PubSub sites), assign_url_params/2
- Extract Propagation.ScoreStore (528 lines): separate file I/O and cache
  management from scoring logic, 13 defdelegate passthroughs
- Split SubmitLive (942->475 lines): extract CSV/ADIF upload rendering into
  3 function component modules (csv_upload, adif_upload, preview)
- Update 16 LiveViews to use shared helpers
2026-08-06 18:06:50 -05:00

184 lines
5.4 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.ContextHelpers
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 ContextHelpers.fetch_owned(FixedStation, id, user) 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 ContextHelpers.fetch_owned(FixedStation, id, user) 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 ContextHelpers.fetch_owned(FixedStation, id, user) 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
# ── 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 ContextHelpers.fetch_owned(Location, id, user) 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 ContextHelpers.fetch_owned(Location, id, user) do
{:ok, location} -> Repo.delete(location)
{:error, :not_found} -> {: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
defp enqueue_station_elevation(id) do
ContextHelpers.safe_enqueue(Microwaveprop.Workers.StationElevationWorker, %{id: id})
end
end