feat(notifications): alert oversight review queues

This commit is contained in:
JB
2026-07-26 01:53:53 -04:00
parent d4ddfe3b80
commit 551a5c29a1
12 changed files with 328 additions and 3 deletions
+22
View File
@@ -67,6 +67,28 @@ CREATE TABLE IF NOT EXISTS notification_preferences (
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
PRIMARY KEY(user_id, category) 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_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 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); 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);
+15
View File
@@ -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 used by the Agent Profile; the source image remains square and uses
`object-fit: cover` so authenticated surfaces crop it consistently. `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 Contact notifications use the mandatory ISAC visual identity because they are
operational inbound messages. The notification carries a concise preview and a operational inbound messages. The notification carries a concise preview and a
specific action to an authorization-checked full record. Full messages never specific action to an authorization-checked full record. Full messages never
+21
View File
@@ -321,3 +321,24 @@ time by the static `/missing-item-icon.svg` asset through the shared
`ItemIcon` component; the placeholder is never inserted into `ItemIcon` component; the placeholder is never inserted into
`game_data_icons`. A later edit or proposal can attach the real icon without `game_data_icons`. A later edit or proposal can attach the real icon without
recreating or replacing the canonical item. 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.
+2
View File
@@ -17,6 +17,8 @@
"db:restore:production": "dotenvx run -f .env.production -- node scripts/db-restore.mjs", "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": "dotenvx run -f .env.development -- node scripts/notification-digest.mjs",
"notifications:digest:production": "dotenvx run -f .env.production -- 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", "notifications:seed-test": "dotenvx run -f .env.development -- node scripts/seed-test-notifications.mjs",
"assets:trim-icons": "node scripts/trim-static-icons.mjs", "assets:trim-icons": "node scripts/trim-static-icons.mjs",
"env:encrypt": "node scripts/env-files.mjs encrypt", "env:encrypt": "node scripts/env-files.mjs encrypt",
@@ -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: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">ISAC OVERSIGHT NETWORK</p><h1>${title}</h1><p style="white-space:pre-line;color:#a5aaab">${body}</p><a style="color:#f6a821" href="${origin}${first.link}">OPEN OVERSIGHT QUEUE</a></div>`
});
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'}.`);
+2
View File
@@ -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} .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}} @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 */ /* 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}} .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}}
+13 -1
View File
@@ -143,7 +143,19 @@
{:else} {:else}
<a class="sign-in" href="/sign-in">SIGN IN →</a> <a class="sign-in" href="/sign-in">SIGN IN →</a>
{/if} {/if}
<button class="mobile-nav-toggle" type="button" aria-label={mobileOpen ? 'Close navigation' : 'Open navigation'} aria-expanded={mobileOpen} aria-controls="mobile-navigation" onclick={mobileOpen ? closeMobileNavigation : openMobileNavigation}><span></span><span></span><span></span><b>{mobileOpen ? 'CLOSE' : 'MENU'}</b></button> <button
class="mobile-nav-toggle"
type="button"
aria-label={`${mobileOpen ? 'Close' : 'Open'} navigation${unreadNotifications ? `, ${unreadNotifications} unread notification${unreadNotifications === 1 ? '' : 's'}` : ''}`}
aria-expanded={mobileOpen}
aria-controls="mobile-navigation"
onclick={mobileOpen ? closeMobileNavigation : openMobileNavigation}
>
<span></span><span></span><span></span><b>{mobileOpen ? 'CLOSE' : 'MENU'}</b>
{#if unreadNotifications}
<em class="mobile-nav-unread" aria-hidden="true">{unreadNotifications > 99 ? '99+' : unreadNotifications}</em>
{/if}
</button>
</header> </header>
{#if mobileOpen} {#if mobileOpen}
+10 -1
View File
@@ -4,6 +4,7 @@ import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import sharp from 'sharp'; import sharp from 'sharp';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { notifyOversightReview } from '$lib/server/oversight-notifications';
export const MAX_AVATAR_BYTES = 5 * 1024 * 1024; export const MAX_AVATAR_BYTES = 5 * 1024 * 1024;
export const MAX_AVATAR_PIXELS = 20_000_000; 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); .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'")) await (await database.prepare("UPDATE avatar_reviews SET status='superseded',reviewed_at=? WHERE user_id=? AND status='pending'"))
.run(now, userId); .run(now, userId);
const reviewId = randomUUID();
await (await database.prepare(`INSERT INTO avatar_reviews await (await database.prepare(`INSERT INTO avatar_reviews
(id,user_id,avatar_updated_at,storage_key,byte_size,status,submitted_at) (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; return now;
} }
+8
View File
@@ -4,6 +4,7 @@ import { getPermissions } from '$lib/server/auth/authorization';
import type { AuthUser } from '$lib/server/auth/session'; import type { AuthUser } from '$lib/server/auth/session';
import { recordIsacAudit } from '$lib/server/audit'; import { recordIsacAudit } from '$lib/server/audit';
import { getContributorMode } from '$lib/server/contributor-mode'; 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 const DATA_MODULES = ['configuration', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips'] as const;
export type DataModule = typeof DATA_MODULES[number]; export type DataModule = typeof DATA_MODULES[number];
@@ -157,6 +158,13 @@ export async function queueDataProposal(user: AuthUser, module: DataModule, acti
before: null, before: null,
after: { module, action, entityId, draftEntityId, dependencies: dependencies.map((item) => item.id), summary } 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 }; return { id, entityId, draftEntityId };
} }
+98
View File
@@ -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<string, number>;
}
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<OversightNotificationFrequency, 'immediate'>, 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()
);
}
}
@@ -3,6 +3,8 @@ import { getSessionUser } from '$lib/server/auth/session';
import { getDatabase } from '$lib/server/database'; import { getDatabase } from '$lib/server/database';
import { getPagination } from '$lib/server/pagination'; import { getPagination } from '$lib/server/pagination';
import { NOTIFICATION_CATEGORIES } from '$lib/server/notifications'; 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'; import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ cookies, url }) => { 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 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); ${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 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 preferences = NOTIFICATION_CATEGORIES.map((name) => {
const stored = preferenceRows.find((row) => row.category === name); const stored = preferenceRows.find((row) => row.category === name);
return { 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 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 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); 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 = { export const actions: Actions = {
@@ -85,5 +98,30 @@ export const actions: Actions = {
notificationPreferencesSaved: true, notificationPreferencesSaved: true,
notificationPreferenceSaveToken: crypto.randomUUID() 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()
};
} }
}; };
@@ -36,6 +36,7 @@
if (notification.category === 'guides') return { label: 'OPEN GUIDE', href: notification.link }; if (notification.category === 'guides') return { label: 'OPEN GUIDE', href: notification.link };
if (notification.category === 'builds') return { label: 'OPEN BUILD', 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 === '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 }; if (notification.link === '/account') return { label: 'OPEN AGENT PROFILE', href: notification.link };
return { label: 'OPEN RELATED PAGE', href: notification.link }; return { label: 'OPEN RELATED PAGE', href: notification.link };
}; };
@@ -83,6 +84,34 @@
{#if form?.notificationPreferencesSaved}<p class="success">Notification preferences synchronized.</p>{/if} {#if form?.notificationPreferencesSaved}<p class="success">Notification preferences synchronized.</p>{/if}
<div class="form-actions"><button class="primary">SAVE PREFERENCES →</button></div> <div class="form-actions"><button class="primary">SAVE PREFERENCES →</button></div>
</PendingForm> </PendingForm>
{#if data.oversightPreferences.length}
<div class="oversight-preference-heading">
<div><p class="eyebrow">REVIEW QUEUE ALERTS</p><h3>OVERSIGHT DELIVERY CADENCE</h3></div>
<span>10:00 AM EASTERN DAILY DISPATCH</span>
</div>
<p class="notification-rule"><b>PERMISSION-AWARE DELIVERY.</b> Only queues you are authorized to review appear below. Consolidated alerts combine new review items by queue.</p>
<PendingForm action="?/saveOversightPreferences" class="oversight-preference-form">
{#each data.oversightPreferences as preference}
<div class="oversight-preference-row">
<div><b>{preference.label}</b><small>{preference.id === 'data_changes' ? 'CONTRIBUTED DATA AWAITING APPROVAL' : 'CUSTOM AGENT IMAGES AWAITING REVIEW'}</small></div>
<SegmentedChoice
legend={`${preference.label} DELIVERY`}
name={`oversight:${preference.id}`}
options={[
{ value: 'immediate', label: 'IMMEDIATE' },
{ value: '15_minutes', label: '15 MIN' },
{ value: '1_hour', label: '1 HOUR' },
{ value: 'daily', label: 'DAILY · 10 AM ET' }
]}
value={preference.frequency}
/>
</div>
{/each}
{#if form?.oversightPreferenceError}<p class="error">{form.oversightPreferenceError}</p>{/if}
{#if form?.oversightPreferencesSaved}<p class="success">Oversight delivery cadence synchronized.</p>{/if}
<div class="form-actions"><button class="primary">SAVE OVERSIGHT CADENCE →</button></div>
</PendingForm>
{/if}
</section> </section>
{/if} {/if}
<div class="notification-toolbar"> <div class="notification-toolbar">