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 :user_submitted, :boolean, default: false field :submitter_email, :string field :flagged_invalid, :boolean, default: false belongs_to :user, User timestamps(type: :utc_datetime) end @required_fields ~w(station1 station2 qso_timestamp mode band)a @optional_fields ~w(grid1 grid2 pos1 pos2 distance_km user_id)a 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_id)a @submission_required ~w(station1 station2 qso_timestamp mode band grid1 grid2)a @allowed_modes ~w(CW SSB FM FT8 FT4) @allowed_bands Enum.map(~w(1296 2304 3456 5760 10000 24000 47000 68000 75000 122000 134000 241000), &Decimal.new/1) 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) |> 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) 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