release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m22s
release / go (push) Successful in 1m40s
release / integration (push) Successful in 4m40s
release / android (push) Successful in 5m1s
release / Build signed APK (releases and dev) (push) Successful in 5m12s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
POST /api/me/notifications/read-all accepts {"up_to": RFC3339}. Android
queues "mark all read" for replay when offline, and a replay landing later
must not mark notices that arrived in between, which the user never saw.
A coalesced notice updated since then has a newer created_at, so it stays
unread. An empty body still marks everything.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
100 lines
4.3 KiB
SQL
100 lines
4.3 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
|
|
INSERT INTO user_notifications (user_id, kind, payload)
|
|
VALUES (sqlc.arg(user_id), sqlc.arg(kind), sqlc.arg(payload))
|
|
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.
|
|
INSERT INTO user_notifications (user_id, kind, payload, coalesce_key)
|
|
VALUES (sqlc.arg(user_id), sqlc.arg(kind), sqlc.arg(payload), sqlc.arg(coalesce_key))
|
|
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 = NULL
|
|
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();
|