Rewrites the internal/db/queries comments to rule 32 and regenerates dbq, whose doc comments and embedded SQL carry them. The bugs behind the tiered mixes, the md5 order seed, the score bands and the round-robin arms are restated as the constraint each one enforces; counts from one library and stale cross-references are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
161 lines
7.3 KiB
SQL
161 lines
7.3 KiB
SQL
-- Notifications inbox. 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: 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.
|
|
--
|
|
-- created_at and emailed_at are left alone: an update changes what the
|
|
-- notice says, not when it arrived. Moving created_at would make the phone
|
|
-- announce it again and clearing emailed_at would send it again, so a notice
|
|
-- that keeps growing would alert on every update.
|
|
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 = 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;
|
|
|
|
-- 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 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.
|
|
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 ------------------------------------------------------
|
|
|
|
-- 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 *;
|