defmodule Microwaveprop.Workers.ContactPositionBackfillWorker do @moduledoc """ Hourly safety net that fills in `pos1` / `pos2` / `distance_km` for contacts whose grids resolve but whose positions are null. New contacts always have positions because `Radio.create_contact/1` refuses to insert without resolvable grids. This worker exists for rows that arrive via direct DB writes (bulk imports, manual fixes) which bypass that path. """ use Oban.Pro.Worker, queue: :admin, max_attempts: 1, unique: [period: 60] import Ecto.Query alias Microwaveprop.Radio alias Microwaveprop.Radio.Contact alias Microwaveprop.Radio.Maidenhead alias Microwaveprop.Repo require Logger @default_limit 500 @impl Oban.Pro.Worker def process(%Oban.Job{args: args}) do limit = Map.get(args, "limit", @default_limit) candidates = Contact |> where([c], (is_nil(c.pos1) and not is_nil(c.grid1)) or (is_nil(c.pos2) and not is_nil(c.grid2))) |> limit(^limit) |> Repo.all() count = Enum.count(candidates, fn contact -> updates = compute_updates(contact) if updates == [] do false else {n, _} = Contact |> where(id: ^contact.id) |> Repo.update_all(set: updates) n == 1 end end) if count > 0 do Logger.info("ContactPositionBackfill: populated positions for #{count} contacts") end {:ok, %{updated: count}} end defp compute_updates(contact) do pos1 = maybe_pos(contact.pos1, contact.grid1) pos2 = maybe_pos(contact.pos2, contact.grid2) [] |> add_pos_change(:pos1, contact.pos1, pos1) |> add_pos_change(:pos2, contact.pos2, pos2) |> add_distance_change(contact, pos1, pos2) |> maybe_touch_updated_at() end defp maybe_pos(existing, _grid) when not is_nil(existing), do: existing defp maybe_pos(_nil, nil), do: nil defp maybe_pos(_nil, grid) do case Maidenhead.to_latlon(grid) do {:ok, {lat, lon}} -> %{"lat" => lat, "lon" => lon} :error -> nil end end defp add_pos_change(updates, _field, existing, _new) when not is_nil(existing), do: updates defp add_pos_change(updates, _field, _existing, nil), do: updates defp add_pos_change(updates, field, _nil, new), do: [{field, new} | updates] # Always overwrite distance_km when both positions resolve — the candidate # query only selects contacts with at least one nil pos, so any previously # stored distance is tied to a stale grid/pos pair. defp add_distance_change(updates, _contact, nil, _pos2), do: updates defp add_distance_change(updates, _contact, _pos1, nil), do: updates defp add_distance_change(updates, _contact, pos1, pos2) do distance = pos1["lat"] |> Radio.haversine_km(pos1["lon"], pos2["lat"], pos2["lon"]) |> round() |> Decimal.new() [{:distance_km, distance} | updates] end defp maybe_touch_updated_at([]), do: [] defp maybe_touch_updated_at(updates) do [{:updated_at, DateTime.truncate(DateTime.utc_now(), :second)} | updates] end end