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.
140 lines
4.2 KiB
Elixir
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
|