NTMS
diff --git a/lib/microwaveprop_web/router.ex b/lib/microwaveprop_web/router.ex
index fe526f6d..71aaf28c 100644
--- a/lib/microwaveprop_web/router.ex
+++ b/lib/microwaveprop_web/router.ex
@@ -55,6 +55,7 @@ defmodule MicrowavepropWeb.Router do
live "/beacons/:id/edit", BeaconLive.Form, :edit
live "/admin/backfill", BackfillLive
+ live "/admin/contact-edits", Admin.ContactEditLive
live "/users", UserManagementLive.Index, :index
live "/users/:id/edit", UserManagementLive.Edit, :edit
diff --git a/priv/repo/migrations/20260411210308_create_contact_edits.exs b/priv/repo/migrations/20260411210308_create_contact_edits.exs
new file mode 100644
index 00000000..6b9e98b4
--- /dev/null
+++ b/priv/repo/migrations/20260411210308_create_contact_edits.exs
@@ -0,0 +1,25 @@
+defmodule Microwaveprop.Repo.Migrations.CreateContactEdits do
+ use Ecto.Migration
+
+ def change do
+ create table(:contact_edits, primary_key: false) do
+ add :id, :binary_id, primary_key: true
+
+ add :contact_id, references(:contacts, type: :binary_id, on_delete: :delete_all),
+ null: false
+
+ add :user_id, references(:users, type: :binary_id, on_delete: :delete_all), null: false
+ add :proposed_changes, :map, null: false
+ add :status, :string, null: false, default: "pending"
+ add :admin_note, :string
+ add :reviewed_by_id, references(:users, type: :binary_id, on_delete: :nilify_all)
+ add :reviewed_at, :utc_datetime
+
+ timestamps(type: :utc_datetime)
+ end
+
+ create index(:contact_edits, [:contact_id])
+ create index(:contact_edits, [:user_id])
+ create index(:contact_edits, [:status])
+ end
+end
diff --git a/test/microwaveprop/radio/contact_edit_test.exs b/test/microwaveprop/radio/contact_edit_test.exs
new file mode 100644
index 00000000..7f44337e
--- /dev/null
+++ b/test/microwaveprop/radio/contact_edit_test.exs
@@ -0,0 +1,248 @@
+defmodule Microwaveprop.Radio.ContactEditTest do
+ use Microwaveprop.DataCase, async: true
+
+ alias Microwaveprop.Accounts
+ alias Microwaveprop.Radio
+ alias Microwaveprop.Radio.Contact
+ alias Microwaveprop.Radio.ContactEdit
+
+ @contact_attrs %{
+ station1: "W5ISP",
+ station2: "K5TR",
+ qso_timestamp: ~U[2026-04-01 14:00:00Z],
+ grid1: "EM13",
+ grid2: "EL29",
+ pos1: %{"lat" => 33.5, "lon" => -97.0},
+ pos2: %{"lat" => 29.5, "lon" => -98.5},
+ mode: "CW",
+ band: Decimal.new("10000"),
+ distance_km: Decimal.new("450")
+ }
+
+ @user_attrs %{
+ callsign: "W5TEST",
+ name: "Test User",
+ email: "test@example.com",
+ password: "testpassword123"
+ }
+
+ @admin_attrs %{
+ callsign: "N5ADM",
+ name: "Admin User",
+ email: "admin@example.com",
+ password: "adminpassword123"
+ }
+
+ defp create_contact(_) do
+ {:ok, contact} = %Contact{} |> Contact.changeset(@contact_attrs) |> Repo.insert()
+ %{contact: contact}
+ end
+
+ defp create_user(_) do
+ {:ok, user} = Accounts.register_user(@user_attrs)
+ %{user: user}
+ end
+
+ defp create_admin(_) do
+ {:ok, admin} = Accounts.register_user(@admin_attrs)
+ admin = admin |> Ecto.Changeset.change(is_admin: true) |> Repo.update!()
+ %{admin: admin}
+ end
+
+ describe "ContactEdit.changeset/2" do
+ setup [:create_contact, :create_user]
+
+ test "valid with proper proposed changes", %{contact: contact, user: user} do
+ changeset =
+ ContactEdit.changeset(%ContactEdit{}, %{
+ contact_id: contact.id,
+ user_id: user.id,
+ proposed_changes: %{"grid1" => "EM13kk"}
+ })
+
+ assert changeset.valid?
+ end
+
+ test "rejects empty proposed_changes", %{contact: contact, user: user} do
+ changeset =
+ ContactEdit.changeset(%ContactEdit{}, %{
+ contact_id: contact.id,
+ user_id: user.id,
+ proposed_changes: %{}
+ })
+
+ refute changeset.valid?
+ assert "must contain at least one change" in errors_on(changeset).proposed_changes
+ end
+
+ test "rejects invalid fields in proposed_changes", %{contact: contact, user: user} do
+ changeset =
+ ContactEdit.changeset(%ContactEdit{}, %{
+ contact_id: contact.id,
+ user_id: user.id,
+ proposed_changes: %{"distance_km" => 100}
+ })
+
+ refute changeset.valid?
+ assert Enum.any?(errors_on(changeset).proposed_changes, &String.contains?(&1, "invalid fields"))
+ end
+
+ test "validates callsign format", %{contact: contact, user: user} do
+ changeset =
+ ContactEdit.changeset(%ContactEdit{}, %{
+ contact_id: contact.id,
+ user_id: user.id,
+ proposed_changes: %{"station1" => "bad call!"}
+ })
+
+ refute changeset.valid?
+ end
+
+ test "validates grid format", %{contact: contact, user: user} do
+ changeset =
+ ContactEdit.changeset(%ContactEdit{}, %{
+ contact_id: contact.id,
+ user_id: user.id,
+ proposed_changes: %{"grid1" => "ZZZZZZ"}
+ })
+
+ refute changeset.valid?
+ end
+
+ test "validates band value", %{contact: contact, user: user} do
+ changeset =
+ ContactEdit.changeset(%ContactEdit{}, %{
+ contact_id: contact.id,
+ user_id: user.id,
+ proposed_changes: %{"band" => 99_999}
+ })
+
+ refute changeset.valid?
+ end
+
+ test "validates mode value", %{contact: contact, user: user} do
+ changeset =
+ ContactEdit.changeset(%ContactEdit{}, %{
+ contact_id: contact.id,
+ user_id: user.id,
+ proposed_changes: %{"mode" => "INVALID"}
+ })
+
+ refute changeset.valid?
+ end
+
+ test "requires contact_id, user_id, proposed_changes" do
+ changeset = ContactEdit.changeset(%ContactEdit{}, %{})
+
+ assert %{
+ contact_id: ["can't be blank"],
+ user_id: ["can't be blank"],
+ proposed_changes: ["can't be blank"]
+ } = errors_on(changeset)
+ end
+ end
+
+ describe "Radio.create_contact_edit/3" do
+ setup [:create_contact, :create_user]
+
+ test "creates a pending edit with only changed fields", %{contact: contact, user: user} do
+ proposed = %{"grid1" => "EM13kk", "station1" => "W5ISP"}
+
+ assert {:ok, edit} = Radio.create_contact_edit(contact, user, proposed)
+ assert edit.status == :pending
+ assert edit.proposed_changes == %{"grid1" => "EM13KK"}
+ assert edit.contact_id == contact.id
+ assert edit.user_id == user.id
+ end
+
+ test "rejects when nothing actually changed", %{contact: contact, user: user} do
+ proposed = %{"station1" => "W5ISP", "mode" => "CW"}
+
+ assert {:error, changeset} = Radio.create_contact_edit(contact, user, proposed)
+ assert "must contain at least one change" in errors_on(changeset).proposed_changes
+ end
+ end
+
+ describe "Radio.list_pending_edits/0" do
+ setup [:create_contact, :create_user]
+
+ test "returns pending edits", %{contact: contact, user: user} do
+ {:ok, _e1} =
+ Radio.create_contact_edit(contact, user, %{"grid1" => "EM13kk"})
+
+ {:ok, _e2} =
+ Radio.create_contact_edit(contact, user, %{"grid2" => "EL29ab"})
+
+ edits = Radio.list_pending_edits()
+ assert length(edits) == 2
+
+ fields = Enum.flat_map(edits, &Map.keys(&1.proposed_changes))
+ assert "grid1" in fields
+ assert "grid2" in fields
+ end
+ end
+
+ describe "Radio.approve_edit/3" do
+ setup [:create_contact, :create_user, :create_admin]
+
+ test "applies changes to the contact and marks approved", %{
+ contact: contact,
+ user: user,
+ admin: admin
+ } do
+ {:ok, edit} = Radio.create_contact_edit(contact, user, %{"grid1" => "EM13kk"})
+
+ assert {:ok, approved_edit} = Radio.approve_edit(edit, admin, "Confirmed via LOTW")
+ assert approved_edit.status == :approved
+ assert approved_edit.admin_note == "Confirmed via LOTW"
+ assert approved_edit.reviewed_by_id == admin.id
+ assert approved_edit.reviewed_at
+
+ updated_contact = Radio.get_contact!(contact.id)
+ assert updated_contact.grid1 == "EM13KK"
+ end
+
+ test "re-enqueues enrichment when grid changes", %{
+ contact: contact,
+ user: user,
+ admin: admin
+ } do
+ {:ok, edit} = Radio.create_contact_edit(contact, user, %{"grid1" => "EM15ab"})
+ {:ok, _approved} = Radio.approve_edit(edit, admin, nil)
+
+ updated = Radio.get_contact!(contact.id)
+ assert updated.grid1 == "EM15AB"
+ assert updated.pos1 != contact.pos1
+ end
+ end
+
+ describe "Radio.reject_edit/3" do
+ setup [:create_contact, :create_user, :create_admin]
+
+ test "marks edit as rejected without changing contact", %{
+ contact: contact,
+ user: user,
+ admin: admin
+ } do
+ {:ok, edit} = Radio.create_contact_edit(contact, user, %{"grid1" => "EM13kk"})
+
+ assert {:ok, rejected_edit} = Radio.reject_edit(edit, admin, "Original data correct")
+ assert rejected_edit.status == :rejected
+ assert rejected_edit.admin_note == "Original data correct"
+
+ unchanged_contact = Radio.get_contact!(contact.id)
+ assert unchanged_contact.grid1 == contact.grid1
+ end
+ end
+
+ describe "Radio.pending_edit_count/0" do
+ setup [:create_contact, :create_user]
+
+ test "counts only pending edits", %{contact: contact, user: user} do
+ {:ok, _} = Radio.create_contact_edit(contact, user, %{"grid1" => "EM13kk"})
+ {:ok, _} = Radio.create_contact_edit(contact, user, %{"grid2" => "EL29ab"})
+
+ assert Radio.pending_edit_count() == 2
+ end
+ end
+end