diff --git a/lib/microwaveprop/radio.ex b/lib/microwaveprop/radio.ex index 7d4d168d..dc6fbb3d 100644 --- a/lib/microwaveprop/radio.ex +++ b/lib/microwaveprop/radio.ex @@ -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 """ diff --git a/test/microwaveprop/radio/contact_submission_test.exs b/test/microwaveprop/radio/contact_submission_test.exs index 5405244a..54db87b4 100644 --- a/test/microwaveprop/radio/contact_submission_test.exs +++ b/test/microwaveprop/radio/contact_submission_test.exs @@ -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