defmodule Microwaveprop.Radio.Contact do @moduledoc false use Ecto.Schema import Ecto.Changeset alias Microwaveprop.Accounts.User alias Microwaveprop.Radio.Maidenhead @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id schema "contacts" do field :station1, :string field :station2, :string field :qso_timestamp, :utc_datetime field :grid1, :string field :grid2, :string field :pos1, :map field :pos2, :map field :mode, :string field :band, :decimal field :distance_km, :decimal field :hrrr_status, Ecto.Enum, values: [:pending, :queued, :processing, :complete, :failed, :unavailable], default: :pending field :weather_status, Ecto.Enum, values: [:pending, :queued, :processing, :complete, :failed, :unavailable], default: :pending field :terrain_status, Ecto.Enum, values: [:pending, :queued, :processing, :complete, :failed, :unavailable], default: :pending field :iemre_status, Ecto.Enum, values: [:pending, :queued, :processing, :complete, :failed, :unavailable], default: :pending field :radar_status, Ecto.Enum, values: [:pending, :queued, :processing, :complete, :failed, :unavailable], default: :pending # ADIF PROP_MODE string as submitted by the operator. Trusted as # ground truth by MechanismClassifier when present. field :user_declared_prop_mode, :string # Output of Microwaveprop.Propagation.MechanismClassifier. Values # align with the classifier's result type — kept as a string for # schema-free extension (new mechanisms don't require a migration). field :propagation_mechanism, :string field :propagation_mechanism_confidence, Ecto.Enum, values: [:high, :medium, :low] field :mechanism_status, Ecto.Enum, values: [:pending, :queued, :processing, :complete, :failed, :unavailable], default: :pending field :user_submitted, :boolean, default: false field :submitter_email, :string field :flagged_invalid, :boolean, default: false field :private, :boolean, default: false # Antenna height above ground level (feet). Optional — when set, the # elevation profile and terrain analysis use the actual heights # instead of a default 10 ft. field :height1_ft, :integer field :height2_ft, :integer # Free-form operator notes captured at submission time (QSO # commentary, propagation mode observed, equipment details, etc). # Purely informational — not consumed by the scoring pipeline. field :notes, :string belongs_to :user, User timestamps(type: :utc_datetime) end @type t :: %__MODULE__{} @required_fields ~w(station1 station2 qso_timestamp band)a @optional_fields ~w(grid1 grid2 pos1 pos2 distance_km user_id mode user_declared_prop_mode height1_ft height2_ft private notes)a @spec changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def changeset(contact, attrs) do contact |> cast(attrs, @required_fields ++ @optional_fields) |> validate_required(@required_fields) end @submission_fields ~w(station1 station2 qso_timestamp mode band grid1 grid2 submitter_email user_declared_prop_mode height1_ft height2_ft private notes)a @submission_required ~w(station1 station2 qso_timestamp band grid1 grid2)a @allowed_modes ~w(CW SSB FM FT8 FT4 Q65) # Keep in sync with Microwaveprop.Propagation.BandConfig.all_bands/0 and # Microwaveprop.Radio.AdifImport.@allowed_bands. @allowed_bands Enum.map( ~w(50 144 222 432 902 1296 2304 3400 5760 10000 24000 47000 68000 75000 122000 134000 241000), &Decimal.new/1 ) @spec submission_changeset(t() | Ecto.Changeset.t(), map()) :: Ecto.Changeset.t() def submission_changeset(contact, attrs) do contact |> cast(attrs, @submission_fields) |> validate_required(@submission_required) |> validate_user_or_email() |> sanitize_callsign(:station1) |> sanitize_callsign(:station2) |> sanitize_grid(:grid1) |> sanitize_grid(:grid2) |> normalize_blank_mode() |> validate_callsign(:station1) |> validate_callsign(:station2) |> validate_grid_format(:grid1) |> validate_grid_format(:grid2) |> validate_inclusion(:mode, @allowed_modes) |> validate_inclusion(:band, @allowed_bands) |> validate_email_format() |> validate_length(:submitter_email, max: 254) |> validate_length(:station1, max: 20) |> validate_length(:station2, max: 20) |> validate_height(:height1_ft) |> validate_height(:height2_ft) |> normalize_blank_notes() |> validate_length(:notes, max: 2000) end # Collapse whitespace-only notes to `nil` so the DB column reflects # "no notes" instead of an empty string. Any non-blank value is kept # as-is (including internal whitespace) so the operator's original # wording round-trips untouched. defp normalize_blank_notes(changeset) do case get_change(changeset, :notes) do nil -> changeset value -> if String.trim(value) == "" do put_change(changeset, :notes, nil) else changeset end end end # Sanity bounds on AGL antenna height. Negative heights and anything # taller than ~1000 ft are almost certainly data-entry errors. defp validate_height(changeset, field) do validate_number(changeset, field, greater_than_or_equal_to: 0, less_than_or_equal_to: 1000) end # Treat empty / whitespace-only mode strings as "not provided" so the column # ends up NULL instead of failing validate_inclusion. Mode is optional on the # submission path. defp normalize_blank_mode(changeset) do case get_change(changeset, :mode) do nil -> changeset value -> case String.trim(value) do "" -> put_change(changeset, :mode, nil) trimmed -> put_change(changeset, :mode, trimmed) end end end # Either a user_id (logged-in submitter) or a submitter_email (anonymous) # must be present so we know who submitted the contact. defp validate_user_or_email(changeset) do user_id = get_field(changeset, :user_id) email = get_field(changeset, :submitter_email) if user_id || (email && email != "") do changeset else add_error(changeset, :submitter_email, "can't be blank") end end defp validate_email_format(changeset) do case get_field(changeset, :submitter_email) do nil -> changeset "" -> changeset _email -> validate_format(changeset, :submitter_email, ~r/^[^\s<>]+@[^\s<>]+\.[^\s<>]+$/) end end # Strip whitespace and upcase callsigns defp sanitize_callsign(changeset, field) do case get_change(changeset, field) do nil -> changeset val -> put_change(changeset, field, val |> String.trim() |> String.upcase()) end end # Upcase grid squares (Maidenhead uses uppercase letters + digits) defp sanitize_grid(changeset, field) do case get_change(changeset, field) do nil -> changeset val -> put_change(changeset, field, val |> String.trim() |> String.upcase()) end end # Callsigns: letters, digits, and / only (e.g. W5XD, KG5CCI/P, VE3/W5XD) defp validate_callsign(changeset, field) do validate_format(changeset, field, ~r/^[A-Z0-9\/]+$/, message: "must contain only letters, digits, and /") end defp validate_grid_format(changeset, field) do validate_change(changeset, field, fn _, value -> if Maidenhead.valid?(value) do [] else [{field, "is not a valid Maidenhead grid square"}] end end) end end