- Bug #12: Lower rate limit to 15/min - Bug #13: Wrap model loading in Task.start - Bug #14: Fix classify_time_period guard gap at -3.0 - Bug #15: Not applicable (Elixir has no ?? operator) - Bug #16: Remove fallback Repo.get for station preload - Bug #17: Extract cache_key() in ContactMapController - Bug #18: Add has_many :contacts and :beacons to User schema - A&D #4: Move serve_markdown_if_requested after secure headers - Config #1: Move signing_salt to runtime.exs env var - Config #2: Use --check-unused instead of --unused - Config #3: Re-enable UnsafeToAtom Credo check - Config #5: Re-enable LeakyEnvironment Credo check - Add @spec annotations to fix re-enabled Specs violations - Replace String.to_atom with to_existing_atom where guarded
1300 lines
44 KiB
Elixir
1300 lines
44 KiB
Elixir
defmodule Microwaveprop.Radio 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.ContactEdit
|
|
alias Microwaveprop.Radio.Maidenhead
|
|
alias Microwaveprop.Repo
|
|
alias Microwaveprop.Workers.ContactWeatherEnqueueWorker
|
|
|
|
@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
|
|
|
|
@type contact_page :: %{
|
|
entries: [Contact.t()],
|
|
grouped_entries: [{Contact.t(), [Contact.t()]}],
|
|
page: pos_integer(),
|
|
total_pages: pos_integer(),
|
|
total_entries: non_neg_integer()
|
|
}
|
|
|
|
@doc """
|
|
Pre-encoded JSON payload for `/contacts/map`. Computing this requires loading
|
|
every contact with coordinates, deriving formatted rows, deduplicating
|
|
reciprocals, and JSON-encoding ~58k rows — ~150-300ms of work that would
|
|
otherwise run on every page mount. Cached for 10 minutes, invalidated when
|
|
a new contact is inserted.
|
|
"""
|
|
@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)
|
|
end
|
|
|
|
@doc """
|
|
Returns contacts submitted by the given user, newest first. Used by
|
|
the `/u/:callsign` profile page. Private contacts are filtered out
|
|
unless the viewer is the profile owner or an admin — anonymous or
|
|
third-party viewers must not see private rows.
|
|
"""
|
|
@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)
|
|
end
|
|
|
|
@doc """
|
|
Returns the 100 most recent contacts where the given callsign appears
|
|
as either `station1` or `station2`, newest first. Matching is
|
|
case-insensitive.
|
|
|
|
When a `viewer` is provided, owner and admin viewers see private
|
|
contacts; other viewers only see non-private contacts.
|
|
"""
|
|
@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
|
|
# If the viewer is a co-station on the contact, let them see it even if private
|
|
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
|
|
|
|
@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
|
|
|
|
@doc """
|
|
Groups reciprocal contacts (same station pair swapped, same band, same timestamp).
|
|
Returns a list of `{primary, reciprocals}` tuples where reciprocals is
|
|
a (possibly empty) list of matching contacts.
|
|
"""
|
|
@spec group_reciprocals([Contact.t()]) :: [{Contact.t(), [Contact.t()]}]
|
|
def group_reciprocals(contacts) do
|
|
{groups, _seen_ids} =
|
|
Enum.reduce(contacts, {[], MapSet.new()}, fn contact, {groups, seen} ->
|
|
group_contact(contact, contacts, groups, seen)
|
|
end)
|
|
|
|
Enum.reverse(groups)
|
|
end
|
|
|
|
defp group_contact(contact, contacts, groups, seen) do
|
|
if MapSet.member?(seen, contact.id) do
|
|
{groups, seen}
|
|
else
|
|
reciprocals =
|
|
Enum.filter(contacts, fn other ->
|
|
other.id != contact.id and
|
|
not MapSet.member?(seen, other.id) and
|
|
other.band == contact.band and
|
|
reciprocal_pair?(contact, other)
|
|
end)
|
|
|
|
new_seen = Enum.reduce(reciprocals, MapSet.put(seen, contact.id), &MapSet.put(&2, &1.id))
|
|
{[{contact, reciprocals} | groups], new_seen}
|
|
end
|
|
end
|
|
|
|
defp reciprocal_pair?(a, b) do
|
|
same_stations =
|
|
(a.station1 == b.station2 and a.station2 == b.station1) or
|
|
(a.station1 == b.station1 and a.station2 == b.station2)
|
|
|
|
same_stations and same_hour?(a.qso_timestamp, b.qso_timestamp)
|
|
end
|
|
|
|
defp same_hour?(t1, t2) do
|
|
t1 |> NaiveDateTime.truncate(:second) |> Map.take([:year, :month, :day, :hour]) ==
|
|
t2 |> NaiveDateTime.truncate(:second) |> Map.take([:year, :month, :day, :hour])
|
|
end
|
|
|
|
# Only cache the unscoped, unsearched total — scope-dependent counts
|
|
# differ per-viewer and must not share a cache.
|
|
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)
|
|
|
|
# Visibility filter for scope-aware queries. Admins see everything;
|
|
# logged-in users see non-private + their own private; others see only
|
|
# non-private.
|
|
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)
|
|
end
|
|
|
|
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 queries ────────────────────────────────────
|
|
|
|
@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)
|
|
|
|
@doc """
|
|
Returns a list of {lat, lon} points along the contact path: pos1, midpoint, pos2.
|
|
Returns [pos1] if pos2 is nil, or [] if pos1 is nil.
|
|
"""
|
|
@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))
|
|
end
|
|
|
|
defp extract_latlon(%{"lat" => lat} = pos) when is_number(lat) do
|
|
lon = pos["lon"]
|
|
if lon, do: {lat, lon}
|
|
end
|
|
|
|
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: []
|
|
|
|
# Spherical-vector midpoint between two lat/lon points. The arithmetic
|
|
# mean of two longitudes folds across the anti-meridian (e.g.
|
|
# `(179 + -179) / 2 == 0` back at Greenwich), so paths that cross the
|
|
# date line otherwise get a bogus center.
|
|
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
|
|
|
|
@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
|
|
# Build a single UPDATE ... FROM (VALUES ...) statement
|
|
# Ecto.UUID.dump!/1 converts string UUIDs to the 16-byte binary Postgrex expects
|
|
{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
|
|
|
|
defp deg_to_rad(deg), do: deg * :math.pi() / 180
|
|
|
|
@spec get_contact!(Ecto.UUID.t()) :: Contact.t()
|
|
def get_contact!(id) do
|
|
Contact
|
|
|> Repo.get!(id)
|
|
|> Repo.preload(:flagged_by_user)
|
|
end
|
|
|
|
@doc """
|
|
True when the viewer (per `Scope`) is allowed to see `contact`.
|
|
Public contacts are always viewable; private contacts only by the
|
|
submitter or an admin.
|
|
"""
|
|
@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
|
|
|
|
@doc """
|
|
Hard-delete a contact and everything attached to it. All FK
|
|
references (`terrain_profiles`, `contact_edits`,
|
|
`contact_common_volume_radar`) declare `on_delete: :delete_all`,
|
|
so a single `Repo.delete!` cascades through.
|
|
|
|
Admin-only operation — callers must gate with `is_admin` before
|
|
invoking. Irreversible: prefer `toggle_flagged_invalid!/2` when
|
|
the data should be hidden but kept in the audit trail.
|
|
"""
|
|
@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
|
|
# Clear attribution on unflag — the next flag (potentially
|
|
# by a different admin) gets a fresh record, and the badge
|
|
# stops claiming a stale flagger after the contact was
|
|
# restored to valid.
|
|
%{flagged_invalid: false, flagged_by_user_id: nil, flagged_at: nil}
|
|
end
|
|
|
|
contact
|
|
|> Ecto.Changeset.change(attrs)
|
|
|> Repo.update!()
|
|
end
|
|
|
|
@doc """
|
|
Returns all flagged-invalid contacts, newest first. Admin-only
|
|
viewer — the admin contact-edits page uses this to surface flagged
|
|
QSOs for review alongside pending edit requests.
|
|
"""
|
|
@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)
|
|
end
|
|
|
|
@doc """
|
|
Computes pos1/pos2/distance_km from grid squares if missing.
|
|
Returns the updated contact, or the original if already computed or grids are missing.
|
|
"""
|
|
@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
|
|
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
|
|
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}
|
|
|
|
_ ->
|
|
# Try truncating to nearest valid even length (e.g. "FN03c" → "FN03")
|
|
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
|
|
|
|
@dedup_window_seconds 3600
|
|
|
|
@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
|
|
# Place user_id on the struct BEFORE running submission_changeset so
|
|
# validate_user_or_email/1 can see it. Otherwise an authenticated
|
|
# caller that omits submitter_email gets a spurious "can't be blank"
|
|
# error even though user_id satisfies the ownership invariant.
|
|
changeset = Contact.submission_changeset(%Contact{user_id: user_id}, normalize_band_attr(attrs))
|
|
|
|
if changeset.valid? do
|
|
insert_validated_contact(changeset)
|
|
else
|
|
{:error, %{changeset | action: :insert}}
|
|
end
|
|
end
|
|
|
|
# Translate ADIF wavelength labels ("33cm") and raw frequency strings
|
|
# ("903.100") into our canonical MHz integer string so every create
|
|
# path — manual form, CSV commit, ADIF commit, API, console — agrees
|
|
# on what "33cm" means before the changeset's validate_inclusion runs.
|
|
# Atom and string keys are both supported so this works regardless of
|
|
# where the map came from.
|
|
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()
|
|
end
|
|
|
|
result
|
|
|
|
_ ->
|
|
changeset = Ecto.Changeset.add_error(changeset, :grid1, "could not resolve grid to coordinates")
|
|
{:error, %{changeset | action: :insert}}
|
|
end
|
|
rescue
|
|
_e in Ecto.ConstraintError ->
|
|
# Unique constraint violation from a concurrent insert — the
|
|
# dedup check at `insert_validated_contact` raced with another
|
|
# process. Re-check and return the existing contact.
|
|
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)
|
|
|
|
expected_pair = Enum.sort([{s1, g1}, {s2, g2}])
|
|
|
|
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.find(fn c ->
|
|
pair =
|
|
Enum.sort([
|
|
{String.upcase(c.station1), String.upcase(c.grid1)},
|
|
{String.upcase(c.station2), String.upcase(c.grid2)}
|
|
])
|
|
|
|
pair == expected_pair
|
|
end)
|
|
end
|
|
|
|
@refinement_fields ~w(grid1 grid2 mode)a
|
|
@refinement_allowed_modes ~w(CW SSB FM FT8 FT4 Q65)
|
|
|
|
@doc """
|
|
Apply a grid/mode refinement from an import row to an existing contact.
|
|
|
|
`changes` may contain any of `:grid1`, `:grid2`, or `:mode`. Grid changes
|
|
trigger recomputation of the corresponding `pos` and the `distance_km`,
|
|
and reset all four enrichment status fields to `:pending` so the caller
|
|
can re-enqueue the enrichment pipeline. Mode-only changes do not touch
|
|
positions or enrichment status.
|
|
"""
|
|
@spec apply_contact_refinement(Contact.t(), map()) ::
|
|
{:ok, Contact.t()} | {:error, Ecto.Changeset.t()}
|
|
def apply_contact_refinement(%Contact{} = contact, changes) when is_map(changes) do
|
|
cleaned = Map.take(changes, @refinement_fields)
|
|
|
|
if cleaned == %{} do
|
|
{:ok, contact}
|
|
else
|
|
do_apply_contact_refinement(contact, cleaned)
|
|
end
|
|
end
|
|
|
|
defp do_apply_contact_refinement(contact, changes) do
|
|
changeset =
|
|
contact
|
|
|> Ecto.Changeset.cast(changes, @refinement_fields)
|
|
|> validate_refinement_mode()
|
|
|> maybe_recompute_refinement_positions(contact, changes)
|
|
|
|
if changeset.valid? do
|
|
Repo.update(changeset)
|
|
else
|
|
{:error, %{changeset | action: :update}}
|
|
end
|
|
end
|
|
|
|
defp validate_refinement_mode(changeset) do
|
|
Ecto.Changeset.validate_inclusion(changeset, :mode, @refinement_allowed_modes)
|
|
end
|
|
|
|
defp maybe_recompute_refinement_positions(changeset, contact, changes) do
|
|
new_grid1 = Map.get(changes, :grid1)
|
|
new_grid2 = Map.get(changes, :grid2)
|
|
|
|
cond do
|
|
new_grid1 && new_grid2 ->
|
|
put_pos_changes(changeset, new_grid1, new_grid2, :both)
|
|
|
|
new_grid1 ->
|
|
put_pos_changes(changeset, new_grid1, contact.grid2, :side1)
|
|
|
|
new_grid2 ->
|
|
put_pos_changes(changeset, contact.grid1, new_grid2, :side2)
|
|
|
|
true ->
|
|
changeset
|
|
end
|
|
end
|
|
|
|
defp put_pos_changes(changeset, grid1, grid2, sides) do
|
|
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
|
|
|> maybe_put_pos_for_side(:pos1, %{"lat" => lat1, "lon" => lon1}, sides)
|
|
|> maybe_put_pos_for_side(:pos2, %{"lat" => lat2, "lon" => lon2}, sides)
|
|
|> Ecto.Changeset.put_change(:distance_km, distance)
|
|
|> reset_all_enrichment_statuses()
|
|
|
|
_ ->
|
|
Ecto.Changeset.add_error(changeset, :grid1, "could not resolve grid to coordinates")
|
|
end
|
|
end
|
|
|
|
defp maybe_put_pos_for_side(changeset, :pos1, pos, sides) when sides in [:side1, :both] do
|
|
Ecto.Changeset.put_change(changeset, :pos1, pos)
|
|
end
|
|
|
|
defp maybe_put_pos_for_side(changeset, :pos2, pos, sides) when sides in [:side2, :both] do
|
|
Ecto.Changeset.put_change(changeset, :pos2, pos)
|
|
end
|
|
|
|
defp maybe_put_pos_for_side(changeset, _field, _pos, _sides), do: changeset
|
|
|
|
defp reset_all_enrichment_statuses(changeset) do
|
|
changeset
|
|
|> Ecto.Changeset.put_change(:hrrr_status, :pending)
|
|
|> Ecto.Changeset.put_change(:weather_status, :pending)
|
|
|> Ecto.Changeset.put_change(:terrain_status, :pending)
|
|
|> Ecto.Changeset.put_change(:iemre_status, :pending)
|
|
end
|
|
|
|
# ── Contact Edits ───────────────────────────────────────────────
|
|
|
|
@doc """
|
|
Create a proposed edit for a contact. Only stores fields that actually
|
|
differ from the contact's current values. Normalizes callsigns and grids
|
|
to uppercase.
|
|
"""
|
|
@spec create_contact_edit(Contact.t(), User.t(), map()) ::
|
|
{:ok, ContactEdit.t()} | {:error, Ecto.Changeset.t()}
|
|
def create_contact_edit(%Contact{} = contact, user, proposed_changes) when is_map(proposed_changes) do
|
|
# Normalize and diff against current values
|
|
normalized = normalize_proposed(proposed_changes)
|
|
diffed = diff_against_contact(contact, normalized)
|
|
|
|
attrs = %{
|
|
contact_id: contact.id,
|
|
user_id: user.id,
|
|
proposed_changes: diffed
|
|
}
|
|
|
|
%ContactEdit{}
|
|
|> ContactEdit.changeset(attrs)
|
|
|> Repo.insert()
|
|
end
|
|
|
|
# Whitelist of keys an owner / pending-edit submitter is allowed
|
|
# to propose. Anything not in this set is dropped before the
|
|
# changes hit `String.to_existing_atom` + `Ecto.Changeset.change`
|
|
# downstream — without this filter a crafted form could mass-assign
|
|
# `user_id`, `flagged_invalid`, `inserted_at`, etc. on a contact the
|
|
# caller owns. `band` is in the list because admins edit it through
|
|
# this same path; `ContactEdit.changeset` rejects `band` for
|
|
# non-admin pending edits via its own validation.
|
|
@editable_proposed_keys ~w(station1 station2 grid1 grid2 band mode qso_timestamp height1_ft height2_ft private)
|
|
|
|
@doc false
|
|
@spec normalize_proposed(map()) :: map()
|
|
def normalize_proposed(changes) do
|
|
changes
|
|
|> Map.take(@editable_proposed_keys)
|
|
|> normalize_string_field("station1")
|
|
|> normalize_string_field("station2")
|
|
|> normalize_string_field("grid1")
|
|
|> normalize_string_field("grid2")
|
|
|> normalize_string_field("mode")
|
|
|> normalize_integer_field("height1_ft")
|
|
|> normalize_integer_field("height2_ft")
|
|
|> normalize_boolean_field("private")
|
|
|> normalize_timestamp_field("qso_timestamp")
|
|
end
|
|
|
|
# The /contacts/:id edit form pre-fills qso_timestamp as
|
|
# `"YYYY-MM-DD HH:MM"` via Calendar.strftime, then submits whatever
|
|
# the user typed. Coerce to %DateTime{} (UTC) so the diff matches
|
|
# against the stored DateTime instead of always reading as a change,
|
|
# and so the apply path doesn't crash on a non-ISO string. Drop the
|
|
# field when empty or unparseable so a bogus value is treated as
|
|
# "no change" rather than raising.
|
|
defp normalize_timestamp_field(map, key) do
|
|
case Map.get(map, key, :not_provided) do
|
|
:not_provided -> map
|
|
nil -> Map.delete(map, key)
|
|
"" -> Map.delete(map, key)
|
|
%DateTime{} = dt -> Map.put(map, key, dt)
|
|
val when is_binary(val) -> put_or_drop_timestamp(map, key, parse_timestamp(val))
|
|
_ -> Map.delete(map, key)
|
|
end
|
|
end
|
|
|
|
defp put_or_drop_timestamp(map, key, nil), do: Map.delete(map, key)
|
|
defp put_or_drop_timestamp(map, key, %DateTime{} = dt), do: Map.put(map, key, dt)
|
|
|
|
defp parse_timestamp(val) do
|
|
trimmed = String.trim(val)
|
|
|
|
# Accept `"YYYY-MM-DD HH:MM[:SS][Z]"` (form pre-fill, space sep) and
|
|
# the strict ISO `"YYYY-MM-DDTHH:MM:SSZ"` form. NaiveDateTime handles
|
|
# both separators and an optional seconds field.
|
|
iso = trimmed |> String.replace(" ", "T") |> ensure_seconds() |> String.trim_trailing("Z")
|
|
|
|
case NaiveDateTime.from_iso8601(iso) do
|
|
{:ok, ndt} -> DateTime.from_naive!(ndt, "Etc/UTC")
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
defp ensure_seconds(s) do
|
|
# Add ":00" before any trailing "Z" / end-of-string when only HH:MM
|
|
# is present, since NaiveDateTime.from_iso8601 requires seconds.
|
|
case Regex.run(~r/^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2})(Z?)$/, s) do
|
|
[_, prefix, tail] -> prefix <> ":00" <> tail
|
|
_ -> s
|
|
end
|
|
end
|
|
|
|
# HTML checkboxes arrive as "true"/"false" strings; Ecto won't coerce
|
|
# those inside a validate_inclusion path, so normalize at the boundary.
|
|
defp normalize_boolean_field(map, key) do
|
|
case Map.get(map, key, :not_provided) do
|
|
:not_provided -> map
|
|
nil -> map
|
|
val when is_boolean(val) -> Map.put(map, key, val)
|
|
"true" -> Map.put(map, key, true)
|
|
"false" -> Map.put(map, key, false)
|
|
_ -> map
|
|
end
|
|
end
|
|
|
|
defp normalize_string_field(map, key) do
|
|
case Map.get(map, key) do
|
|
nil -> map
|
|
val when is_binary(val) -> Map.put(map, key, val |> String.trim() |> String.upcase())
|
|
val -> Map.put(map, key, val)
|
|
end
|
|
end
|
|
|
|
# Forms deliver integer-shaped inputs as strings. Drop the field entirely
|
|
# on empty strings so the diff doesn't register "" != current_value.
|
|
defp normalize_integer_field(map, key) do
|
|
case Map.get(map, key, :not_provided) do
|
|
:not_provided ->
|
|
map
|
|
|
|
nil ->
|
|
map
|
|
|
|
val when is_integer(val) ->
|
|
map
|
|
|
|
"" ->
|
|
Map.delete(map, key)
|
|
|
|
val when is_binary(val) ->
|
|
case Integer.parse(String.trim(val)) do
|
|
{int, ""} -> Map.put(map, key, int)
|
|
_ -> map
|
|
end
|
|
|
|
_ ->
|
|
map
|
|
end
|
|
end
|
|
|
|
@doc false
|
|
@spec diff_against_contact(Contact.t(), map()) :: map()
|
|
def diff_against_contact(contact, proposed) do
|
|
Enum.reduce(proposed, %{}, fn {key, new_val}, acc ->
|
|
current = current_value(contact, key)
|
|
|
|
if values_equal?(current, new_val) do
|
|
acc
|
|
else
|
|
Map.put(acc, key, new_val)
|
|
end
|
|
end)
|
|
end
|
|
|
|
defp current_value(contact, "station1"), do: contact.station1
|
|
defp current_value(contact, "station2"), do: contact.station2
|
|
defp current_value(contact, "grid1"), do: contact.grid1
|
|
defp current_value(contact, "grid2"), do: contact.grid2
|
|
defp current_value(contact, "mode"), do: contact.mode
|
|
|
|
defp current_value(contact, "band") do
|
|
if contact.band, do: Decimal.to_integer(contact.band)
|
|
end
|
|
|
|
defp current_value(contact, "qso_timestamp"), do: contact.qso_timestamp
|
|
defp current_value(contact, "height1_ft"), do: contact.height1_ft
|
|
defp current_value(contact, "height2_ft"), do: contact.height2_ft
|
|
defp current_value(contact, "private"), do: contact.private
|
|
defp current_value(_contact, _key), do: nil
|
|
|
|
defp values_equal?(a, b) when is_binary(a) and is_binary(b) do
|
|
String.upcase(a) == String.upcase(b)
|
|
end
|
|
|
|
defp values_equal?(a, b) when is_integer(a), do: a == to_integer(b)
|
|
defp values_equal?(a, b) when is_integer(b), do: to_integer(a) == b
|
|
defp values_equal?(a, b), do: a == b
|
|
|
|
defp to_integer(v) when is_integer(v), do: v
|
|
defp to_integer(v) when is_binary(v), do: String.to_integer(v)
|
|
defp to_integer(%Decimal{} = v), do: Decimal.to_integer(v)
|
|
defp to_integer(v), do: v
|
|
|
|
@spec list_pending_edits() :: [ContactEdit.t()]
|
|
def list_pending_edits do
|
|
ContactEdit
|
|
|> where([e], e.status == :pending)
|
|
|> order_by([e], desc: e.inserted_at)
|
|
|> preload([:user, :contact])
|
|
|> Repo.all()
|
|
end
|
|
|
|
@doc """
|
|
Base query for pending contact edits with `:user` and `:contact`
|
|
preloaded. Used as a `live_table` `data_provider` so sort/paginate
|
|
can be applied on top.
|
|
"""
|
|
@spec pending_edits_query() :: Ecto.Query.t()
|
|
def pending_edits_query do
|
|
from e in ContactEdit,
|
|
as: :resource,
|
|
where: e.status == :pending,
|
|
preload: [:user, :contact]
|
|
end
|
|
|
|
@spec list_contact_edits(Ecto.UUID.t()) :: [ContactEdit.t()]
|
|
def list_contact_edits(contact_id) do
|
|
ContactEdit
|
|
|> where([e], e.contact_id == ^contact_id)
|
|
|> order_by([e], desc: e.inserted_at)
|
|
|> preload([:user, :reviewed_by])
|
|
|> Repo.all()
|
|
end
|
|
|
|
@spec pending_edit_count() :: non_neg_integer()
|
|
def pending_edit_count do
|
|
ContactEdit
|
|
|> where([e], e.status == :pending)
|
|
|> Repo.aggregate(:count)
|
|
end
|
|
|
|
@spec pending_edit_for_user(Ecto.UUID.t(), Ecto.UUID.t()) :: ContactEdit.t() | nil
|
|
def pending_edit_for_user(contact_id, user_id) do
|
|
ContactEdit
|
|
|> where([e], e.contact_id == ^contact_id and e.user_id == ^user_id and e.status == :pending)
|
|
|> Repo.one()
|
|
end
|
|
|
|
@spec get_contact_edit!(Ecto.UUID.t()) :: ContactEdit.t()
|
|
def get_contact_edit!(id) do
|
|
ContactEdit
|
|
|> preload([:user, :contact, :reviewed_by])
|
|
|> Repo.get!(id)
|
|
end
|
|
|
|
@spec approve_edit(ContactEdit.t(), User.t(), String.t() | nil) ::
|
|
{:ok, ContactEdit.t()} | {:error, any()}
|
|
def approve_edit(%ContactEdit{status: :pending} = edit, admin, note) do
|
|
Repo.transaction(fn ->
|
|
# Mark edit as approved
|
|
{:ok, approved} =
|
|
edit
|
|
|> ContactEdit.review_changeset(%{
|
|
status: :approved,
|
|
admin_note: note,
|
|
reviewed_by_id: admin.id,
|
|
reviewed_at: DateTime.truncate(DateTime.utc_now(), :second)
|
|
})
|
|
|> Repo.update()
|
|
|
|
# Apply changes to contact
|
|
contact = Repo.get!(Contact, edit.contact_id)
|
|
_ = apply_edit_to_contact(contact, edit.proposed_changes)
|
|
|
|
Repo.preload(approved, [:user, :contact, :reviewed_by])
|
|
end)
|
|
end
|
|
|
|
@spec reject_edit(ContactEdit.t(), User.t(), String.t() | nil) ::
|
|
{:ok, ContactEdit.t()} | {:error, Ecto.Changeset.t()}
|
|
def reject_edit(%ContactEdit{status: :pending} = edit, admin, note) do
|
|
edit
|
|
|> ContactEdit.review_changeset(%{
|
|
status: :rejected,
|
|
admin_note: note,
|
|
reviewed_by_id: admin.id,
|
|
reviewed_at: DateTime.truncate(DateTime.utc_now(), :second)
|
|
})
|
|
|> Repo.update()
|
|
end
|
|
|
|
@doc """
|
|
Whether the given user submitted (owns) this contact. Returns `false`
|
|
for anonymous contacts (`user_id == nil`) and for a `nil` user.
|
|
"""
|
|
@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
|
|
|
|
@doc """
|
|
Allow the original submitter of a contact to edit it directly without
|
|
going through the admin review queue. Reuses the same normalization
|
|
and diff logic as `create_contact_edit/3` so owners can only push
|
|
meaningful changes (at least one field must actually differ).
|
|
|
|
Returns `{:ok, updated_contact}` on success, `{:error, :not_owner}` if
|
|
the user did not submit the contact, or `{:error, :no_changes}` if
|
|
every proposed value already matches the current contact.
|
|
"""
|
|
@spec apply_owner_edit(Contact.t(), User.t(), map()) ::
|
|
{:ok, Contact.t()} | {:error, :not_owner | :no_changes}
|
|
def apply_owner_edit(%Contact{} = contact, %User{} = user, proposed_changes) when is_map(proposed_changes) do
|
|
if owner?(contact, user) do
|
|
diffed = diff_against_contact(contact, normalize_proposed(proposed_changes))
|
|
|
|
if diffed == %{} do
|
|
{:error, :no_changes}
|
|
else
|
|
{:ok, apply_edit_to_contact(contact, diffed)}
|
|
end
|
|
else
|
|
{:error, :not_owner}
|
|
end
|
|
end
|
|
|
|
@doc "Apply proposed changes directly to a contact (admin use)."
|
|
@spec apply_edit_to_contact(Contact.t(), map()) :: Contact.t()
|
|
def apply_edit_to_contact(contact, proposed_changes) do
|
|
changes = build_contact_changes(contact, proposed_changes)
|
|
changes = maybe_recompute_positions(changes, contact, proposed_changes)
|
|
changes = maybe_reset_terrain_for_heights(changes, proposed_changes)
|
|
|
|
updated =
|
|
contact
|
|
|> Ecto.Changeset.change(changes)
|
|
|> Repo.update!()
|
|
|
|
maybe_enqueue_terrain_for_heights(updated, proposed_changes)
|
|
|
|
# Public contact-map + count caches are built from `private == false`
|
|
# rows. A grid correction, callsign change, flagged_invalid flip, or
|
|
# a `private: false -> true` toggle on an already-mapped contact all
|
|
# change what /api/contacts/map and /contacts/map should return —
|
|
# private flips in particular are a privacy leak until the 10-minute
|
|
# TTL expires. Invalidate on every direct update.
|
|
invalidate_contact_map_caches()
|
|
|
|
updated
|
|
end
|
|
|
|
defp invalidate_contact_map_caches do
|
|
Cache.invalidate(@count_cache_key)
|
|
Cache.invalidate(@map_payload_cache_key)
|
|
Cache.invalidate(MicrowavepropWeb.ContactMapController.cache_key())
|
|
:ok
|
|
end
|
|
|
|
defp maybe_enqueue_terrain_for_heights(contact, proposed) do
|
|
if Map.has_key?(proposed, "height1_ft") or Map.has_key?(proposed, "height2_ft") do
|
|
ContactWeatherEnqueueWorker.enqueue_for_contact(contact, [:terrain])
|
|
end
|
|
|
|
:ok
|
|
end
|
|
|
|
# Antenna heights feed directly into the elevation-profile terrain
|
|
# analysis (diffraction loss, clearance verdict). Changing them
|
|
# invalidates the stored terrain_profile, so reset the status to
|
|
# :pending and let TerrainProfileWorker re-run.
|
|
defp maybe_reset_terrain_for_heights(changes, proposed) do
|
|
if Map.has_key?(proposed, "height1_ft") or Map.has_key?(proposed, "height2_ft") do
|
|
Map.put(changes, :terrain_status, :pending)
|
|
else
|
|
changes
|
|
end
|
|
end
|
|
|
|
defp maybe_recompute_positions(changes, contact, proposed) do
|
|
needs_recompute =
|
|
Map.has_key?(proposed, "grid1") or
|
|
Map.has_key?(proposed, "grid2") or
|
|
Map.has_key?(proposed, "band") or
|
|
Map.has_key?(proposed, "qso_timestamp")
|
|
|
|
if needs_recompute do
|
|
recompute_positions(changes, contact, proposed)
|
|
else
|
|
changes
|
|
end
|
|
end
|
|
|
|
defp recompute_positions(changes, contact, proposed) do
|
|
pos1 = resolve_grid_position(Map.get(proposed, "grid1", contact.grid1)) || contact.pos1
|
|
pos2 = resolve_grid_position(Map.get(proposed, "grid2", contact.grid2)) || contact.pos2
|
|
distance = compute_distance(pos1, pos2)
|
|
|
|
changes
|
|
|> Map.put(:pos1, pos1)
|
|
|> Map.put(:pos2, pos2)
|
|
|> Map.put(:distance_km, distance)
|
|
|> Map.put(:hrrr_status, :pending)
|
|
|> Map.put(:weather_status, :pending)
|
|
|> Map.put(:terrain_status, :pending)
|
|
|> Map.put(:iemre_status, :pending)
|
|
end
|
|
|
|
defp compute_distance(pos1, pos2) 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(_, _), do: nil
|
|
|
|
defp build_contact_changes(_contact, proposed) do
|
|
Enum.reduce(proposed, %{}, fn
|
|
{"band", val}, acc ->
|
|
Map.put(acc, :band, Decimal.new(to_string(val)))
|
|
|
|
{"qso_timestamp", val}, acc when is_binary(val) ->
|
|
{:ok, dt, _} = DateTime.from_iso8601(val)
|
|
Map.put(acc, :qso_timestamp, dt)
|
|
|
|
{"qso_timestamp", %DateTime{} = dt}, acc ->
|
|
Map.put(acc, :qso_timestamp, dt)
|
|
|
|
{key, val}, acc ->
|
|
Map.put(acc, String.to_existing_atom(key), val)
|
|
end)
|
|
end
|
|
|
|
defp resolve_grid_position(nil), do: nil
|
|
|
|
defp resolve_grid_position(grid) do
|
|
case Maidenhead.to_latlon(grid) do
|
|
{:ok, {lat, lon}} -> %{"lat" => lat, "lon" => lon}
|
|
_ -> nil
|
|
end
|
|
end
|
|
end
|