fix(radio): invalidate contact-map cache on edits, not just inserts

`apply_edit_to_contact/2` is the path for both owner direct-edits and
admin reviewed-edits. The public contact-map + total-count + gzipped
controller payload caches are built from `private == false` rows, but
only the insert path was clearing them — every other update left
/api/contacts/map and /contacts/map serving the previous snapshot for
up to 10 minutes. A `private: false -> true` flip on a contact already
present in the cached payload was a temporary privacy leak; grid
corrections, callsign changes, and flagged_invalid flips silently
diverged from the live row.

Extracts the three `Cache.invalidate` calls into
`invalidate_contact_map_caches/0` and runs it from both
`apply_edit_to_contact/2` and the existing insert path so the public
view stays in sync.
This commit is contained in:
Graham McIntire 2026-04-25 10:08:13 -05:00
parent ff5b0acf98
commit c17f912622
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 40 additions and 3 deletions

View file

@ -641,9 +641,7 @@ defmodule Microwaveprop.Radio do
|> Repo.insert()
with {:ok, _} <- result do
Cache.invalidate(@count_cache_key)
Cache.invalidate(@map_payload_cache_key)
Cache.invalidate({MicrowavepropWeb.ContactMapController, :gzipped_payload})
invalidate_contact_map_caches()
end
result
@ -1095,9 +1093,24 @@ defmodule Microwaveprop.Radio do
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, :gzipped_payload})
: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])

View file

@ -2,6 +2,7 @@ defmodule Microwaveprop.Radio.ContactEditTest do
use Microwaveprop.DataCase, async: true
alias Microwaveprop.Accounts
alias Microwaveprop.Cache
alias Microwaveprop.Radio
alias Microwaveprop.Radio.Contact
alias Microwaveprop.Radio.ContactEdit
@ -481,6 +482,29 @@ defmodule Microwaveprop.Radio.ContactEditTest do
{:ok, updated} = Radio.apply_owner_edit(contact, user, %{"private" => "true"})
assert updated.private == true
end
test "applying an edit invalidates the public contact-map caches", %{user: user} do
contact = owned_contact(user)
# Pre-seed the same three cache keys the insert path invalidates.
# If apply_edit_to_contact forgets to invalidate, /api/contacts/map
# and /contacts/map keep serving stale (and for private flips,
# privacy-leaking) data for up to 10 minutes.
sentinel = :stale_payload
ten_min = 10 * 60 * 1_000
Cache.put({Radio, :total_contact_count}, sentinel, ten_min)
Cache.put({Radio, :contact_map_payload}, sentinel, ten_min)
Cache.put({MicrowavepropWeb.ContactMapController, :gzipped_payload}, sentinel, ten_min)
{:ok, _} = Radio.apply_owner_edit(contact, user, %{"private" => true})
assert :ets.lookup(:microwaveprop_cache, {Radio, :total_contact_count}) == []
assert :ets.lookup(:microwaveprop_cache, {Radio, :contact_map_payload}) == []
assert :ets.lookup(:microwaveprop_cache, {MicrowavepropWeb.ContactMapController, :gzipped_payload}) ==
[]
end
end
describe "Radio.owner?/2" do