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