Profile page:
* New MicrowavepropWeb.UserProfileLive at /u/:callsign is a public
page showing a user's contacts and beacons. Resolves case-
insensitively so /u/w5isp and /u/W5ISP are the same thing; unknown
callsigns redirect to /. Uses daisyUI card / stats / table
components with an avatar-placeholder initial and hero icons.
* Accounts.get_user_by_callsign/1 (case-insensitive) plus
Radio.list_contacts_for_user/1 and Beacons.list_beacons_for_user/1
back the page. Beacons list includes both approved and pending so
owners see their drafts.
* The top nav bar and the three LiveView sidebars (MapLive,
WeatherMapLive, ContactMapLive) now render the logged-in callsign
as a navigate link to /u/:callsign instead of a static label.
* Nine new tests cover the lookup, the LiveView render, and the
ownership-scoped queries.
Flexible band input:
* New Microwaveprop.Radio.BandResolver module converts any of:
ADIF wavelength labels ("33cm", "1.25cm", "6mm", case/whitespace
insensitive), numeric frequency strings ("903.100", "10368.000"),
and canonical MHz integers into the one of the site's known bands.
Returns the nearest allowed band for numeric inputs >= 900 MHz,
nil otherwise.
* 902 MHz is added to Contact.@allowed_bands, ContactEdit.@allowed_bands,
AdifImport.@allowed_bands, and the BandResolver list so "33cm"
round-trips end-to-end.
* AdifImport and CsvImport now delegate band resolution to
BandResolver, and Radio.create_contact/2 normalizes the :band attr
on the way in so the manual form and any API callers benefit too.
CsvImport's "invalid band" tests previously used 99999 MHz which
the new resolver snaps to the nearest allowed band; swapped to
"notaband" which is truly unresolvable.
Contacts and beacons list UX:
* Remove the "Submitted" column from /contacts — it duplicated info
already visible on the detail page and was pushing the real
columns off narrow viewports. submitted_cell/1 and its three
column-specific tests go with it.
* Hide the Lat / Lon columns from /beacons — six decimal places of
coordinates weren't useful next to the grid square and took a
disproportionate amount of row width.
892 lines
29 KiB
Elixir
892 lines
29 KiB
Elixir
defmodule Microwaveprop.Radio do
|
|
@moduledoc false
|
|
|
|
import Ecto.Query
|
|
|
|
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
|
|
|
|
@per_page 20
|
|
@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),
|
|
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"] || c.pos1["lng"]
|
|
lat2 = c.pos2["lat"]
|
|
lon2 = c.pos2["lon"] || c.pos2["lng"]
|
|
|
|
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 all contacts submitted by the given user, newest first. Used
|
|
by the public `/u/:callsign` profile page; expected to return a small
|
|
list since only logged-in submissions carry a `user_id`.
|
|
"""
|
|
@spec list_contacts_for_user(User.t()) :: [Contact.t()]
|
|
def list_contacts_for_user(%User{id: user_id}) do
|
|
Contact
|
|
|> where([c], c.user_id == ^user_id)
|
|
|> order_by([c], desc: c.qso_timestamp, desc: c.id)
|
|
|> Repo.all()
|
|
end
|
|
|
|
@spec list_contacts(keyword()) :: contact_page()
|
|
def list_contacts(opts \\ []) do
|
|
page = max(Keyword.get(opts, :page, 1), 1)
|
|
offset = (page - 1) * @per_page
|
|
search = Keyword.get(opts, :search)
|
|
|
|
{sort_field, sort_dir} = sort_opts(opts)
|
|
|
|
base_query = maybe_search(Contact, search)
|
|
|
|
total_entries = total_entries_for(search, 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
|
|
|
|
@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
|
|
|
|
defp total_entries_for(search, 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, query), do: Repo.aggregate(query, :count)
|
|
|
|
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 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"] || pos1["lng"]
|
|
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"] || pos["lng"]
|
|
if lon, do: {lat, lon}
|
|
end
|
|
|
|
defp extract_latlon(_), do: nil
|
|
|
|
defp build_path_points({lat1, lon1}, {lat2, lon2}) do
|
|
mid_lat = (lat1 + lat2) / 2
|
|
mid_lon = (lon1 + lon2) / 2
|
|
[{lat1, lon1}, {mid_lat, mid_lon}, {lat2, lon2}]
|
|
end
|
|
|
|
defp build_path_points({lat1, lon1}, _), do: [{lat1, lon1}]
|
|
defp build_path_points(_, _), do: []
|
|
|
|
@earth_radius_km 6371.0
|
|
|
|
@spec haversine_km(number(), number(), number(), number()) :: float()
|
|
def haversine_km(lat1, lon1, lat2, lon2) do
|
|
dlat = deg_to_rad(lat2 - lat1)
|
|
dlon = deg_to_rad(lon2 - lon1)
|
|
rlat1 = deg_to_rad(lat1)
|
|
rlat2 = deg_to_rad(lat2)
|
|
|
|
a =
|
|
:math.sin(dlat / 2) ** 2 +
|
|
:math.cos(rlat1) * :math.cos(rlat2) * :math.sin(dlon / 2) ** 2
|
|
|
|
2 * @earth_radius_km * :math.asin(:math.sqrt(a))
|
|
end
|
|
|
|
@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"] || contact.pos1["lng"],
|
|
lat2 = contact.pos2["lat"],
|
|
lon2 = contact.pos2["lon"] || contact.pos2["lng"],
|
|
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
|
|
Repo.get!(Contact, id)
|
|
end
|
|
|
|
@spec toggle_flagged_invalid!(Contact.t()) :: Contact.t()
|
|
def toggle_flagged_invalid!(contact) do
|
|
contact
|
|
|> Ecto.Changeset.change(flagged_invalid: !contact.flagged_invalid)
|
|
|> Repo.update!()
|
|
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"] || pos1["lng"]
|
|
lon2 = pos2["lon"] || pos2["lng"]
|
|
|
|
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
|
|
%{}
|
|
|> then(fn c -> if pos1 && is_nil(contact.pos1), do: Map.put(c, :pos1, pos1), else: c end)
|
|
|> then(fn c -> if pos2 && is_nil(contact.pos2), do: Map.put(c, :pos2, pos2), else: c end)
|
|
|> then(fn c ->
|
|
if distance && is_nil(contact.distance_km), do: Map.put(c, :distance_km, distance), else: c
|
|
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, _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
|
|
changeset =
|
|
%Contact{}
|
|
|> Contact.submission_changeset(normalize_band_attr(attrs))
|
|
|> maybe_put_user_id(user_id)
|
|
|
|
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 maybe_put_user_id(changeset, nil), do: changeset
|
|
defp maybe_put_user_id(changeset, user_id), do: Ecto.Changeset.put_change(changeset, :user_id, user_id)
|
|
|
|
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
|
|
Cache.invalidate(@count_cache_key)
|
|
Cache.invalidate(@map_payload_cache_key)
|
|
Cache.invalidate({MicrowavepropWeb.ContactMapController, :gzipped_payload})
|
|
end
|
|
|
|
result
|
|
|
|
_ ->
|
|
changeset = Ecto.Changeset.add_error(changeset, :grid1, "could not resolve grid to coordinates")
|
|
{:error, %{changeset | action: :insert}}
|
|
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
|
|
|
|
# ── 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
|
|
|
|
@doc false
|
|
@spec normalize_proposed(map()) :: map()
|
|
def normalize_proposed(changes) do
|
|
changes
|
|
|> normalize_string_field("station1")
|
|
|> normalize_string_field("station2")
|
|
|> normalize_string_field("grid1")
|
|
|> normalize_string_field("grid2")
|
|
|> normalize_string_field("mode")
|
|
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
|
|
|
|
@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, _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
|
|
|
|
@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)
|
|
|
|
contact
|
|
|> Ecto.Changeset.change(changes)
|
|
|> Repo.update!()
|
|
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"] || pos1["lng"]
|
|
lon2 = pos2["lon"] || pos2["lng"]
|
|
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
|