Aliases: add module aliases for 9 nested module references Apply: replace apply/3 with direct module attribute calls Line length: break 1 long spec line Refactoring: extract helpers to reduce complexity and nesting in show.ex, radio.ex, weather workers, terrain, duct detection, backfill dashboard, contact map, and mix tasks
695 lines
22 KiB
Elixir
695 lines
22 KiB
Elixir
defmodule Microwaveprop.Radio do
|
|
@moduledoc false
|
|
|
|
import Ecto.Query
|
|
|
|
alias Microwaveprop.Accounts.User
|
|
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
|
|
|
|
@type contact_page :: %{
|
|
entries: [Contact.t()],
|
|
grouped_entries: [{Contact.t(), [Contact.t()]}],
|
|
page: pos_integer(),
|
|
total_pages: pos_integer(),
|
|
total_entries: non_neg_integer()
|
|
}
|
|
|
|
@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 = Repo.aggregate(base_query, :count)
|
|
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 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()) ::
|
|
{:ok, Contact.t()} | {:error, Ecto.Changeset.t()} | {:error, :duplicate, Contact.t()}
|
|
def create_contact(attrs) do
|
|
changeset = Contact.submission_changeset(%Contact{}, attrs)
|
|
|
|
if changeset.valid? do
|
|
insert_validated_contact(changeset)
|
|
else
|
|
{:error, %{changeset | action: :insert}}
|
|
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()
|
|
|
|
changeset
|
|
|> Ecto.Changeset.put_change(:pos1, %{"lat" => lat1, "lon" => lon1})
|
|
|> Ecto.Changeset.put_change(:pos2, %{"lat" => lat2, "lon" => lon2})
|
|
|> Ecto.Changeset.put_change(:distance_km, distance)
|
|
|> Ecto.Changeset.put_change(:user_submitted, true)
|
|
|> Repo.insert()
|
|
|
|
_ ->
|
|
changeset = Ecto.Changeset.add_error(changeset, :grid1, "could not resolve grid to coordinates")
|
|
{:error, %{changeset | action: :insert}}
|
|
end
|
|
end
|
|
|
|
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 "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
|
|
grids_or_band_changed =
|
|
Map.has_key?(proposed, "grid1") or
|
|
Map.has_key?(proposed, "grid2") or
|
|
Map.has_key?(proposed, "band")
|
|
|
|
if grids_or_band_changed 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
|