import { randomUUID } from 'node:crypto'; import { getDatabase } from '$lib/server/database'; import { createNotification } from '$lib/server/notifications'; export const OVERSIGHT_NOTIFICATION_CATEGORIES = [ { id: 'data_changes', label: 'DATA CHANGES', permission: 'data.approve', link: '/isac/data-moderation' }, { id: 'avatars', label: 'AVATAR REVIEW', permission: 'avatars.review', link: '/isac/avatar-oversight' } ] as const; export const OVERSIGHT_NOTIFICATION_FREQUENCIES = ['immediate', '15_minutes', '1_hour', 'daily'] as const; export type OversightNotificationCategory = (typeof OVERSIGHT_NOTIFICATION_CATEGORIES)[number]['id']; export type OversightNotificationFrequency = (typeof OVERSIGHT_NOTIFICATION_FREQUENCIES)[number]; function easternParts(date: Date) { const parts = new Intl.DateTimeFormat('en-CA', { timeZone: 'America/New_York', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(date); return Object.fromEntries(parts.map((part) => [part.type, Number(part.value)])) as Record; } function easternInstant(year: number, month: number, day: number, hour: number) { for (const offset of [4, 5]) { const candidate = new Date(Date.UTC(year, month - 1, day, hour + offset)); const parts = easternParts(candidate); if (parts.year === year && parts.month === month && parts.day === day && parts.hour === hour) return candidate; } throw new Error('Unable to resolve Eastern delivery time.'); } function deliveryTime(frequency: Exclude, now: Date) { if (frequency !== 'daily') { const window = frequency === '15_minutes' ? 15 * 60_000 : 60 * 60_000; return new Date(Math.floor(now.getTime() / window) * window + window); } const parts = easternParts(now); const localDate = new Date(Date.UTC(parts.year, parts.month - 1, parts.day)); if (parts.hour >= 10) localDate.setUTCDate(localDate.getUTCDate() + 1); return easternInstant(localDate.getUTCFullYear(), localDate.getUTCMonth() + 1, localDate.getUTCDate(), 10); } async function eligibleReviewers(permission: string) { const database = await getDatabase(); return (await database.prepare(` SELECT DISTINCT users.id FROM users LEFT JOIN deleted_accounts deleted ON deleted.user_id=users.id LEFT JOIN user_status status ON status.user_id=users.id LEFT JOIN user_roles ON user_roles.user_id=users.id LEFT JOIN role_permissions ON role_permissions.role_id=user_roles.role_id LEFT JOIN permissions role_permission ON role_permission.id=role_permissions.permission_id LEFT JOIN user_permissions ON user_permissions.user_id=users.id LEFT JOIN permissions direct_permission ON direct_permission.id=user_permissions.permission_id WHERE deleted.user_id IS NULL AND status.blocked_at IS NULL AND (status.banned_until IS NULL OR status.banned_until<=?) AND (users.role='admin' OR role_permission.name IN (?, 'system.admin') OR direct_permission.name IN (?, 'system.admin')) `)).all(new Date().toISOString(), permission, permission); } export async function notifyOversightReview(input: { category: OversightNotificationCategory; itemId: string; title: string; summary: string; link?: string; }) { const definition = OVERSIGHT_NOTIFICATION_CATEGORIES.find((item) => item.id === input.category); if (!definition) throw new Error('Unknown Oversight notification category.'); const database = await getDatabase(); const now = new Date(); for (const reviewer of await eligibleReviewers(definition.permission)) { const userId = String(reviewer.id); const preference = await (await database.prepare( 'SELECT frequency FROM oversight_notification_preferences WHERE user_id=? AND oversight_category=?' )).get(userId, input.category); const frequency = String(preference?.frequency ?? 'immediate') as OversightNotificationFrequency; if (frequency === 'immediate') { await createNotification({ userId, category: 'isac', source: 'ISAC OVERSIGHT NETWORK', title: input.title, body: input.summary, link: input.link ?? definition.link, mandatory: true }); continue; } await (await database.prepare(`INSERT INTO oversight_notification_queue (id,user_id,oversight_category,review_item_id,title,summary,link,frequency,created_at,deliver_after) VALUES(?,?,?,?,?,?,?,?,?,?)`)).run( randomUUID(), userId, input.category, input.itemId, input.title.slice(0, 140), input.summary.slice(0, 2000), input.link ?? definition.link, frequency, now.toISOString(), deliveryTime(frequency, now).toISOString() ); } }