import { all, chunks, placeholders } from "./db"; import { accountDb, repoDb } from "./scoped-db"; import { forEachScope } from "./account-scope"; import type { NotifyMessage } from "./events/emit"; export const WATCH_LEVELS = ["all", "participating", "releases", "ignore"] as const; export type WatchLevel = (typeof WATCH_LEVELS)[number]; export type NotificationSettings = { emailParticipating: boolean; emailWatching: boolean; webParticipating: boolean; webWatching: boolean; }; export type Delivery = { userId: string; reason: string; web: boolean; email: string | null }; const DEFAULT_SETTINGS: NotificationSettings = { emailParticipating: true, emailWatching: false, webParticipating: true, webWatching: true, }; type SettingsRow = { user_id: string; email_participating: number; email_watching: number; web_participating: number; web_watching: number; }; type JoinedRow = { user_id: string; email: string; email_verified_at: number | null } & { [Key in Exclude]: number | null; }; function joinedSettings(row: JoinedRow): NotificationSettings { const { email_participating, email_watching, web_participating, web_watching } = row; if (email_participating === null || email_watching === null || web_participating === null || web_watching === null) { return DEFAULT_SETTINGS; } return toSettings({ user_id: row.user_id, email_participating, email_watching, web_participating, web_watching }); } function toSettings(row: SettingsRow | null): NotificationSettings { if (!row) return DEFAULT_SETTINGS; return { emailParticipating: row.email_participating === 1, emailWatching: row.email_watching === 1, webParticipating: row.web_participating === 1, webWatching: row.web_watching === 1, }; } export async function loadNotificationSettings(userId: string): Promise { return toSettings( await accountDb(userId).first("SELECT * FROM notification_settings WHERE user_id = ?", userId), ); } function isWatchLevel(value: string): value is WatchLevel { return WATCH_LEVELS.some((level) => level === value); } /** Adds repo watchers and explicit thread subscribers, then drops ignored or unsubscribed users. */ export async function expandRecipients(message: NotifyMessage, recipients: Map): Promise { const watchers = await all<{ user_id: string; level: string }>( "SELECT user_id, level FROM watches WHERE repo_id = ?", message.repoId, ); const ignored = new Set(); for (const watcher of watchers) { const level = isWatchLevel(watcher.level) ? watcher.level : "all"; if (level === "ignore") ignored.add(watcher.user_id); const wantsThis = level === "all" || (level === "releases" && message.reason === "release"); if (wantsThis && !recipients.has(watcher.user_id)) recipients.set(watcher.user_id, "subscribed"); } if (message.issueId) { const threads = await repoDb(message.repoId).all<{ user_id: string; subscribed: number }>( "SELECT user_id, subscribed FROM thread_subscriptions WHERE issue_id = ?", message.issueId, ); for (const thread of threads) { if (thread.subscribed === 1) { if (!recipients.has(thread.user_id)) recipients.set(thread.user_id, "subscribed"); } else if (recipients.get(thread.user_id) !== "mention") { recipients.delete(thread.user_id); } } } for (const userId of ignored) recipients.delete(userId); } /** Resolves per-user web/email delivery from notification settings; email only goes to verified addresses. */ export async function planDeliveries(recipients: [string, string][]): Promise { const out: Delivery[] = []; for (const part of chunks(recipients)) { const ids = part.map(([userId]) => userId); const users = await all<{ user_id: string; email: string; email_verified_at: number | null }>( `SELECT account_id AS user_id, email, email_verified_at FROM users WHERE account_id IN (${placeholders(ids.length)})`, ...ids, ); const settings = await forEachScope( users.map((user) => user.user_id), (userId) => accountDb(userId).first("SELECT * FROM notification_settings WHERE user_id = ?", userId), ); const rows: JoinedRow[] = users.map((user, index) => ({ ...user, email_participating: settings[index]?.email_participating ?? null, email_watching: settings[index]?.email_watching ?? null, web_participating: settings[index]?.web_participating ?? null, web_watching: settings[index]?.web_watching ?? null, })); const byId = new Map(rows.map((row) => [row.user_id, row])); for (const [userId, reason] of part) { const row = byId.get(userId); if (!row) continue; const settings = joinedSettings(row); const watching = reason === "subscribed"; const web = watching ? settings.webWatching : settings.webParticipating; const wantsEmail = watching ? settings.emailWatching : settings.emailParticipating; const email = wantsEmail && row.email_verified_at !== null ? row.email : null; if (web || email) out.push({ userId, reason, web, email }); } } return out; }