Deduplicate single contact submissions same as CSV import

Check for existing contacts with the same station pair (order-independent),
band, and grids within a 1-hour window before inserting.
This commit is contained in:
Graham McIntire 2026-04-11 17:26:54 -05:00
parent 3334db5a08
commit cdb59be6d7
2 changed files with 87 additions and 12 deletions

View file

@ -329,6 +329,8 @@ defmodule Microwaveprop.Radio do
end
end
@dedup_window_seconds 3600
def create_contact(attrs) do
changeset = Contact.submission_changeset(%Contact{}, attrs)
@ -336,26 +338,63 @@ defmodule Microwaveprop.Radio do
grid1 = Ecto.Changeset.get_change(changeset, :grid1)
grid2 = Ecto.Changeset.get_change(changeset, :grid2)
case {Maidenhead.to_latlon(grid1), Maidenhead.to_latlon(grid2)} do
{{:ok, {lat1, lon1}}, {:ok, {lat2, lon2}}} ->
distance = lat1 |> haversine_km(lon1, lat2, lon2) |> round() |> Decimal.new()
if duplicate_contact_exists?(changeset) do
changeset =
Ecto.Changeset.add_error(changeset, :station1, "a matching contact already exists within the last hour")
changeset
|> Ecto.Changeset.put_change(:pos1, %{"lat" => lat1, "lon" => lon1})
|> Ecto.Changeset.put_change(:pos2, %{"lat" => lat2, "lon" => lon2})
|> Ecto.Changeset.put_change(:distance_km, distance)
|> Ecto.Changeset.put_change(:user_submitted, true)
|> Repo.insert()
{:error, %{changeset | action: :insert}}
else
case {Maidenhead.to_latlon(grid1), Maidenhead.to_latlon(grid2)} do
{{:ok, {lat1, lon1}}, {:ok, {lat2, lon2}}} ->
distance = lat1 |> haversine_km(lon1, lat2, lon2) |> round() |> Decimal.new()
_ ->
changeset = Ecto.Changeset.add_error(changeset, :grid1, "could not resolve grid to coordinates")
{:error, %{changeset | action: :insert}}
changeset
|> Ecto.Changeset.put_change(:pos1, %{"lat" => lat1, "lon" => lon1})
|> Ecto.Changeset.put_change(:pos2, %{"lat" => lat2, "lon" => lon2})
|> Ecto.Changeset.put_change(:distance_km, distance)
|> Ecto.Changeset.put_change(:user_submitted, true)
|> Repo.insert()
_ ->
changeset = Ecto.Changeset.add_error(changeset, :grid1, "could not resolve grid to coordinates")
{:error, %{changeset | action: :insert}}
end
end
else
{:error, %{changeset | action: :insert}}
end
end
defp duplicate_contact_exists?(changeset) do
s1 = Ecto.Changeset.get_field(changeset, :station1) |> to_string() |> String.upcase()
s2 = Ecto.Changeset.get_field(changeset, :station2) |> to_string() |> String.upcase()
g1 = Ecto.Changeset.get_field(changeset, :grid1) |> to_string() |> String.upcase()
g2 = Ecto.Changeset.get_field(changeset, :grid2) |> to_string() |> String.upcase()
band = Ecto.Changeset.get_field(changeset, :band)
ts = Ecto.Changeset.get_field(changeset, :qso_timestamp)
band_decimal = if is_binary(band), do: Decimal.new(band), else: band
min_ts = DateTime.add(ts, -@dedup_window_seconds, :second)
max_ts = DateTime.add(ts, @dedup_window_seconds, :second)
# Query for contacts with same band in the time window, then check
# station/grid pair match (order-independent) in Elixir
from(c in Contact,
where:
c.band == ^band_decimal and
c.qso_timestamp >= ^min_ts and
c.qso_timestamp <= ^max_ts and
c.flagged_invalid == false
)
|> Repo.all()
|> Enum.any?(fn c ->
a = {String.upcase(c.station1), String.upcase(c.grid1)}
b = {String.upcase(c.station2), String.upcase(c.grid2)}
pair = Enum.sort([a, b])
pair == Enum.sort([{s1, g1}, {s2, g2}])
end)
end
# ── Contact Edits ───────────────────────────────────────────────
@doc """

View file

@ -92,4 +92,40 @@ defmodule Microwaveprop.Radio.ContactSubmissionTest do
refute Ecto.Changeset.get_change(changeset, :user_submitted)
end
end
describe "create_contact/1 deduplication" do
alias Microwaveprop.Radio
test "rejects duplicate contact within 1-hour window" do
assert {:ok, _contact} = Radio.create_contact(@valid_attrs)
assert {:error, changeset} = Radio.create_contact(@valid_attrs)
assert "a matching contact already exists within the last hour" in errors_on(changeset).station1
end
test "allows same stations on a different band" do
assert {:ok, _} = Radio.create_contact(@valid_attrs)
assert {:ok, _} = Radio.create_contact(%{@valid_attrs | band: "2304"})
end
test "allows same stations with different grids" do
assert {:ok, _} = Radio.create_contact(@valid_attrs)
assert {:ok, _} = Radio.create_contact(%{@valid_attrs | grid1: "EM13"})
end
test "allows contact outside the 1-hour window" do
assert {:ok, _} = Radio.create_contact(@valid_attrs)
later = DateTime.add(@valid_attrs.qso_timestamp, 3601, :second)
assert {:ok, _} = Radio.create_contact(%{@valid_attrs | qso_timestamp: later})
end
test "detects duplicates regardless of station order" do
assert {:ok, _} = Radio.create_contact(@valid_attrs)
swapped = %{@valid_attrs | station1: "K5TR", grid1: "EM00", station2: "W5XD", grid2: "EM12"}
assert {:error, changeset} = Radio.create_contact(swapped)
assert "a matching contact already exists within the last hour" in errors_on(changeset).station1
end
end
end