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 """ Wipes the existing path matrix for a mission and re-enqueues all pairings. Called after a mission edit when the station list or `only_known_good` toggle could have shifted the matrix. """ @spec replace_mission_paths(Mission.t()) :: :ok def replace_mission_paths(%Mission{} = mission) do Repo.delete_all(from(p in Path, where: p.mission_id == ^mission.id)) enqueue_paths_for(mission) end defp enqueue_paths_for(%Mission{} = mission) do rovers = candidate_rover_locations(mission) stations = mission.stations || [] pairs = for rover <- rovers, station <- stations do %{rover: rover, station: station} end case pairs do [] -> :ok _ -> insert_pending_paths_and_jobs(mission, pairs) end end defp candidate_rover_locations(%Mission{only_known_good: true}) do Repo.all(from(l in Location, where: l.status == :good)) end defp candidate_rover_locations(%Mission{only_known_good: false}) do Repo.all(Location) end defp insert_pending_paths_and_jobs(mission, pairs) do {:ok, _} = pairs |> Enum.reduce(Multi.new(), fn %{rover: r, station: s}, multi -> attrs = %{ mission_id: mission.id, rover_location_id: r.id, station_id: s.id, status: :pending } Multi.insert(multi, {:path, r.id, s.id}, Path.changeset(%Path{}, attrs), on_conflict: :nothing, conflict_target: [:mission_id, :rover_location_id, :station_id] ) end) |> Repo.transaction() enqueue_jobs(mission, pairs) :ok end defp enqueue_jobs(mission, pairs) do for %{rover: r, station: s} <- pairs do %{ "mission_id" => mission.id, "rover_location_id" => r.id, "station_id" => s.id } |> 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