prop/lib/microwaveprop/radio/contact_edit.ex
Graham McIntire a66d3094ca Add contact edit approval system with admin review queue
Registered users can suggest edits to any contact's core fields
(callsigns, grids, band, mode, timestamp). Edits enter an admin
approval queue with field-by-field diff view. On approve, changes
are applied and enrichment re-enqueued if grids/band changed.
Users receive email notification on approve or reject.

Also updates dependabot.yml for mix ecosystem.
2026-04-11 16:15:49 -05:00

140 lines
4.2 KiB
Elixir

defmodule Microwaveprop.Radio.ContactEdit do
@moduledoc false
use Ecto.Schema
import Ecto.Changeset
alias Microwaveprop.Accounts.User
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Radio.Maidenhead
@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id
@allowed_modes ~w(CW SSB FM FT8 FT4)
@allowed_bands ~w(1296 2304 3456 5760 10000 24000 47000 68000 75000 122000 134000 241000)
schema "contact_edits" do
belongs_to :contact, Contact
belongs_to :user, User
belongs_to :reviewed_by, User
field :proposed_changes, :map
field :status, Ecto.Enum, values: [:pending, :approved, :rejected], default: :pending
field :admin_note, :string
field :reviewed_at, :utc_datetime
timestamps(type: :utc_datetime)
end
@editable_fields ~w(station1 station2 grid1 grid2 band mode qso_timestamp)
def changeset(edit, attrs) do
edit
|> cast(attrs, [:contact_id, :user_id, :proposed_changes])
|> validate_required([:contact_id, :user_id, :proposed_changes])
|> validate_proposed_changes()
|> foreign_key_constraint(:contact_id)
|> foreign_key_constraint(:user_id)
end
def review_changeset(edit, attrs) do
edit
|> cast(attrs, [:status, :admin_note, :reviewed_by_id, :reviewed_at])
|> validate_required([:status, :reviewed_by_id, :reviewed_at])
|> validate_inclusion(:status, [:approved, :rejected])
end
defp validate_proposed_changes(changeset) do
case get_field(changeset, :proposed_changes) do
nil ->
changeset
changes when changes == %{} ->
add_error(changeset, :proposed_changes, "must contain at least one change")
changes when is_map(changes) ->
keys = Map.keys(changes)
invalid_keys = Enum.reject(keys, &(&1 in @editable_fields))
changeset =
if invalid_keys == [] do
changeset
else
add_error(changeset, :proposed_changes, "contains invalid fields: #{Enum.join(invalid_keys, ", ")}")
end
Enum.reduce(keys, changeset, &validate_field_value/2)
_ ->
add_error(changeset, :proposed_changes, "must be a map")
end
end
defp validate_field_value("station1", cs), do: validate_callsign_value(cs, "station1")
defp validate_field_value("station2", cs), do: validate_callsign_value(cs, "station2")
defp validate_field_value("grid1", cs), do: validate_grid_value(cs, "grid1")
defp validate_field_value("grid2", cs), do: validate_grid_value(cs, "grid2")
defp validate_field_value("band", changeset) do
val = get_field(changeset, :proposed_changes)["band"]
if to_string(val) in @allowed_bands do
changeset
else
add_error(changeset, :proposed_changes, "band is not a valid frequency")
end
end
defp validate_field_value("mode", changeset) do
val = get_field(changeset, :proposed_changes)["mode"]
if val in @allowed_modes do
changeset
else
add_error(changeset, :proposed_changes, "mode must be one of: #{Enum.join(@allowed_modes, ", ")}")
end
end
defp validate_field_value("qso_timestamp", changeset) do
val = get_field(changeset, :proposed_changes)["qso_timestamp"]
case val do
%DateTime{} ->
changeset
s when is_binary(s) ->
case DateTime.from_iso8601(s) do
{:ok, _, _} -> changeset
_ -> add_error(changeset, :proposed_changes, "qso_timestamp is not a valid datetime")
end
_ ->
add_error(changeset, :proposed_changes, "qso_timestamp is not a valid datetime")
end
end
defp validate_field_value(_, changeset), do: changeset
defp validate_callsign_value(changeset, field) do
val = get_field(changeset, :proposed_changes)[field] || ""
val = val |> String.trim() |> String.upcase()
if Regex.match?(~r/^[A-Z0-9\/]+$/, val) do
changeset
else
add_error(changeset, :proposed_changes, "#{field} must contain only letters, digits, and /")
end
end
defp validate_grid_value(changeset, field) do
val = get_field(changeset, :proposed_changes)[field] || ""
val = val |> String.trim() |> String.upcase()
if Maidenhead.valid?(val) do
changeset
else
add_error(changeset, :proposed_changes, "#{field} is not a valid Maidenhead grid square")
end
end
end