feat(discover): time-boxed suggestion snooze, server side — #2374
test-go / test (push) Successful in 1m20s
test-go / integration (push) Successful in 5m1s

Migration 0049 adds suggestion_snoozes(user_id, candidate_mbid,
candidate_name, snoozed_until), and SuggestArtistsForUser excludes rows
whose snooze hasn't expired.

This is NOT a dislike. Rule #101 forbids a "Not for me" / thumbs-down
UI; a snooze is the approved shape instead because it records no verdict
on the music, expires on its own (~90d), and never reaches the taste
profile. It's acquisition triage — "not right now" — so the filter sits
at the candidate stage rather than in the score, where it would become a
ranking signal by the back door.

Per-user throughout (rule #47): one household member parking a candidate
leaves everyone else's deck untouched.

candidate_name is denormalized because suggestions are out-of-library by
definition — there is no artists row to resolve a display name from, and
the un-snooze list has to show something. That list is why GET
/discover/snoozes exists at all: a parked candidate is by definition
absent from the deck, so without it the DELETE would be unreachable.

Also fixes a hole in the codegen check from #2380: `git diff` ignores
untracked paths, so a brand-new generated file would have passed it
silently. `git add -N` first. This commit is the first to add one.

Endpoints:
  POST   /api/discover/suggestions/{mbid}/snooze  (body: name, days)
  DELETE /api/discover/suggestions/{mbid}/snooze
  GET    /api/discover/snoozes

UI lands in slice 4 (#2375) before any of this merges — rule #27.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 22:51:51 -04:00
co-authored by Claude Opus 5
parent e006de5d4b
commit 86af79bd2f
13 changed files with 619 additions and 4 deletions
@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS suggestion_snoozes_expiry_idx;
DROP TABLE IF EXISTS suggestion_snoozes;
@@ -0,0 +1,33 @@
-- 0049_suggestion_snoozes.up.sql — time-boxed "not right now" on a Discover
-- artist suggestion (#2374, milestone #268 slice 3).
--
-- This is NOT a dislike. Rule #101 forbids a "Not for me" / thumbs-down /
-- exclusion UI, and a snooze deliberately isn't one: it records no verdict on
-- the music, expires on its own, and MUST NEVER feed the taste profile. It is
-- acquisition triage — "I don't want to request this right now" — so the same
-- candidate is free to return once snoozed_until passes. Anything that reads
-- this table as negative preference signal is a bug.
--
-- Per-user (rule #47), never global: one household member parking a
-- suggestion must not remove it from anyone else's deck.
--
-- candidate_mbid is text with NO foreign key, on purpose. Suggestions come
-- from artist_similarity_unmatched and are out-of-library BY DEFINITION, so
-- there is no artists row to reference — the MBID is the only stable identity
-- available. candidate_name is denormalized for the same reason: the manage /
-- un-snooze list has nowhere else to resolve a display name from.
CREATE TABLE suggestion_snoozes (
user_id uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
candidate_mbid text NOT NULL,
candidate_name text NOT NULL,
snoozed_until timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, candidate_mbid)
);
-- Supports the gc sweep's unqualified `WHERE snoozed_until < now()` scan. The
-- composite PK already covers every per-user read, so this is the only extra
-- index worth its write cost at household-scale row counts (same reasoning as
-- lidarr_quarantine in 0011).
CREATE INDEX suggestion_snoozes_expiry_idx ON suggestion_snoozes (snoozed_until);