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
+13 -1
View File
@@ -143,7 +143,19 @@
{:else}
<a class="sign-in" href="/sign-in">SIGN IN →</a>
{/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>
{#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 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;
}
+8
View File
@@ -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 };
}
+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()
);
}
}