Files
minstrel/internal/db/queries/notifications.sql
T
bvandeusenandClaude Opus 5.5 63709a433d
release / govulncheck (push) Successful in 21s
release / web (push) Successful in 1m19s
release / go (push) Successful in 1m39s
release / integration (push) Successful in 5m27s
release / android (push) Successful in 5m47s
release / Build signed APK (releases and dev) (push) Successful in 5m34s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 26s
release / Verify release artifacts (tag releases only) (push) Skipped
feat(notifications): grouped email digest, new music as a daily summary (#5346)
Nothing is emailed per event. New music (request_completed) goes out at
most once a day, at the summary hour in each user's own timezone, grouped
by artist. Everything else is batched: one email a window after the first
un-emailed item, holding whatever accumulated.

- Migration 0074: notification_email_settings (summary hour, batch window,
  admin-configurable) and user_notification_email_state (batch start, last
  sent, failures and retry_after per user and group). Existing rows are
  stamped emailed so the upgrade sends no backlog.
- The Notifier stamps emailed_at at write time when the recipient's email
  channel is off, so turning email on later doesn't send old items.
- Read rows are never selected. A row is stamped only after the mailer
  accepts, in one transaction with the state, against the read's clock, so
  a coalesced row updated mid-send stays pending.
- A failed send backs off 5m doubling to 6h; SMTP not configured just waits.
- Links come from the public address; without one the email has none.
- The mailer now RFC 2047-encodes subjects and strips line breaks from them.
- Admin → Integrations gains a Notification emails card.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:48:22 -04:00

161 lines
7.0 KiB
SQL

-- M489 notifications inbox (#726). Rows are written only through
-- internal/notifications.Notifier, which decides recipients and honours each
-- recipient's inbox preference before it reaches these.
-- name: InsertNotification :one
-- email_wanted is the recipient's email channel for this kind as of now. A
-- row nobody wants emailed is stamped at once, so the digest never has to
-- judge it and a later "email on" doesn't send an old backlog.
INSERT INTO user_notifications (user_id, kind, payload, emailed_at)
VALUES (sqlc.arg(user_id), sqlc.arg(kind), sqlc.arg(payload),
CASE WHEN sqlc.arg(email_wanted)::boolean THEN NULL ELSE now() END)
RETURNING id;
-- name: UpsertCoalescedNotification :one
-- 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.
--
-- 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.
INSERT INTO user_notifications (user_id, kind, payload, coalesce_key, emailed_at)
VALUES (sqlc.arg(user_id), sqlc.arg(kind), sqlc.arg(payload), sqlc.arg(coalesce_key),
CASE WHEN sqlc.arg(email_wanted)::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 sqlc.arg(sum_count)::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
RETURNING id;
-- name: ListNotifications :many
-- Newest first, keyset-paged on (created_at, id). Pass both cursor halves
-- from the last row of the previous page, or neither for the first page.
SELECT id, kind, payload, created_at, read_at
FROM user_notifications
WHERE user_id = sqlc.arg(user_id)
AND (sqlc.narg(before_created_at)::timestamptz IS NULL
OR (created_at, id) < (sqlc.narg(before_created_at)::timestamptz, sqlc.narg(before_id)::uuid))
ORDER BY created_at DESC, id DESC
LIMIT sqlc.arg(page_limit);
-- name: CountUnreadNotifications :one
SELECT count(*) FROM user_notifications
WHERE user_id = $1 AND read_at IS NULL;
-- name: MarkNotificationRead :execrows
-- Scoped to the owner: another user's id matches no row. Marking an already
-- read row keeps its original read_at and still matches, so a repeat is not
-- mistaken for "not yours".
UPDATE user_notifications
SET read_at = COALESCE(read_at, now())
WHERE id = sqlc.arg(id) AND user_id = sqlc.arg(user_id);
-- name: MarkAllNotificationsRead :execrows
-- 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.
UPDATE user_notifications
SET read_at = now()
WHERE user_id = sqlc.arg(user_id)
AND read_at IS NULL
AND (sqlc.narg(up_to)::timestamptz IS NULL OR created_at <= sqlc.narg(up_to));
-- name: TrimNotifications :execrows
-- Retention: read rows go after read_cutoff, and anything at all after
-- any_cutoff, so an inbox nobody opens doesn't grow without bound either.
DELETE FROM user_notifications
WHERE (read_at IS NOT NULL AND read_at < sqlc.arg(read_cutoff))
OR created_at < sqlc.arg(any_cutoff);
-- name: ListAdminUserIDs :many
SELECT id FROM users WHERE is_admin = true ORDER BY created_at, id;
-- name: ListNotificationPrefsForUser :many
SELECT kind, inbox, phone, email
FROM user_notification_prefs
WHERE user_id = $1;
-- name: ListNotificationPrefsForKind :many
-- The stored prefs of one kind for a set of recipients. A recipient with no
-- row has the kind's defaults.
SELECT user_id, inbox, phone, email
FROM user_notification_prefs
WHERE kind = sqlc.arg(kind) AND user_id = ANY(sqlc.arg(user_ids)::uuid[]);
-- name: UpsertNotificationPref :exec
INSERT INTO user_notification_prefs (user_id, kind, inbox, phone, email)
VALUES (sqlc.arg(user_id), sqlc.arg(kind), sqlc.arg(inbox), sqlc.arg(phone), sqlc.arg(email))
ON CONFLICT (user_id, kind) DO UPDATE SET
inbox = EXCLUDED.inbox,
phone = EXCLUDED.phone,
email = EXCLUDED.email,
updated_at = now();
-- Email digest (#5346) ------------------------------------------------------
-- name: ListEmailPendingNotifications :many
-- Every unread, un-emailed row of a user who has an address, oldest first.
-- Read rows are never selected: the user has seen them, so they are not news.
-- read_as_of is the database's clock at the read, handed back to
-- MarkNotificationsEmailed.
SELECT n.id, n.user_id, n.kind, n.payload, n.created_at,
u.email::text AS email, u.username, u.display_name, u.timezone,
now()::timestamptz AS read_as_of
FROM user_notifications n
JOIN users u ON u.id = n.user_id
WHERE n.read_at IS NULL
AND n.emailed_at IS NULL
AND u.email IS NOT NULL AND u.email <> ''
ORDER BY n.user_id, n.created_at, n.id;
-- name: MarkNotificationsEmailed :execrows
-- Stamps the rows an email carried, or that were judged not to need one.
-- read_as_of is from ListEmailPendingNotifications: a coalesced row updated
-- since that read carries a later created_at, holds newer news, and stays
-- pending for the next email.
UPDATE user_notifications
SET emailed_at = now()
WHERE id = ANY(sqlc.arg(ids)::uuid[])
AND created_at <= sqlc.arg(read_as_of)::timestamptz
AND emailed_at IS NULL;
-- name: ListNotificationEmailState :many
SELECT user_id, email_group, batch_opened_at, last_sent_at, failures, retry_after
FROM user_notification_email_state;
-- name: UpsertNotificationEmailState :exec
INSERT INTO user_notification_email_state
(user_id, email_group, batch_opened_at, last_sent_at, failures, retry_after)
VALUES (sqlc.arg(user_id), sqlc.arg(email_group), sqlc.narg(batch_opened_at),
sqlc.narg(last_sent_at), sqlc.arg(failures), sqlc.narg(retry_after))
ON CONFLICT (user_id, email_group) DO UPDATE SET
batch_opened_at = EXCLUDED.batch_opened_at,
last_sent_at = EXCLUDED.last_sent_at,
failures = EXCLUDED.failures,
retry_after = EXCLUDED.retry_after;
-- name: GetNotificationEmailSettings :one
SELECT * FROM notification_email_settings WHERE id = true;
-- name: UpdateNotificationEmailSettings :one
-- Migration 0074's CHECKs are the backstop behind the service's validation.
UPDATE notification_email_settings
SET summary_hour = sqlc.arg(summary_hour),
batch_window_minutes = sqlc.arg(batch_window_minutes),
updated_at = now()
WHERE id = true
RETURNING *;