defmodule Microwaveprop.RoverPlanning.Mission do @moduledoc """ A planned rover mission. Owned by a user but globally visible. Holds the band + height parameters that drive path-profile computation, the list of stationary endpoints (`stations`), and the matrix of computed paths from each rover-location to each station (`paths`). `only_known_good` toggles whether the rover endpoint set is the `:good` rover-locations (default) or every rover-location. """ use Ecto.Schema import Ecto.Changeset alias Microwaveprop.Accounts.User alias Microwaveprop.RoverPlanning.Path alias Microwaveprop.RoverPlanning.Station @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id schema "rover_missions" do field :name, :string field :band_mhz, :integer, default: 10_000 field :bands_mhz, {:array, :integer}, default: [] field :only_known_good, :boolean, default: true field :rover_height_ft, :float, default: 8.0 field :station_height_ft, :float, default: 30.0 field :notes, :string belongs_to :user, User has_many :stations, Station, foreign_key: :mission_id, on_replace: :delete has_many :paths, Path, foreign_key: :mission_id timestamps(type: :utc_datetime) end @type t :: %__MODULE__{} @castable_fields [ :name, :band_mhz, :bands_mhz, :only_known_good, :rover_height_ft, :station_height_ft, :notes ] @doc """ Returns the bands the mission's path matrix should iterate over. Prefers `bands_mhz` (canonical, possibly empty) and falls back to `[band_mhz]` for legacy rows. Always returns a non-empty list when the mission is valid. """ @spec bands(t()) :: [integer()] def bands(%__MODULE__{bands_mhz: list}) when is_list(list) and list != [], do: Enum.uniq(list) def bands(%__MODULE__{band_mhz: primary}) when is_integer(primary), do: [primary] def bands(_), do: [] @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(mission, attrs) do mission |> cast(prepare_bands_attr(attrs), @castable_fields) |> normalize_bands() |> cast_assoc(:stations, with: &Station.changeset/2, required: true) |> validate_required([:name, :band_mhz]) |> validate_length(:name, min: 1, max: 100) |> validate_number(:band_mhz, greater_than: 0) |> validate_bands_mhz() |> validate_number(:rover_height_ft, greater_than_or_equal_to: 0.0) |> validate_number(:station_height_ft, greater_than_or_equal_to: 0.0) |> validate_length(:notes, max: 4000) |> validate_at_least_one_station() end # The multi-checkbox form sends a hidden empty-string sentinel so # the key always exists even when nothing is checked. Drop those # sentinels here so Ecto's `{:array, :integer}` cast doesn't choke. defp prepare_bands_attr(%{"bands_mhz" => list} = attrs) when is_list(list) do cleaned = list |> Enum.reject(fn v -> v == "" or v == nil end) |> Enum.uniq() Map.put(attrs, "bands_mhz", cleaned) end defp prepare_bands_attr(attrs), do: attrs # Treats `bands_mhz` as the canonical multi-band list. When the # form submits it (multi-checkbox), use that. When only `band_mhz` # is present (legacy single-band form path), seed bands_mhz from it. # Also keeps `band_mhz` in sync as the "primary band" so older # readers stay correct. defp normalize_bands(changeset) do bands = get_change(changeset, :bands_mhz) || get_field(changeset, :bands_mhz) primary = get_change(changeset, :band_mhz) || get_field(changeset, :band_mhz) cond do is_list(bands) and bands != [] -> cleaned = bands |> Enum.uniq() |> Enum.sort() changeset |> put_change(:bands_mhz, cleaned) |> put_change(:band_mhz, hd(cleaned)) is_integer(primary) and primary > 0 -> put_change(changeset, :bands_mhz, [primary]) true -> changeset end end defp validate_bands_mhz(changeset) do case get_field(changeset, :bands_mhz) do list when is_list(list) and list != [] -> if Enum.all?(list, &(is_integer(&1) and &1 > 0)), do: changeset, else: add_error(changeset, :bands_mhz, "must be a list of positive integers") _ -> add_error(changeset, :bands_mhz, "must include at least one band") end end defp validate_at_least_one_station(changeset) do case fetch_field(changeset, :stations) do {_source, list} when is_list(list) and list != [] -> kept = Enum.reject(list, fn s -> match?(%Ecto.Changeset{action: :replace}, s) end) if kept == [], do: add_error(changeset, :stations, "must include at least one station"), else: changeset _ -> add_error(changeset, :stations, "must include at least one station") end end end