defmodule Microwaveprop.RoverPlanning do @moduledoc """ Context for the `/rover-planning` feature: planned rover missions with stationary endpoints and per-rover-location terrain path profiles. Mutations are scoped to a user (or admin); reads are global. After a successful `create_mission/2` or `replace_mission_paths/1`, the context enqueues one `RoverPathProfileWorker` job per (rover-location, station) pair so the show page can render results as they land. """ import Ecto.Query alias Ecto.Multi alias Microwaveprop.Accounts.User alias Microwaveprop.Repo alias Microwaveprop.Rover.Location alias Microwaveprop.RoverPlanning.Mission alias Microwaveprop.RoverPlanning.Path alias Microwaveprop.RoverPlanning.Station alias Microwaveprop.Workers.RoverPathProfileWorker @spec list_missions() :: [Mission.t()] def list_missions do Mission |> order_by([m], desc: m.inserted_at) |> preload([:user, :stations]) |> Repo.all() end @spec get_mission(Ecto.UUID.t()) :: Mission.t() | nil def get_mission(id) when is_binary(id) do case Ecto.UUID.cast(id) do {:ok, uuid} -> Mission |> Repo.get(uuid) |> Repo.preload([:user, stations: from(s in Station, order_by: s.position)]) :error -> nil end end @spec get_mission_with_paths(Ecto.UUID.t()) :: Mission.t() | nil def get_mission_with_paths(id) do case get_mission(id) do nil -> nil mission -> Repo.preload(mission, paths: [:rover_location, :station]) end end @spec create_mission(User.t(), map()) :: {:ok, Mission.t()} | {:error, Ecto.Changeset.t()} def create_mission(%User{} = user, attrs) do changeset = Mission.changeset(%Mission{user_id: user.id}, attrs) case Repo.insert(changeset) do {:ok, mission} -> mission = Repo.preload(mission, [:user, :stations]) :ok = enqueue_paths_for(mission) {:ok, mission} {:error, _} = error -> error end end @spec update_mission(User.t(), Ecto.UUID.t(), map()) :: {:ok, Mission.t()} | {:error, :not_found | Ecto.Changeset.t()} def update_mission(%User{} = user, id, attrs) do case fetch_owned(user, id) do {:ok, mission} -> mission |> Repo.preload(stations: from(s in Station, order_by: s.position)) |> Mission.changeset(attrs) |> Repo.update() |> case do {:ok, updated} -> updated = Repo.preload(updated, [:user, :stations], force: true) :ok = replace_mission_paths(updated) {:ok, updated} other -> other end {:error, :not_found} -> {:error, :not_found} end end @spec delete_mission(User.t(), Ecto.UUID.t()) :: {:ok, Mission.t()} | {:error, :not_found} def delete_mission(%User{} = user, id) do case fetch_owned(user, id) do {:ok, mission} -> Repo.delete(mission) {:error, :not_found} -> {:error, :not_found} end end @spec list_paths(Mission.t()) :: [Path.t()] def list_paths(%Mission{id: mission_id}) do Repo.all( from(p in Path, where: p.mission_id == ^mission_id, preload: [:rover_location, :station], order_by: [asc: p.station_id, asc: p.rover_location_id] ) ) end @doc """ Reconciles the path matrix for a mission against the current set of (rover_location × station × band) tuples. Deletes paths for tuples that no longer apply, leaves :complete paths in place when they're still relevant, and enqueues new pending paths for any tuple missing from the table. Called after a mission edit, or after the user adds/removes a rover site. Aliased as `replace_mission_paths/1` for backwards compatibility with callers that expected the previous destructive behaviour; internally both go through the same reconcile path. """ @spec reconcile_mission_paths(Mission.t()) :: :ok def reconcile_mission_paths(%Mission{} = mission) do desired = MapSet.new(desired_tuples(mission)) actual_paths = Repo.all(from(p in Path, where: p.mission_id == ^mission.id)) actual = MapSet.new(actual_paths, &{&1.rover_location_id, &1.station_id, &1.band_mhz}) stale_ids = actual_paths |> Enum.reject(&MapSet.member?(desired, {&1.rover_location_id, &1.station_id, &1.band_mhz})) |> Enum.map(& &1.id) if stale_ids != [] do Repo.delete_all(from(p in Path, where: p.id in ^stale_ids)) end desired |> MapSet.difference(actual) |> Enum.to_list() |> insert_pending_paths_and_jobs(mission) :ok end @spec replace_mission_paths(Mission.t()) :: :ok defdelegate replace_mission_paths(mission), to: __MODULE__, as: :reconcile_mission_paths @doc """ Walks every mission and re-enqueues any (rover_location × station) pair whose `Path` is missing or not `:complete`. Existing complete rows are left alone — the worker also short-circuits on `:complete`, so this is idempotent and safe to run on a cron. Use case: a rolling deploy or a transient elevation-API failure left some missions with `:pending` / `:failed` entries; the next backfill tick recovers them without operator action. """ @spec backfill_paths() :: :ok def backfill_paths do Mission |> Repo.all() |> Repo.preload(stations: from(s in Station, order_by: s.position)) |> Enum.each(&enqueue_paths_for/1) :ok end defp enqueue_paths_for(%Mission{} = mission) do mission |> desired_tuples() |> insert_pending_paths_and_jobs(mission) end # Cross product of (rover_location_id, station_id, band_mhz) for the # mission's current scope. The reconcile + initial-create paths both # go through this same source of truth. @spec desired_tuples(Mission.t()) :: [{Ecto.UUID.t(), Ecto.UUID.t(), integer()}] defp desired_tuples(%Mission{} = mission) do rovers = candidate_rover_locations(mission) stations = mission.stations || [] bands = Mission.bands(mission) for rover <- rovers, station <- stations, band <- bands do {rover.id, station.id, band} end end @doc """ Rover-locations the matrix actually scores against, given the mission's `only_known_good` flag. Public so the show LiveView can render the same list the worker enqueues against. """ @spec candidate_rover_locations(Mission.t()) :: [Location.t()] def candidate_rover_locations(%Mission{only_known_good: true}) do Repo.all(from(l in Location, where: l.status == :good, order_by: [desc: l.inserted_at])) end def candidate_rover_locations(%Mission{only_known_good: false}) do Repo.all(from(l in Location, order_by: [desc: l.inserted_at])) end defp insert_pending_paths_and_jobs([], _mission), do: :ok defp insert_pending_paths_and_jobs(tuples, mission) when is_list(tuples) do {:ok, _} = tuples |> Enum.reduce(Multi.new(), fn {rover_id, station_id, band_mhz}, multi -> attrs = %{ mission_id: mission.id, rover_location_id: rover_id, station_id: station_id, band_mhz: band_mhz, status: :pending } Multi.insert( multi, {:path, rover_id, station_id, band_mhz}, Path.changeset(%Path{}, attrs), on_conflict: :nothing, conflict_target: [:mission_id, :rover_location_id, :station_id, :band_mhz] ) end) |> Repo.transaction() enqueue_jobs(tuples, mission) :ok end defp enqueue_jobs(tuples, mission) do for {rover_id, station_id, band_mhz} <- tuples do %{ "mission_id" => mission.id, "rover_location_id" => rover_id, "station_id" => station_id, "band_mhz" => band_mhz } |> RoverPathProfileWorker.new() |> Oban.insert() end :ok end defp fetch_owned(%User{is_admin: true}, id) do case get_mission(id) do nil -> {:error, :not_found} mission -> {:ok, mission} end end defp fetch_owned(%User{id: user_id}, id) do case get_mission(id) do %Mission{user_id: ^user_id} = mission -> {:ok, mission} _ -> {:error, :not_found} end end end