import { fail, redirect } from '@sveltejs/kit'; import { getSessionUser } from '$lib/server/auth/session'; import { getDatabase } from '$lib/server/database'; import { getPagination, getRegistrySearch } from '$lib/server/pagination'; import { NOTIFICATION_CATEGORIES } from '$lib/server/notifications'; import { getPermissions } from '$lib/server/auth/authorization'; import { OVERSIGHT_NOTIFICATION_CATEGORIES, OVERSIGHT_NOTIFICATION_FREQUENCIES, } from '$lib/server/oversight-notifications'; import { notificationOrderBy, parseNotificationSort, parseNotificationState, } from '$lib/server/notification-sort'; import type { Actions, PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ cookies, url }) => { const user = await getSessionUser(cookies); if (!user) redirect(303, '/sign-in'); const database = await getDatabase(); const state = parseNotificationState(url.searchParams.get('state')); const sort = parseNotificationSort(url.searchParams.get('sort')); const { search, pattern } = getRegistrySearch(url); const conditions = [ 'n.user_id=?', '(n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)', ]; const args: unknown[] = [user.id]; if (state === 'unread') conditions.push('n.read_at IS NULL'); if (state === 'read') conditions.push('n.read_at IS NOT NULL'); if (search) { conditions.push(`(n.title LIKE ? ESCAPE '\\' COLLATE NOCASE OR n.body LIKE ? ESCAPE '\\' COLLATE NOCASE OR n.source LIKE ? ESCAPE '\\' COLLATE NOCASE)`); args.push(pattern, pattern, pattern); } const where = `WHERE ${conditions.join(' AND ')}`; const orderBy = notificationOrderBy(sort); const total = Number( ( await ( await database.prepare( `SELECT COUNT(*) AS total FROM notifications n LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category ${where}`, ) ).get(...args) )?.total ?? 0, ); const pagination = getPagination(url, total); const notifications = await ( await database.prepare(`SELECT n.* FROM notifications n LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category ${where} ORDER BY ${orderBy} LIMIT ? OFFSET ?`) ).all(...args, pagination.perPage, pagination.offset); const preferenceRows = await ( await database.prepare( 'SELECT * FROM notification_preferences WHERE user_id=?', ) ).all(user.id); const permissions = await getPermissions(user); const oversightCategories = OVERSIGHT_NOTIFICATION_CATEGORIES.filter( (category) => permissions.includes('*') || permissions.includes(category.permission), ); const oversightRows = oversightCategories.length ? await ( await database.prepare( 'SELECT * FROM oversight_notification_preferences WHERE user_id=?', ) ).all(user.id) : []; const oversightPreferences = oversightCategories.map((category) => ({ ...category, frequency: String( oversightRows.find((row) => row.oversight_category === category.id) ?.frequency ?? 'immediate', ), })); const preferences = NOTIFICATION_CATEGORIES.map((name) => { const stored = preferenceRows.find((row) => row.category === name); return { category: name, inApp: name === 'isac' ? true : stored?.in_app_enabled === undefined ? true : Boolean(stored.in_app_enabled), browser: Boolean(stored?.browser_enabled), emailFrequency: String(stored?.email_frequency ?? 'none'), }; }); const unread = Number( ( await ( await database.prepare(`SELECT COUNT(*) AS count FROM notifications n LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category WHERE n.user_id=? AND n.read_at IS NULL AND (n.mandatory=1 OR COALESCE(p.in_app_enabled,1)=1)`) ).get(user.id) )?.count ?? 0, ); return { notifications, preferences, oversightPreferences, pagination, filters: { state, sort, search }, unread, hasVerifiedEmail: Boolean( await ( await database.prepare('SELECT 1 FROM user_emails WHERE user_id=?') ).get(user.id), ), }; }; export const actions: Actions = { markRead: async ({ cookies, request }) => { const user = await getSessionUser(cookies); if (!user) redirect(303, '/sign-in'); const id = String((await request.formData()).get('id') ?? ''); await ( await ( await getDatabase() ).prepare( 'UPDATE notifications SET read_at=COALESCE(read_at,?) WHERE id=? AND user_id=?', ) ).run(new Date().toISOString(), id, user.id); return { notificationUpdated: true }; }, markAllRead: async ({ cookies }) => { const user = await getSessionUser(cookies); if (!user) redirect(303, '/sign-in'); await ( await ( await getDatabase() ).prepare( 'UPDATE notifications SET read_at=? WHERE user_id=? AND read_at IS NULL', ) ).run(new Date().toISOString(), user.id); return { notificationsCleared: true }; }, savePreferences: async ({ cookies, request }) => { const user = await getSessionUser(cookies); if (!user) redirect(303, '/sign-in'); const form = await request.formData(); const database = await getDatabase(); const now = new Date().toISOString(); const hasEmail = Boolean( await ( await database.prepare('SELECT 1 FROM user_emails WHERE user_id=?') ).get(user.id), ); for (const category of NOTIFICATION_CATEGORIES) { const inApp = category === 'isac' || form.get(`inApp:${category}`) === 'on'; const browser = form.get(`browser:${category}`) === 'on'; const emailFrequency = String(form.get(`email:${category}`) ?? 'none'); if (!['none', 'immediate', 'daily'].includes(emailFrequency)) return fail(400, { notificationPreferenceError: 'Choose a valid email frequency.', }); if (emailFrequency !== 'none' && !hasEmail) return fail(400, { notificationPreferenceError: 'Add and verify a magic-link email before enabling email notifications.', }); await ( await database.prepare(`INSERT INTO notification_preferences(user_id,category,in_app_enabled,browser_enabled,email_frequency,updated_at) VALUES(?,?,?,?,?,?) ON CONFLICT(user_id,category) DO UPDATE SET in_app_enabled=excluded.in_app_enabled,browser_enabled=excluded.browser_enabled,email_frequency=excluded.email_frequency,updated_at=excluded.updated_at`) ).run( user.id, category, inApp ? 1 : 0, browser ? 1 : 0, emailFrequency, now, ); } return { notificationPreferencesSaved: true, notificationPreferenceSaveToken: crypto.randomUUID(), }; }, saveOversightPreferences: async ({ cookies, request }) => { const user = await getSessionUser(cookies); if (!user) redirect(303, '/sign-in'); const permissions = await getPermissions(user); const available = OVERSIGHT_NOTIFICATION_CATEGORIES.filter( (category) => permissions.includes('*') || permissions.includes(category.permission), ); if (!available.length) return fail(403, { oversightPreferenceError: 'Oversight permission is required.', }); const form = await request.formData(); const database = await getDatabase(); const now = new Date().toISOString(); for (const category of available) { const frequency = String( form.get(`oversight:${category.id}`) ?? 'immediate', ); if (!OVERSIGHT_NOTIFICATION_FREQUENCIES.includes(frequency as any)) { return fail(400, { oversightPreferenceError: 'Choose a valid Oversight notification frequency.', }); } await ( await database.prepare(`INSERT INTO oversight_notification_preferences(user_id,oversight_category,frequency,updated_at) VALUES(?,?,?,?) ON CONFLICT(user_id,oversight_category) DO UPDATE SET frequency=excluded.frequency,updated_at=excluded.updated_at`) ).run(user.id, category.id, frequency, now); } return { oversightPreferencesSaved: true, notificationPreferenceSaveToken: crypto.randomUUID(), }; }, };