prop/lib/microwaveprop/rover_planning/station.ex
Graham McIntire d31e783776
Some checks failed
Build and Push / Build and Push Docker Image (push) Failing after 4m35s
fix: resolve 18 bugs across LiveViews, schemas, tests, and logic
- MonitorLive.Show: safe nil-guard on current_scope for anonymous access
- Admin.MonitorLive.Index: add phx-update=stream to enable stream ops
- ImportLive: require owner/admin authorization, not_found redirect
- MapLive: store timer refs in assigns, cancel before reschedule
- 10 schemas: add missing foreign_key_constraint on belongs_to
- Soundings: preload :station to eliminate N+1 in path analysis
- PathAnalysis: defensive preload of :station on soundings
- GridTaskEnqueuer: wrap reclaim_stale_running in Repo.transaction()
- HrdpsClient: replace String.to_atom with compile-time atom literals
- Contacts: fix extract_latlon false return for lon=0.0
- Tests: remove duplicate Mox.defmock, unblock swallowed task exits,
  bump refute_receive timeouts from 50ms to 200ms
2026-07-29 07:46:54 -05:00

111 lines
3.4 KiB
Elixir

defmodule Microwaveprop.RoverPlanning.Station do
@moduledoc """
A stationary endpoint for a rover mission. The user provides one of:
callsign, Maidenhead grid, or `lat, lon`; the changeset normalizes
the input into all three when possible (callsigns geocode via
`MicrowavepropWeb.LocationResolver`).
"""
use Ecto.Schema
import Ecto.Changeset
alias Microwaveprop.RoverPlanning.Mission
alias MicrowavepropWeb.LocationResolver
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
schema "rover_mission_stations" do
field :callsign, :string
field :grid, :string
field :input, :string
field :lat, :float
field :lon, :float
field :position, :integer, default: 0
belongs_to :mission, Mission
timestamps(type: :utc_datetime)
end
@type t :: %__MODULE__{}
@spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t()
def changeset(station, attrs) do
station
|> cast(attrs, [:callsign, :grid, :input, :lat, :lon, :position])
|> validate_required([:input])
|> resolve_input()
|> validate_required([:lat, :lon])
|> validate_number(:lat, greater_than_or_equal_to: -90.0, less_than_or_equal_to: 90.0)
|> validate_number(:lon, greater_than_or_equal_to: -180.0, less_than_or_equal_to: 180.0)
|> foreign_key_constraint(:mission_id)
end
# When `input` looks like a callsign or grid, geocode it and populate
# the matching field. If the user TYPED a new input (changeset has a
# `:input` change), wipe any previously-resolved callsign / grid /
# lat / lon first — otherwise an early prefix that resolved
# successfully (e.g. "AA5" matched a callsign) would lock those
# fields and the user's later keystrokes would never re-resolve.
defp resolve_input(changeset) do
cond do
not changeset.valid? ->
changeset
input_changed?(changeset) ->
changeset
|> clear_resolved()
|> resolve_from_input()
get_field(changeset, :lat) && get_field(changeset, :lon) ->
changeset
true ->
resolve_from_input(changeset)
end
end
defp input_changed?(changeset), do: not is_nil(get_change(changeset, :input))
defp clear_resolved(changeset) do
changeset
|> put_change(:callsign, nil)
|> put_change(:grid, nil)
|> put_change(:lat, nil)
|> put_change(:lon, nil)
end
defp resolve_from_input(changeset) do
case get_field(changeset, :input) do
input when is_binary(input) and input != "" ->
apply_resolution(changeset, LocationResolver.resolve(String.trim(input)))
_ ->
changeset
end
end
defp apply_resolution(changeset, {:ok, %{kind: :callsign} = res}) do
changeset
|> put_change(:callsign, res.label)
|> put_change(:grid, Map.get(res, :grid))
|> put_change(:lat, res.lat)
|> put_change(:lon, res.lon)
end
defp apply_resolution(changeset, {:ok, %{kind: :grid} = res}) do
changeset
|> put_change(:grid, res.label)
|> put_change(:lat, res.lat)
|> put_change(:lon, res.lon)
end
defp apply_resolution(changeset, {:ok, %{kind: :coordinates} = res}) do
changeset
|> put_change(:lat, res.lat)
|> put_change(:lon, res.lon)
end
defp apply_resolution(changeset, {:error, msg}), do: add_error(changeset, :input, msg)
defp apply_resolution(changeset, :empty), do: add_error(changeset, :input, "is required")
end