import { waitUntil } from "cloudflare:workers"; import { all, chunks, first, placeholders } from "./db"; import { accountDb } from "./scoped-db"; import { repoReadableSql } from "./repos"; /** * Notifications live in the recipient's AccountState; whether the recipient may still read a notification's * repository is a D1 directory question. These helpers apply `inboxReadableSql`'s rule in code: account-level * rows always show, repository rows only while the repository is visible to the recipient (site admins see all). */ export async function readableRepoIds(userId: string, repoIds: Iterable): Promise> { const ids = [...new Set(repoIds)]; if (ids.length === 0) return new Set(); const admin = await first<{ ok: number }>("SELECT 1 AS ok FROM users WHERE account_id = ? AND is_admin = 1", userId); const out = new Set(); if (admin) { // Site admins read every repository that still exists. for (const part of chunks(ids)) { const rows = await all<{ id: string }>( `SELECT id FROM repos WHERE id IN (${placeholders(part.length)})`, ...part, ); for (const row of rows) out.add(row.id); } return out; } for (const part of chunks(ids)) { const rows = await all<{ id: string }>( `SELECT r.id FROM repos r WHERE r.id IN (${placeholders(part.length)}) AND r.hidden_at IS NULL AND ${repoReadableSql("r", "?")}`, ...part, ...Array(repoReadableSqlUserSlots).fill(userId), ); for (const row of rows) out.add(row.id); } return out; } /** `repoReadableSql(alias, "?")` binds the user once per mention. */ const repoReadableSqlUserSlots = (repoReadableSql("r", "?").match(/\?/g) ?? []).length; /** * Of repositories the recipient can't read, those whose notifications should go: deleted ones, and ones the * recipient lost access to (as a revocation purge used to do). A repository merely hidden by moderation keeps * them, filtered, so un-hiding brings them back. */ async function prunableRepoIds(userId: string, candidates: string[]): Promise { // Re-checked when the prune runs: access may have come back since the read that found these unreadable. const readable = await readableRepoIds(userId, candidates); const repoIds = candidates.filter((id) => !readable.has(id)); if (repoIds.length === 0) return []; const hidden = new Set(); const existing = new Set(); for (const part of chunks(repoIds)) { const rows = await all<{ id: string; hidden: number }>( `SELECT id, hidden_at IS NOT NULL AS hidden FROM repos WHERE id IN (${placeholders(part.length)})`, ...part, ); for (const row of rows) { existing.add(row.id); if (row.hidden) hidden.add(row.id); } } return repoIds.filter((id) => !existing.has(id) || !hidden.has(id)); } /** * Notifications and digest items on repositories the recipient can no longer read are not purged by a fan-out * over every account when access changes; reads filter them and drop them here, in the background, the first * time they are seen. */ function pruneUnreadable(userId: string, candidates: string[]): void { if (candidates.length === 0) return; const db = accountDb(userId); const prune = prunableRepoIds(userId, candidates) .then((repoIds) => deleteRepoRows(db, userId, repoIds)) .catch((error: unknown) => console.warn("Notification prune failed", userId, error)); try { waitUntil(prune); } catch { // Outside a request (tests): the prune still settles on its own. } } async function deleteRepoRows(db: ReturnType, userId: string, repoIds: string[]): Promise { await Promise.all( chunks(repoIds).flatMap((part) => [ db.run( `DELETE FROM notifications WHERE user_id = ? AND repo_id IN (${placeholders(part.length)})`, userId, ...part, ), db.run( `DELETE FROM notification_digest_items WHERE user_id = ? AND repo_id IN (${placeholders(part.length)})`, userId, ...part, ), ]), ); } /** Keeps rows the recipient can read (see `readableRepoIds`) and prunes the rest. */ export async function filterReadable(userId: string, rows: T[]): Promise { const repoIds = [...new Set(rows.flatMap((row) => (row.repo_id ? [row.repo_id] : [])))]; const readable = await readableRepoIds(userId, repoIds); pruneUnreadable( userId, repoIds.filter((id) => !readable.has(id)), ); return rows.filter((row) => row.repo_id === null || readable.has(row.repo_id)); } /** Unread notifications the user can still read, for the inbox badge. */ export async function unreadNotificationCount(userId: string): Promise { const groups = await accountDb(userId).all<{ repo_id: string | null; count: number }>( "SELECT repo_id, COUNT(*) AS count FROM notifications WHERE user_id = ? AND read_at IS NULL GROUP BY repo_id", userId, ); const readable = await filterReadable(userId, groups); return readable.reduce((sum, group) => sum + group.count, 0); }