From c17f91262297111bf52d9617a3e51901af952fd2 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Sat, 25 Apr 2026 10:08:13 -0500 Subject: [PATCH] fix(radio): invalidate contact-map cache on edits, not just inserts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- lib/microwaveprop/radio.ex | 19 ++++++++++++--- .../microwaveprop/radio/contact_edit_test.exs | 24 +++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/lib/microwaveprop/radio.ex b/lib/microwaveprop/radio.ex index 2f4614f9..5b3e0e22 100644 --- a/lib/microwaveprop/radio.ex +++ b/lib/microwaveprop/radio.ex @@ -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]) diff --git a/test/microwaveprop/radio/contact_edit_test.exs b/test/microwaveprop/radio/contact_edit_test.exs index 147e0784..33415675 100644 --- a/test/microwaveprop/radio/contact_edit_test.exs +++ b/test/microwaveprop/radio/contact_edit_test.exs @@ -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