prop/lib/microwaveprop/radio/contacts.ex
Graham McIntire 0c3be97abb
Some checks failed
Build base image / Build and push base image (push) Successful in 3m10s
Build and Push / Build and Push Docker Image (push) Failing after 14s
Build prop-grid-rs / Test, build, push (push) Successful in 12m52s
fix: resolve 27 security, architecture, test, and performance audit findings
P0 (security-critical):
- Gate CSV/ADIF upload tabs behind authentication, add 30s cooldown to all upload handlers
- Cap CSV/ADIF imports at 2,000 rows server-side in both parsers
- Add submitter_verified boolean to contacts (client-cannot-set, anonymous=false)
- Create k8s/secret.example.yaml with placeholders, add LIVE_VIEW_SIGNING_SALT

P1 (high-priority):
- Add Mox.verify_on_exit!() to valkey_test.exs
- Replace DateTime.utc_now() truncation with static ~U literals in map_live_test.exs
- Replace Process.sleep with render_async in pskr_spots_live_test.exs (6 occurrences)
- Add MonitorLive.Show test coverage (4 tests: owner view, non-owner redirect, config success/error)
- Extract duct-detection and mechanism-classification logic from ContactLive.Show into Propagation.PathAnalysis
- Split ContactLive.Show render into 12 function components
- Update CLAUDE.md: remove stale ML model, mark HRDPS active, add backtest/pskr dirs
- Batch CSV import enrichment jobs via new enqueue_for_contacts/1

P2 (medium-priority):
- Set secure:true on session and remember-me cookies in production
- Change SMTP TLS from verify_none to verify_peer with public_key cacerts
- Make /metrics fail-closed in production when PROMETHEUS_AUTH_TOKEN unset
- Add RateLimiter (anon_limit:10, auth_limit:60) to /api/contacts/map
- Add content-security-policy-report-only header
- Add comment noting String.to_atom is compile-time safe in hrdps_client.ex
- Delegate duplicated haversine_km to canonical Microwaveprop.Geo.haversine_km/4
- Consolidate score-tier/color/verdict formatting into Microwaveprop.Format
- Update CLAUDE.md testing section to match actual raw-string-matching practice
- Batch HrrrPointEnqueuer Repo.insert_all calls to single round-trip
- Split weather.ex (1696→216 lines) and radio.ex (1285→54 lines) into purpose-based sub-facades

P3 (low-priority):
- Add LIVE_VIEW_SIGNING_SALT warning comment, extend filter_parameters
- Add host/community validation to snmp_client.ex
- Add raw/1 safety comment in algo_live.ex
- Add hex-audit and cargo-audit Makefile targets
- Add privacy_live smoke test
- Replace notify_listener busy-poll loop with Process.monitor/1 + assert_receive
- Add ContactCommonVolumeRadar changeset validation tests (5 tests)
2026-07-27 18:19:37 -05:00

607 lines
22 KiB
Elixir

defmodule Microwaveprop.Radio.Contacts do
@moduledoc false
import Ecto.Query
alias Microwaveprop.Accounts.Scope
alias Microwaveprop.Accounts.User
alias Microwaveprop.Cache
alias Microwaveprop.Radio.BandResolver
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Repo
@per_page 20
@max_per_page 200
@sortable_fields ~w(station1 station2 band mode distance_km qso_timestamp inserted_at)a
@count_cache_key {__MODULE__, :total_contact_count}
@count_cache_ttl_ms 30_000
@map_payload_cache_key {__MODULE__, :contact_map_payload}
@map_payload_ttl_ms 10 * 60 * 1_000
@dedup_window_seconds 3600
@type contact_page :: %{
entries: [Contact.t()],
grouped_entries: [{Contact.t(), [Contact.t()]}],
page: pos_integer(),
total_pages: pos_integer(),
total_entries: non_neg_integer()
}
# ── Map payload ──
@spec contact_map_payload() :: %{json: iodata(), count: non_neg_integer(), bands: [integer()]}
def contact_map_payload do
if Application.get_env(:microwaveprop, :cache_contact_map, true) do
Cache.fetch_or_store(@map_payload_cache_key, @map_payload_ttl_ms, fn ->
build_contact_map_payload()
end)
else
build_contact_map_payload()
end
end
defp build_contact_map_payload do
contacts = load_contacts_for_map()
bands = contacts |> Enum.map(fn [_, _, _, _, band | _] -> band end) |> Enum.uniq() |> Enum.sort()
%{json: Jason.encode_to_iodata!(contacts), count: length(contacts), bands: bands}
end
defp load_contacts_for_map do
from(c in Contact,
where: not is_nil(c.pos1) and not is_nil(c.pos2) and c.private == false,
select: %{
id: c.id,
pos1: c.pos1,
pos2: c.pos2,
band: c.band,
station1: c.station1,
station2: c.station2,
mode: c.mode,
distance_km: c.distance_km,
qso_timestamp: c.qso_timestamp
}
)
|> Repo.all()
|> Enum.map(&format_map_contact/1)
|> Enum.reject(&is_nil/1)
|> dedup_map_reciprocals()
end
defp format_map_contact(c) do
lat1 = c.pos1["lat"]
lon1 = c.pos1["lon"]
lat2 = c.pos2["lat"]
lon2 = c.pos2["lon"]
if lat1 && lon1 && lat2 && lon2 do
[
lat1,
lon1,
lat2,
lon2,
format_map_band(c.band),
c.station1,
c.station2,
c.mode,
format_map_distance(c.distance_km),
format_map_timestamp(c.qso_timestamp),
c.id
]
end
end
defp format_map_band(nil), do: 0
defp format_map_band(band), do: Decimal.to_integer(band)
defp format_map_distance(nil), do: nil
defp format_map_distance(d), do: Decimal.to_float(d)
defp format_map_timestamp(nil), do: nil
defp format_map_timestamp(ts), do: Calendar.strftime(ts, "%Y-%m-%d %H:%M")
defp dedup_map_reciprocals(contacts),
do:
Enum.uniq_by(contacts, fn [_lat1, _lon1, _lat2, _lon2, band, s1, s2, _mode, _dist, ts, _id] ->
stations = Enum.sort([s1 || "", s2 || ""])
hour = if ts, do: String.slice(ts, 0..12), else: ""
{stations, band, hour}
end)
# ── User queries ──
@spec list_contacts_for_user(User.t(), User.t() | nil) :: [Contact.t()]
def list_contacts_for_user(%User{} = owner, viewer \\ nil) do
Contact
|> where([c], c.user_id == ^owner.id)
|> filter_private_for_viewer(owner, viewer)
|> order_by([c], desc: c.qso_timestamp, desc: c.id)
|> preload(:user)
|> Repo.all()
end
defp filter_private_for_viewer(query, %User{id: owner_id}, %User{id: viewer_id}) when owner_id == viewer_id, do: query
defp filter_private_for_viewer(query, _owner, %User{is_admin: true}), do: query
defp filter_private_for_viewer(query, _owner, _viewer), do: where(query, [c], c.private == false)
@spec list_contacts_involving_callsign(String.t() | nil, User.t() | nil) :: [Contact.t()]
def list_contacts_involving_callsign(callsign, viewer \\ nil)
def list_contacts_involving_callsign(callsign, _viewer) when callsign in [nil, ""], do: []
def list_contacts_involving_callsign(callsign, viewer) when is_binary(callsign) do
upcased = String.upcase(callsign)
Contact
|> where([c], fragment("upper(?)", c.station1) == ^upcased or fragment("upper(?)", c.station2) == ^upcased)
|> filter_private_for_callsign_viewer(viewer)
|> order_by([c], desc: c.qso_timestamp, desc: c.id)
|> limit(100)
|> preload(:user)
|> Repo.all()
end
defp filter_private_for_callsign_viewer(query, nil), do: where(query, [c], c.private == false)
defp filter_private_for_callsign_viewer(query, %User{is_admin: true}), do: query
defp filter_private_for_callsign_viewer(query, %User{callsign: callsign}) do
upcased = String.upcase(callsign)
where(
query,
[c],
c.private == false or
(c.private == true and
(fragment("upper(?)", c.station1) == ^upcased or fragment("upper(?)", c.station2) == ^upcased))
)
end
# ── Paginated list ──
@spec list_contacts(keyword()) :: contact_page()
def list_contacts(opts \\ []) do
page = max(Keyword.get(opts, :page, 1), 1)
per_page = clamp_per_page(Keyword.get(opts, :per_page, @per_page))
offset = (page - 1) * per_page
search = Keyword.get(opts, :search)
scope = Keyword.get(opts, :scope)
{sort_field, sort_dir} = sort_opts(opts)
base_query = Contact |> maybe_search(search) |> filter_private(scope)
total_entries = total_entries_for(search, scope, base_query)
total_pages = max(ceil(total_entries / per_page), 1)
entries =
base_query
|> order_by([q], [{^sort_dir, field(q, ^sort_field)}, {^sort_dir, q.id}])
|> limit(^per_page)
|> offset(^offset)
|> Repo.all()
|> Repo.preload(:user)
%{
entries: entries,
grouped_entries: group_reciprocals(entries),
page: page,
total_pages: total_pages,
total_entries: total_entries
}
end
defp clamp_per_page(value) when is_integer(value) and value >= 1, do: min(value, @max_per_page)
defp clamp_per_page(_), do: @per_page
@spec group_reciprocals([Contact.t()]) :: [{Contact.t(), [Contact.t()]}]
def group_reciprocals(contacts) do
contacts |> Enum.group_by(&reciprocal_key/1) |> Enum.map(fn {_key, [primary | rest]} -> {primary, rest} end)
end
defp reciprocal_key(contact),
do: {Enum.sort([contact.station1, contact.station2]), contact.band, reciprocal_hour_key(contact.qso_timestamp)}
defp reciprocal_hour_key(nil), do: nil
defp reciprocal_hour_key(%_{} = ts), do: Map.take(ts, [:year, :month, :day, :hour])
defp total_entries_for(search, nil, query) when search in [nil, ""] do
if Application.get_env(:microwaveprop, :cache_contact_count, true) do
Cache.fetch_or_store(@count_cache_key, @count_cache_ttl_ms, fn -> Repo.aggregate(query, :count) end)
else
Repo.aggregate(query, :count)
end
end
defp total_entries_for(_search, _scope, query), do: Repo.aggregate(query, :count)
defp filter_private(query, %Scope{user: %User{is_admin: true}}), do: query
defp filter_private(query, %Scope{user: %User{id: user_id}}),
do: where(query, [c], c.private == false or c.user_id == ^user_id)
defp filter_private(query, _), do: where(query, [c], c.private == false)
defp maybe_search(query, nil), do: query
defp maybe_search(query, ""), do: query
defp maybe_search(query, search) do
terms = search |> String.split() |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == ""))
case terms do
[a, b] ->
pa = "%" <> String.upcase(a) <> "%"
pb = "%" <> String.upcase(b) <> "%"
where(
query,
[q],
(ilike(q.station1, ^pa) and ilike(q.station2, ^pb)) or (ilike(q.station1, ^pb) and ilike(q.station2, ^pa))
)
_ ->
pattern = "%" <> String.upcase(search) <> "%"
where(query, [q], ilike(q.station1, ^pattern) or ilike(q.station2, ^pattern))
end
end
defp sort_opts(opts) do
sort_by = Keyword.get(opts, :sort_by, :qso_timestamp)
sort_order = Keyword.get(opts, :sort_order, :desc)
field = if sort_by in @sortable_fields, do: sort_by, else: :qso_timestamp
dir = if sort_order in [:asc, :desc], do: sort_order, else: :desc
{field, dir}
end
# ── Enrichment status ──
@enrichable_statuses [:pending, :failed]
@spec contacts_needing_enrichment(
atom(),
non_neg_integer(),
(Ecto.Query.t() -> Ecto.Query.t()) | nil
) :: [Contact.t()]
def contacts_needing_enrichment(field, limit \\ 500, extra_filter \\ nil) do
query =
Contact
|> where([q], field(q, ^field) in ^@enrichable_statuses and not is_nil(q.pos1))
|> order_by([q], asc: q.qso_timestamp)
|> limit(^limit)
query = if extra_filter, do: extra_filter.(query), else: query
Repo.all(query)
end
@spec unprocessed_contacts(non_neg_integer()) :: [Contact.t()]
def unprocessed_contacts(limit \\ 500), do: contacts_needing_enrichment(:weather_status, limit)
@spec unprocessed_hrrr_contacts(non_neg_integer()) :: [Contact.t()]
def unprocessed_hrrr_contacts(limit \\ 500), do: contacts_needing_enrichment(:hrrr_status, limit)
@spec unprocessed_terrain_contacts(non_neg_integer()) :: [Contact.t()]
def unprocessed_terrain_contacts(limit \\ 500),
do: contacts_needing_enrichment(:terrain_status, limit, &where(&1, [q], not is_nil(q.pos2)))
@spec unprocessed_iemre_contacts(non_neg_integer()) :: [Contact.t()]
def unprocessed_iemre_contacts(limit \\ 500), do: contacts_needing_enrichment(:iemre_status, limit)
@spec unprocessed_radar_contacts(non_neg_integer()) :: [Contact.t()]
def unprocessed_radar_contacts(limit \\ 500),
do: contacts_needing_enrichment(:radar_status, limit, &where(&1, [q], not is_nil(q.pos2)))
@spec set_enrichment_status!([Ecto.UUID.t()], atom(), atom()) :: {non_neg_integer(), nil | [any()]}
def set_enrichment_status!(ids, field, status) do
Contact |> where([q], q.id in ^ids and field(q, ^field) != ^status) |> Repo.update_all(set: [{field, status}])
end
@spec mark_weather_queued!([Ecto.UUID.t()]) :: {non_neg_integer(), nil | [any()]}
def mark_weather_queued!(ids), do: set_enrichment_status!(ids, :weather_status, :queued)
@spec mark_hrrr_queued!([Ecto.UUID.t()]) :: {non_neg_integer(), nil | [any()]}
def mark_hrrr_queued!(ids), do: set_enrichment_status!(ids, :hrrr_status, :queued)
@spec mark_terrain_queued!([Ecto.UUID.t()]) :: {non_neg_integer(), nil | [any()]}
def mark_terrain_queued!(ids), do: set_enrichment_status!(ids, :terrain_status, :queued)
@spec mark_iemre_queued!([Ecto.UUID.t()]) :: {non_neg_integer(), nil | [any()]}
def mark_iemre_queued!(ids), do: set_enrichment_status!(ids, :iemre_status, :queued)
# ── Path points ──
@spec contact_path_points(Contact.t()) :: [{float(), float()}]
def contact_path_points(%{pos1: nil}), do: []
def contact_path_points(%{pos1: pos1, pos2: nil}) do
lat = pos1["lat"]
lon = pos1["lon"]
if lat && lon, do: [{lat, lon}], else: []
end
def contact_path_points(%{pos1: pos1, pos2: pos2}), do: build_path_points(extract_latlon(pos1), extract_latlon(pos2))
defp extract_latlon(%{"lat" => lat} = pos) when is_number(lat), do: if(pos["lon"], do: {lat, pos["lon"]})
defp extract_latlon(_), do: nil
defp build_path_points({lat1, lon1}, {lat2, lon2}) do
{mid_lat, mid_lon} = great_circle_midpoint(lat1, lon1, lat2, lon2)
[{lat1, lon1}, {mid_lat, mid_lon}, {lat2, lon2}]
end
defp build_path_points({lat1, lon1}, _), do: [{lat1, lon1}]
defp build_path_points(_, _), do: []
defp great_circle_midpoint(lat1, lon1, lat2, lon2) do
rlat1 = deg_to_rad(lat1)
rlat2 = deg_to_rad(lat2)
rlon1 = deg_to_rad(lon1)
dlon = deg_to_rad(lon2 - lon1)
bx = :math.cos(rlat2) * :math.cos(dlon)
by = :math.cos(rlat2) * :math.sin(dlon)
mid_lat = :math.atan2(:math.sin(rlat1) + :math.sin(rlat2), :math.sqrt((:math.cos(rlat1) + bx) ** 2 + by ** 2))
mid_lon = rlon1 + :math.atan2(by, :math.cos(rlat1) + bx)
{rad_to_deg(mid_lat), normalize_lon(rad_to_deg(mid_lon))}
end
defp rad_to_deg(rad), do: rad * 180.0 / :math.pi()
defp normalize_lon(lon) when lon > 180.0, do: lon - 360.0
defp normalize_lon(lon) when lon < -180.0, do: lon + 360.0
defp normalize_lon(lon), do: lon
defp deg_to_rad(deg), do: deg * :math.pi() / 180
# ── Haversine / Backfill ──
@spec haversine_km(number(), number(), number(), number()) :: float()
defdelegate haversine_km(lat1, lon1, lat2, lon2), to: Microwaveprop.Geo
@spec backfill_distances([Contact.t()]) :: Postgrex.Result.t() | nil
def backfill_distances(contacts) do
updates =
for contact <- contacts,
is_nil(contact.distance_km) && contact.pos1 && contact.pos2,
lat1 = contact.pos1["lat"],
lon1 = contact.pos1["lon"],
lat2 = contact.pos2["lat"],
lon2 = contact.pos2["lon"],
lat1 && lon1 && lat2 && lon2 do
dist = lat1 |> haversine_km(lon1, lat2, lon2) |> round()
{contact.id, dist}
end
execute_distance_updates(updates)
end
defp execute_distance_updates([]), do: nil
defp execute_distance_updates(updates) do
{values_sql, params} =
updates
|> Enum.with_index()
|> Enum.reduce({"", []}, fn {{id, dist}, i}, {sql, params} ->
frag = "($#{i * 2 + 1}::uuid, $#{i * 2 + 2}::numeric)"
sep = if sql == "", do: "", else: ", "
{sql <> sep <> frag, params ++ [Ecto.UUID.dump!(id), dist]}
end)
Repo.query!(
"UPDATE contacts AS c SET distance_km = v.dist FROM (VALUES #{values_sql}) AS v(id, dist) WHERE c.id = v.id",
params
)
end
# ── Single contact ops ──
@spec get_contact!(term()) :: Contact.t()
def get_contact!(id), do: Contact |> Repo.get!(id) |> Repo.preload(:flagged_by_user)
@spec can_view?(Contact.t(), Scope.t() | nil) :: boolean()
def can_view?(%Contact{private: false}, _scope), do: true
def can_view?(%Contact{}, %Scope{user: %User{is_admin: true}}), do: true
def can_view?(%Contact{user_id: user_id}, %Scope{user: %User{id: user_id}}) when not is_nil(user_id), do: true
def can_view?(%Contact{}, _scope), do: false
@spec delete_contact!(Contact.t()) :: Contact.t()
def delete_contact!(%Contact{} = contact), do: Repo.delete!(contact)
@spec toggle_flagged_invalid!(Contact.t(), User.t() | nil) :: Contact.t()
def toggle_flagged_invalid!(contact, flagger \\ nil) do
new_state = !contact.flagged_invalid
attrs =
if new_state do
%{
flagged_invalid: true,
flagged_by_user_id: flagger && flagger.id,
flagged_at: DateTime.truncate(DateTime.utc_now(), :second)
}
else
%{flagged_invalid: false, flagged_by_user_id: nil, flagged_at: nil}
end
contact |> Ecto.Changeset.change(attrs) |> Repo.update!()
end
@spec list_flagged_contacts() :: [Contact.t()]
def list_flagged_contacts do
Contact |> where([c], c.flagged_invalid == true) |> order_by([c], desc: c.inserted_at, desc: c.id) |> Repo.all()
end
@spec change_contact(Contact.t(), map()) :: Ecto.Changeset.t()
def change_contact(contact, attrs \\ %{}), do: Contact.submission_changeset(contact, attrs)
@spec owner?(Contact.t(), User.t() | nil) :: boolean()
def owner?(%Contact{user_id: nil}, _user), do: false
def owner?(%Contact{}, nil), do: false
def owner?(%Contact{user_id: uid}, %User{id: uid}), do: true
def owner?(%Contact{}, %User{}), do: false
# ── Position resolution ──
@spec ensure_positions!(Contact.t()) :: Contact.t()
def ensure_positions!(contact) do
needs_pos1 = is_nil(contact.pos1) && contact.grid1
needs_pos2 = is_nil(contact.pos2) && contact.grid2
if needs_pos1 || needs_pos2, do: do_ensure_positions(contact), else: contact
end
defp do_ensure_positions(contact) do
pos1 = contact.pos1 || latlon_from_grid(contact.grid1)
pos2 = contact.pos2 || latlon_from_grid(contact.grid2)
distance = compute_distance(pos1, pos2, contact.distance_km)
changes = build_position_changes(contact, pos1, pos2, distance)
apply_position_changes(contact, changes)
end
defp compute_distance(pos1, pos2, _existing_distance) when not is_nil(pos1) and not is_nil(pos2) do
lon1 = pos1["lon"]
lon2 = pos2["lon"]
pos1["lat"] |> haversine_km(lon1, pos2["lat"], lon2) |> round() |> Decimal.new()
end
defp compute_distance(_pos1, _pos2, existing_distance), do: existing_distance
defp build_position_changes(contact, pos1, pos2, distance) do
Enum.reduce([pos1: pos1, pos2: pos2, distance_km: distance], %{}, fn {key, val}, acc ->
if val && is_nil(Map.get(contact, key)), do: Map.put(acc, key, val), else: acc
end)
end
defp apply_position_changes(contact, changes) when changes == %{}, do: contact
defp apply_position_changes(contact, changes) do
changes = reset_enrichment_statuses(contact, changes)
contact |> Ecto.Changeset.change(changes) |> Repo.update!()
end
defp reset_enrichment_statuses(contact, changes) do
new_pos1 = Map.get(changes, :pos1)
new_pos2 = Map.get(changes, :pos2)
changes =
if new_pos1 do
changes
|> maybe_reset_status(:hrrr_status, contact.hrrr_status)
|> maybe_reset_status(:weather_status, contact.weather_status)
|> maybe_reset_status(:iemre_status, contact.iemre_status)
else
changes
end
if new_pos1 && (new_pos2 || contact.pos2),
do: maybe_reset_status(changes, :terrain_status, contact.terrain_status),
else: changes
end
defp maybe_reset_status(changes, field, :unavailable), do: Map.put(changes, field, :pending)
defp maybe_reset_status(changes, field, :complete), do: Map.put(changes, field, :pending)
defp maybe_reset_status(changes, _field, _status), do: changes
defp latlon_from_grid(nil), do: nil
defp latlon_from_grid(grid) do
case Maidenhead.to_latlon(grid) do
{:ok, {lat, lon}} ->
%{"lat" => lat, "lon" => lon}
_ ->
truncated = String.slice(grid, 0, div(String.length(grid), 2) * 2)
case Maidenhead.to_latlon(truncated) do
{:ok, {lat, lon}} -> %{"lat" => lat, "lon" => lon}
_ -> nil
end
end
end
# ── Create contact ──
@spec create_contact(map(), String.t() | nil) ::
{:ok, Contact.t()} | {:error, Ecto.Changeset.t()} | {:error, :duplicate, Contact.t()}
def create_contact(attrs, user_id \\ nil) do
changeset =
Contact.submission_changeset(
%Contact{user_id: user_id, submitter_verified: user_id != nil},
normalize_band_attr(attrs)
)
if changeset.valid?, do: insert_validated_contact(changeset), else: {:error, %{changeset | action: :insert}}
end
defp normalize_band_attr(attrs) do
cond do
raw = Map.get(attrs, "band") ->
case BandResolver.resolve_as_string(raw) do
nil -> attrs
canonical -> Map.put(attrs, "band", canonical)
end
raw = Map.get(attrs, :band) ->
case BandResolver.resolve_as_string(raw) do
nil -> attrs
canonical -> Map.put(attrs, :band, canonical)
end
true ->
attrs
end
end
defp insert_validated_contact(changeset) do
if existing = find_duplicate_contact(changeset) do
{:error, :duplicate, existing}
else
resolve_grids_and_insert(changeset)
end
end
defp resolve_grids_and_insert(changeset) 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()
result =
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()
with {:ok, _} <- result, do: invalidate_contact_map_caches()
result
_ ->
changeset = Ecto.Changeset.add_error(changeset, :grid1, "could not resolve grid to coordinates")
{:error, %{changeset | action: :insert}}
end
rescue
_e in Ecto.ConstraintError ->
case find_duplicate_contact(changeset) do
nil -> {:error, :constraint_error}
existing -> {:error, :duplicate, existing}
end
end
defp find_duplicate_contact(changeset) do
s1 = changeset |> Ecto.Changeset.get_field(:station1) |> to_string() |> String.upcase()
s2 = changeset |> Ecto.Changeset.get_field(:station2) |> to_string() |> String.upcase()
g1 = changeset |> Ecto.Changeset.get_field(:grid1) |> to_string() |> String.upcase()
g2 = changeset |> Ecto.Changeset.get_field(: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)
Repo.one(
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 and fragment("LEAST(UPPER(station1), UPPER(station2)) = ?", ^min(s1, s2)) and
fragment("GREATEST(UPPER(station1), UPPER(station2)) = ?", ^max(s1, s2)) and
fragment("LEAST(UPPER(grid1), UPPER(grid2)) = ?", ^min(g1, g2)) and
fragment("GREATEST(UPPER(grid1), UPPER(grid2)) = ?", ^max(g1, g2))
)
)
end
@doc false
@spec invalidate_contact_map_caches() :: :ok
def invalidate_contact_map_caches do
Cache.invalidate(@count_cache_key)
Cache.invalidate(@map_payload_cache_key)
Cache.invalidate(MicrowavepropWeb.ContactMapController.cache_key())
:ok
end
end