feat(notifications): alert oversight review queues
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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'}.`);
|
||||||
@@ -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}}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user