May 19, 2026

Saved-Search Alerts See What You See

Saved-Search Filter Drift (#171)

The server-side saved-search checker had drifted badly from the homepage filter engine: it matched on filter IDs the frontend stopped emitting long ago (f_saildate vs the real f_departure) and queried columns that don't exist on the sailings table (embark_port, sea_days, is_repositioning). A production audit found 7 of 16 saved searches were over-matching the whole sailings table -- their change alerts were noise.

The rewrite:

  • Switch keys aligned with the real frontend filter IDs, and filters with no backing column (sea days, port count, repositioning, transoceanic) are now evaluated in PHP against hydrated Sailing objects -- a hybrid SQL pre-filter + in-PHP evaluator.
  • A new Playwright parity spec loads the homepage with filter URL params and asserts the JS engine and the PHP checker return the same match count. This is the test that makes drift a CI failure instead of a silent production bug.
  • Two capacity bugs caught during the post-deploy snapshot: the sailing cache's 1000-row LRU eviction was silently truncating large candidate sets (4810 matches came back as 978), and the SQL candidate LIMIT was biased toward past sailings. Both fixed -- rows hydrate inline now, and the cap has 2x headroom over the full table.
  • A 48-hour post-deploy suppression window logged (rather than emailed) the giant first-diff every drift-affected search would otherwise have fired.

One Email API, Opt-Out Honored Everywhere

After yesterday's duplicate-send incident, a second user asked to delete their account because we kept emailing past their preferences. All outbound email now goes through a single canonical Mailer::Enqueue entry point that:

  • Honors email_alerts_enabled for every automated notification (transactional email you explicitly triggered -- password resets, invites you sent -- still goes through).
  • Re-checks eligibility again at SMTP dispatch time, so a user who opts out between enqueue and send, or any future code path that sneaks a row in sideways, still can't be emailed against their wishes.
  • Drops mail to soft-deleted users at three layers: alert-generation queries now exclude them up front, defensive checks guard the send paths, and the dispatch-time recheck backstops everything.

The account auto-pause notice was also reclassified as an alert (it's purely automated), and the vestigial /contact form was removed -- the in-app feedback popover already covers it.

Homepage: Booked-Cabin Rings and a B2B Legend

  • Price cells whose cabin type matches one of your bookings now get an amber ring, sitting alongside the existing green casino-offer ring -- they stack when both apply.
  • The B2B column header gained a hover legend showing what the S, D, S+D, B, and O badges actually mean.

Fleet Reference PDFs

New one-page fleet reference sheets for Royal Caribbean and Carnival at /docs/royal-caribbean-fleet and /docs/carnival-fleet -- ships grouped by class with amenity badges (Playmakers for RC, water slides for both). Carnival amenities were researched and backfilled ship by ship; 29 of 30 Carnival ships have at least one waterslide (Luminosa is the lone holdout).

Smaller Fixes

  • Virgin scrape was failing with a validation error because their API rejects a start date of "today" -- shifted to tomorrow.
  • Dashboard bookings where RC truly omits the cabin tier (~4% of rows, all guarantee bookings) now show a "Guarantee" badge with an explanatory tooltip instead of a bare "GTY".
  • Admin users page dropped the redundant role column (admin now shown via avatar tint).