feat: notifications say what changed and alert once: a grouped notice names the tracks, grows without re-alerting, and a phone pile lists its titles (M542 #6146-#6151)
release / govulncheck (push) Successful in 14s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m33s
release / android (push) Failing after 1m42s
release / integration (push) Failing after 4m42s
release / Build signed APK (releases and dev) (push) Successful in 4m55s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Skipped
release / Verify release artifacts (tag releases only) (push) Skipped

- An update to a grouped notice keeps its arrival time and email state, so the
  phone and the email no longer go off again each time it grows.
- Notices carry the names of what changed (newest ten), shown as three and
  "and N more": tracks still missing after the scan's move/replacement fold,
  duplicate groups new to a sweep, songs merged automatically, and the track
  and listener a playback error was for.
- Duplicates found counts only the groups new to that sweep; playback errors
  count reports, not the unresolved total.
- A scan failure says what stopped and links to Admin > Library.
- Android: a pile of more than three alerts lists the newest five titles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-19 22:44:19 -04:00
co-authored by Claude Opus 5.5
parent 2365514e5b
commit 854c307834
26 changed files with 486 additions and 190 deletions
+20 -22
View File
@@ -346,8 +346,9 @@ type MarkAllNotificationsReadParams struct {
// up_to, when set, limits it to what existed when the user asked: a "mark
// all read" queued offline and replayed later must not mark notices that
// arrived in between, which the user never saw. A coalesced row updated
// since then carries a newer created_at, so it stays unread too.
// arrived in between, which the user never saw. A coalesced row that grew
// since then is marked read with it: it arrived before they asked, and its
// growth is a quiet update to a notice they had seen (M542).
func (q *Queries) MarkAllNotificationsRead(ctx context.Context, arg MarkAllNotificationsReadParams) (int64, error) {
result, err := q.db.Exec(ctx, markAllNotificationsRead, arg.UserID, arg.UpTo)
if err != nil {
@@ -457,15 +458,15 @@ VALUES ($1, $2, $3, $4,
CASE WHEN $5::boolean THEN NULL ELSE now() END)
ON CONFLICT (user_id, coalesce_key) WHERE read_at IS NULL AND coalesce_key IS NOT NULL
DO UPDATE SET
payload = CASE
WHEN $6::boolean THEN
EXCLUDED.payload || jsonb_build_object('count',
COALESCE((user_notifications.payload->>'count')::bigint, 0)
+ COALESCE((EXCLUDED.payload->>'count')::bigint, 0))
ELSE EXCLUDED.payload
END,
created_at = now(),
emailed_at = EXCLUDED.emailed_at
payload = EXCLUDED.payload || jsonb_build_object(
'count', COALESCE((user_notifications.payload->>'count')::bigint, 0)
+ COALESCE((EXCLUDED.payload->>'count')::bigint, 0),
'items', (SELECT COALESCE(jsonb_agg(e.item ORDER BY e.n), '[]'::jsonb)
FROM jsonb_array_elements(
COALESCE(EXCLUDED.payload->'items', '[]'::jsonb)
|| COALESCE(user_notifications.payload->'items', '[]'::jsonb)
) WITH ORDINALITY AS e(item, n)
WHERE e.n <= 10))
RETURNING id
`
@@ -475,20 +476,18 @@ type UpsertCoalescedNotificationParams struct {
Payload []byte
CoalesceKey *string
EmailWanted bool
SumCount bool
}
// One unread row per (user, coalesce_key). A new event while that row is
// unread updates it in place and moves it back to the top of the inbox.
// unread updates it in place: its `count` adds to the row's, and its `items`
// (the names of what changed) go in front of the row's, keeping the newest
// ten. Every coalesced kind's event is "N more happened", never a restated
// total, so the row always says what is new since it was last read.
//
// sum_count: the payload's `count` adds to the unread row's count rather than
// replacing it. For kinds whose event is "N more happened" (tracks marked
// missing). Kinds whose event states the whole current total (pending
// duplicate groups) pass false and the payload simply replaces.
//
// emailed_at is cleared so the newer state goes out in the next batch; an
// emailed-but-unread row that keeps growing is news the user hasn't seen.
// Unless email_wanted is false, as in InsertNotification.
// created_at and emailed_at are left alone (M542): an update changes what the
// notice says, not when it arrived. Moving created_at made the phone announce
// it again and clearing emailed_at sent it again, so a notice the hourly
// duplicate pass kept growing alerted every hour.
func (q *Queries) UpsertCoalescedNotification(ctx context.Context, arg UpsertCoalescedNotificationParams) (pgtype.UUID, error) {
row := q.db.QueryRow(ctx, upsertCoalescedNotification,
arg.UserID,
@@ -496,7 +495,6 @@ func (q *Queries) UpsertCoalescedNotification(ctx context.Context, arg UpsertCoa
arg.Payload,
arg.CoalesceKey,
arg.EmailWanted,
arg.SumCount,
)
var id pgtype.UUID
err := row.Scan(&id)