diff --git a/database/schema.sql b/database/schema.sql index 1e9326a..ba88760 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -67,6 +67,28 @@ CREATE TABLE IF NOT EXISTS notification_preferences ( updated_at TEXT NOT NULL, PRIMARY KEY(user_id, category) ); +CREATE TABLE IF NOT EXISTS oversight_notification_preferences ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + oversight_category TEXT NOT NULL CHECK(oversight_category IN ('data_changes', 'avatars')), + frequency TEXT NOT NULL DEFAULT 'immediate' CHECK(frequency IN ('immediate', '15_minutes', '1_hour', 'daily')), + updated_at TEXT NOT NULL, + PRIMARY KEY(user_id, oversight_category) +); +CREATE TABLE IF NOT EXISTS oversight_notification_queue ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + oversight_category TEXT NOT NULL CHECK(oversight_category IN ('data_changes', 'avatars')), + review_item_id TEXT NOT NULL, + title TEXT NOT NULL, + summary TEXT NOT NULL, + link TEXT NOT NULL, + frequency TEXT NOT NULL CHECK(frequency IN ('15_minutes', '1_hour', 'daily')), + created_at TEXT NOT NULL, + deliver_after TEXT NOT NULL, + delivered_at TEXT +); +CREATE INDEX IF NOT EXISTS oversight_notification_queue_due_idx ON oversight_notification_queue(delivered_at, deliver_after); +CREATE INDEX IF NOT EXISTS oversight_notification_queue_user_category_idx ON oversight_notification_queue(user_id, oversight_category, delivered_at); CREATE INDEX IF NOT EXISTS avatar_reviews_status_submitted_idx ON avatar_reviews(status, submitted_at DESC); CREATE INDEX IF NOT EXISTS avatar_reviews_user_submitted_idx ON avatar_reviews(user_id, submitted_at DESC); CREATE TABLE IF NOT EXISTS builds (id TEXT PRIMARY KEY, owner_id TEXT NOT NULL REFERENCES users(id), title TEXT NOT NULL, slug TEXT NOT NULL UNIQUE, visibility TEXT NOT NULL DEFAULT 'private', summary TEXT, guide_body TEXT, source_build_id TEXT REFERENCES builds(id), loadout_json TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL); diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 847f5d6..1b0d5f8 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -114,6 +114,21 @@ are always rendered as circular portraits with the restrained orange SHD ring used by the Agent Profile; the source image remains square and uses `object-fit: cover` so authenticated surfaces crop it consistently. +When unread notifications exist on mobile, repeat the compact orange count on +both the menu trigger and the Notifications destination. The trigger badge sits +outside the upper-right control edge without changing the hamburger geometry. +Its accessible name includes the full unread count; the visible badge is hidden +from assistive technology to avoid announcing the count twice. + +Oversight reviewers receive a permission-aware cadence console inside +Notification Preferences. Each available review queue is shown independently +and uses the shared segmented radio pattern for Immediate, 15-minute, one-hour, +or daily delivery. Do not expose categories the agent cannot review. +Consolidated delivery groups queue items rather than issuing repeated messages; +daily delivery targets 10:00 AM in `America/New_York` so the Eastern wall-clock +time remains stable across daylight-saving transitions. On mobile, cadence +segments wrap to two columns and each review category becomes its own row. + Contact notifications use the mandatory ISAC visual identity because they are operational inbound messages. The notification carries a concise preview and a specific action to an authorization-checked full record. Full messages never diff --git a/docs/architecture.md b/docs/architecture.md index 22a135c..f2b5357 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -321,3 +321,24 @@ time by the static `/missing-item-icon.svg` asset through the shared `ItemIcon` component; the placeholder is never inserted into `game_data_icons`. A later edit or proposal can attach the real icon without recreating or replacing the canonical item. +# Oversight notification dispatch + +New Data Change and Avatar Oversight review items are routed only to agents who +currently hold `data.approve` or `avatars.review` respectively. Each reviewer +chooses Immediate, 15-minute, one-hour, or daily delivery independently for +each authorized queue. Immediate alerts are created during submission; +consolidated alerts are persisted in `oversight_notification_queue`. + +Production must run the idempotent dispatcher once per minute from exactly one +scheduler instance: + +```text +* * * * * cd /path/to/shd.build && npm run notifications:oversight:production +``` + +The dispatcher groups due rows by reviewer, queue, cadence, and delivery +window. Daily rows are assigned to the next 10:00 AM wall-clock boundary in +`America/New_York`, including daylight-saving transitions. Successfully +dispatched rows retain `delivered_at` evidence and are never selected again. +The existing ISAC notification delivery preferences still determine optional +browser and email fan-out after the consolidated inbox message is created. diff --git a/package.json b/package.json index 3db271b..77aaf5e 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,8 @@ "db:restore:production": "dotenvx run -f .env.production -- node scripts/db-restore.mjs", "notifications:digest": "dotenvx run -f .env.development -- node scripts/notification-digest.mjs", "notifications:digest:production": "dotenvx run -f .env.production -- node scripts/notification-digest.mjs", + "notifications:oversight": "dotenvx run -f .env.development -- node scripts/oversight-notification-dispatch.mjs", + "notifications:oversight:production": "dotenvx run -f .env.production -- node scripts/oversight-notification-dispatch.mjs", "notifications:seed-test": "dotenvx run -f .env.development -- node scripts/seed-test-notifications.mjs", "assets:trim-icons": "node scripts/trim-static-icons.mjs", "env:encrypt": "node scripts/env-files.mjs encrypt", diff --git a/scripts/oversight-notification-dispatch.mjs b/scripts/oversight-notification-dispatch.mjs new file mode 100644 index 0000000..f22b648 --- /dev/null +++ b/scripts/oversight-notification-dispatch.mjs @@ -0,0 +1,69 @@ +import { connect } from '@tursodatabase/database'; +import nodemailer from 'nodemailer'; +import { randomUUID } from 'node:crypto'; +import { resolve } from 'node:path'; + +const database = await connect(resolve(process.env.TDR_DATABASE_PATH || './data/shd-build.db')); +const now = new Date().toISOString(); +const due = await (await database.prepare(`SELECT * FROM oversight_notification_queue + WHERE delivered_at IS NULL AND deliver_after<=? ORDER BY user_id,oversight_category,created_at`)).all(now); +const groups = new Map(); +for (const item of due) { + const key = `${item.user_id}:${item.oversight_category}:${item.frequency}:${item.deliver_after}`; + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(item); +} + +const smtpConfigured = Boolean(process.env.SMTP_HOST && process.env.SMTP_FROM); +const transport = smtpConfigured ? nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: Number(process.env.SMTP_PORT || 587), + secure: process.env.SMTP_SECURE === 'true', + auth: process.env.SMTP_USER ? { user: process.env.SMTP_USER, pass: process.env.SMTP_PASSWORD } : undefined, + requireTLS: process.env.SMTP_SECURE !== 'true', + disableFileAccess: true, + disableUrlAccess: true +}) : null; +const origin = (process.env.APP_ORIGIN || 'http://localhost:5173').replace(/\/+$/, ''); +let delivered = 0; + +for (const items of groups.values()) { + const first = items[0]; + const categoryLabel = first.oversight_category === 'avatars' ? 'avatar' : 'data change'; + const title = `${items.length} ${categoryLabel} review${items.length === 1 ? '' : 's'} awaiting Oversight`; + const details = items.slice(0, 10).map((item) => `• ${item.summary}`).join('\n'); + const remainder = items.length > 10 ? `\n• ${items.length - 10} additional review items` : ''; + const body = `${details}${remainder}`; + const notificationId = randomUUID(); + await (await database.prepare(`INSERT INTO notifications + (id,user_id,category,source,title,body,link,mandatory,created_at) + VALUES(?,?,'isac','ISAC OVERSIGHT NETWORK',?,?,?,1,?)`)).run( + notificationId, first.user_id, title, body, first.link, now + ); + for (const item of items) { + await (await database.prepare('UPDATE oversight_notification_queue SET delivered_at=? WHERE id=? AND delivered_at IS NULL')).run(now, item.id); + } + const preference = await (await database.prepare( + "SELECT email_frequency FROM notification_preferences WHERE user_id=? AND category='isac'" + )).get(first.user_id); + if (transport && preference?.email_frequency === 'immediate') { + const identity = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(first.user_id); + if (typeof identity?.email === 'string') { + try { + await transport.sendMail({ + from: process.env.SMTP_FROM, + to: identity.email, + subject: `SHD.build · ${title}`, + text: `${body}\n\n${origin}${first.link}`, + html: `

ISAC OVERSIGHT NETWORK

${title}

${body}

OPEN OVERSIGHT QUEUE
` + }); + await (await database.prepare('UPDATE notifications SET emailed_at=? WHERE id=?')).run(now, notificationId); + } catch (error) { + console.error(`Unable to email Oversight digest ${notificationId}`, error); + } + } + } + delivered += items.length; +} + +console.log(`Delivered ${delivered} queued Oversight review alert${delivered === 1 ? '' : 's'} in ${groups.size} digest${groups.size === 1 ? '' : 's'}.`); diff --git a/src/app.css b/src/app.css index 37e4680..a77e460 100644 --- a/src/app.css +++ b/src/app.css @@ -220,6 +220,8 @@ .desktop-agent-trigger>img,.desktop-agent-panel header>img,.mobile-agent-state>img{border-color:var(--shd-orange);border-radius:50%;box-shadow:0 0 0 3px #0a0c0d,0 0 0 4px #f6a82144} @media(max-width:900px){.mobile-navigation{overflow-x:hidden}.mobile-navigation>a span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}} +.mobile-nav-toggle{position:relative}.mobile-nav-unread{position:absolute;top:-7px;right:-7px;min-width:18px;height:18px;display:grid;place-items:center;padding:0 4px;border:1px solid #e8a43a;background:var(--shd-orange);box-shadow:0 0 0 2px #0a0c0d;color:#111;font-size:6px;font-style:normal;font-weight:900;line-height:1;pointer-events:none} +.oversight-preference-heading{display:flex;align-items:end;justify-content:space-between;gap:16px;margin-top:18px;padding:14px 9px 9px;border-top:1px solid var(--shd-border)}.oversight-preference-heading h3{margin:4px 0 0;font-size:13px}.oversight-preference-heading>span{color:#8ecce5;font-size:6px;font-weight:900;letter-spacing:.8px}.oversight-preference-form{display:grid;gap:7px}.oversight-preference-row{display:grid;grid-template-columns:minmax(190px,.65fr) minmax(420px,1.35fr);align-items:center;gap:10px;padding:9px;border:1px solid var(--shd-border);background:#0d1011}.oversight-preference-row>div:first-child{display:grid;gap:4px}.oversight-preference-row>div:first-child b{font-size:8px;letter-spacing:.7px}.oversight-preference-row>div:first-child small{color:var(--shd-text-muted);font-size:5px;letter-spacing:.5px}.oversight-preference-row fieldset{margin:0}@media(max-width:760px){.oversight-preference-heading{align-items:start;flex-direction:column}.oversight-preference-row{grid-template-columns:1fr}.oversight-preference-row fieldset>div{grid-template-columns:1fr 1fr}} /* Global action feedback */ .toast-viewport{position:fixed;z-index:1000;top:calc(var(--shd-header-height) + 12px);right:clamp(12px,2vw,28px);width:min(390px,calc(100vw - 24px));display:grid;gap:8px;pointer-events:none}.toast-message{--toast-tone:#829093;position:relative;min-height:72px;display:grid;grid-template-columns:4px minmax(0,1fr) 34px;overflow:hidden;border:1px solid #414849;border-top:2px solid var(--toast-tone);background:linear-gradient(105deg,#171b1cee,#0c0f10f7);box-shadow:0 12px 30px #000b;color:var(--shd-text);pointer-events:auto;animation:toast-enter .18s ease-out}.toast-message.success{--toast-tone:var(--shd-success)}.toast-message.error{--toast-tone:var(--shd-danger)}.toast-message.warning{--toast-tone:var(--shd-orange)}.toast-message.info{--toast-tone:#6d9daf}.toast-message>i{width:4px;height:100%;background:var(--toast-tone);box-shadow:0 0 12px color-mix(in srgb,var(--toast-tone) 50%,transparent)}.toast-message>div{min-width:0;display:grid;align-content:center;gap:4px;padding:11px 10px}.toast-message span{color:var(--toast-tone);font-size:6px;font-weight:900;letter-spacing:1px}.toast-message strong{overflow-wrap:anywhere;font-size:9px;line-height:1.3;letter-spacing:.3px}.toast-message p{margin:0;color:#91999b;font-size:7px;line-height:1.45}.toast-message>button{width:34px;height:34px;margin:4px 3px 0 0;border:0;background:transparent;color:#70787a;font-size:17px;cursor:pointer}.toast-message>button:hover,.toast-message>button:focus-visible{background:#ffffff09;color:var(--toast-tone)}.toast-message>button:active{transform:translateY(1px);filter:brightness(.8)}@keyframes toast-enter{from{opacity:0;transform:translateX(18px)}to{opacity:1;transform:none}}@media(max-width:700px){.toast-viewport{top:calc(var(--shd-header-height) + 8px);right:8px;width:calc(100vw - 16px)}.toast-message{min-height:68px}}@media(prefers-reduced-motion:reduce){.toast-message{animation:none}} diff --git a/src/lib/components/AppShell.svelte b/src/lib/components/AppShell.svelte index a25c526..bea37c4 100644 --- a/src/lib/components/AppShell.svelte +++ b/src/lib/components/AppShell.svelte @@ -143,7 +143,19 @@ {:else} SIGN IN → {/if} - + {#if mobileOpen} diff --git a/src/lib/server/avatars.ts b/src/lib/server/avatars.ts index 84925e8..d236a68 100644 --- a/src/lib/server/avatars.ts +++ b/src/lib/server/avatars.ts @@ -4,6 +4,7 @@ import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'; import path from 'node:path'; import sharp from 'sharp'; import { randomUUID } from 'node:crypto'; +import { notifyOversightReview } from '$lib/server/oversight-notifications'; export const MAX_AVATAR_BYTES = 5 * 1024 * 1024; export const MAX_AVATAR_PIXELS = 20_000_000; @@ -60,9 +61,17 @@ export async function saveUploadedAvatar(database: Database, userId: string, out .run(userId, 'upload', key, output.length, now); await (await database.prepare("UPDATE avatar_reviews SET status='superseded',reviewed_at=? WHERE user_id=? AND status='pending'")) .run(now, userId); + const reviewId = randomUUID(); await (await database.prepare(`INSERT INTO avatar_reviews (id,user_id,avatar_updated_at,storage_key,byte_size,status,submitted_at) - VALUES(?,?,?,?,?,'pending',?)`)).run(randomUUID(), userId, now, key, output.length, now); + VALUES(?,?,?,?,?,'pending',?)`)).run(reviewId, userId, now, key, output.length, now); + await notifyOversightReview({ + category: 'avatars', + itemId: reviewId, + title: 'Avatar awaiting review', + summary: 'A new custom agent avatar was uploaded and is ready for Oversight review.', + link: '/isac/avatar-oversight?status=pending' + }).catch((error) => console.error('Unable to notify Avatar Oversight reviewers', error)); return now; } diff --git a/src/lib/server/data-moderation.ts b/src/lib/server/data-moderation.ts index c1b72ba..212591d 100644 --- a/src/lib/server/data-moderation.ts +++ b/src/lib/server/data-moderation.ts @@ -4,6 +4,7 @@ import { getPermissions } from '$lib/server/auth/authorization'; import type { AuthUser } from '$lib/server/auth/session'; import { recordIsacAudit } from '$lib/server/audit'; import { getContributorMode } from '$lib/server/contributor-mode'; +import { notifyOversightReview } from '$lib/server/oversight-notifications'; export const DATA_MODULES = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips'] as const; export type DataModule = typeof DATA_MODULES[number]; @@ -157,6 +158,13 @@ export async function queueDataProposal(user: AuthUser, module: DataModule, acti before: null, after: { module, action, entityId, draftEntityId, dependencies: dependencies.map((item) => item.id), summary } }); + await notifyOversightReview({ + category: 'data_changes', + itemId: id, + title: 'Data change awaiting review', + summary: `${summary} was submitted to the Oversight queue and is ready for review.`, + link: `/isac/data-moderation?search=${encodeURIComponent(name || entityId || id)}` + }).catch((error) => console.error('Unable to notify Data Oversight reviewers', error)); return { id, entityId, draftEntityId }; } diff --git a/src/lib/server/oversight-notifications.ts b/src/lib/server/oversight-notifications.ts new file mode 100644 index 0000000..387a5bd --- /dev/null +++ b/src/lib/server/oversight-notifications.ts @@ -0,0 +1,98 @@ +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() + ); + } +} diff --git a/src/routes/account/notifications/+page.server.ts b/src/routes/account/notifications/+page.server.ts index c4297e1..6475897 100644 --- a/src/routes/account/notifications/+page.server.ts +++ b/src/routes/account/notifications/+page.server.ts @@ -3,6 +3,8 @@ import { getSessionUser } from '$lib/server/auth/session'; import { getDatabase } from '$lib/server/database'; import { getPagination } 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 type { Actions, PageServerLoad } from './$types'; export const load: PageServerLoad = async ({ cookies, url }) => { @@ -34,6 +36,17 @@ export const load: PageServerLoad = async ({ cookies, url }) => { LEFT JOIN notification_preferences p ON p.user_id=n.user_id AND p.category=n.category ${where} ORDER BY n.created_at DESC 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 { @@ -46,7 +59,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => { 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, pagination, filters: { categories, states }, unread, hasVerifiedEmail: Boolean(await (await database.prepare('SELECT 1 FROM user_emails WHERE user_id=?')).get(user.id)) }; + return { notifications, preferences, oversightPreferences, pagination, filters: { categories, states }, unread, hasVerifiedEmail: Boolean(await (await database.prepare('SELECT 1 FROM user_emails WHERE user_id=?')).get(user.id)) }; }; export const actions: Actions = { @@ -85,5 +98,30 @@ export const actions: Actions = { 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() + }; } }; diff --git a/src/routes/account/notifications/+page.svelte b/src/routes/account/notifications/+page.svelte index 97147be..5bfb402 100644 --- a/src/routes/account/notifications/+page.svelte +++ b/src/routes/account/notifications/+page.svelte @@ -36,6 +36,7 @@ if (notification.category === 'guides') return { label: 'OPEN GUIDE', href: notification.link }; if (notification.category === 'builds') return { label: 'OPEN BUILD', href: notification.link }; if (notification.source === 'SHD CONTACT RELAY') return { label: notification.title.startsWith('DMCA NOTICE') ? 'REVIEW COPYRIGHT REQUEST' : 'OPEN CONTACT MESSAGE', href: notification.link }; + if (notification.source === 'ISAC OVERSIGHT NETWORK') return { label: 'OPEN OVERSIGHT QUEUE', href: notification.link }; if (notification.link === '/account') return { label: 'OPEN AGENT PROFILE', href: notification.link }; return { label: 'OPEN RELATED PAGE', href: notification.link }; }; @@ -83,6 +84,34 @@ {#if form?.notificationPreferencesSaved}

Notification preferences synchronized.

{/if}
+ {#if data.oversightPreferences.length} +
+

REVIEW QUEUE ALERTS

OVERSIGHT DELIVERY CADENCE

+ 10:00 AM EASTERN DAILY DISPATCH +
+

PERMISSION-AWARE DELIVERY. Only queues you are authorized to review appear below. Consolidated alerts combine new review items by queue.

+ + {#each data.oversightPreferences as preference} +
+
{preference.label}{preference.id === 'data_changes' ? 'CONTRIBUTED DATA AWAITING APPROVAL' : 'CUSTOM AGENT IMAGES AWAITING REVIEW'}
+ +
+ {/each} + {#if form?.oversightPreferenceError}

{form.oversightPreferenceError}

{/if} + {#if form?.oversightPreferencesSaved}

Oversight delivery cadence synchronized.

{/if} +
+
+ {/if} {/if}