Add automated ISAC system notifications

This commit is contained in:
JB
2026-07-26 02:08:34 -04:00
parent 551a5c29a1
commit 32e10c710b
20 changed files with 392 additions and 11 deletions
+33
View File
@@ -33,6 +33,7 @@ CREATE TABLE IF NOT EXISTS notifications (
title TEXT NOT NULL,
body TEXT NOT NULL,
link TEXT,
actions_json TEXT,
mandatory INTEGER NOT NULL DEFAULT 0 CHECK(mandatory IN (0, 1)),
created_at TEXT NOT NULL,
read_at TEXT,
@@ -87,6 +88,37 @@ CREATE TABLE IF NOT EXISTS oversight_notification_queue (
deliver_after TEXT NOT NULL,
delivered_at TEXT
);
CREATE TABLE IF NOT EXISTS automated_system_messages (
id TEXT PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL,
event_key TEXT NOT NULL CHECK(event_key IN ('account.created')),
enabled INTEGER NOT NULL DEFAULT 1 CHECK(enabled IN (0,1)),
send_delay_minutes INTEGER NOT NULL DEFAULT 0 CHECK(send_delay_minutes BETWEEN 0 AND 10080),
source TEXT NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
link TEXT,
actions_json TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_by TEXT REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS automated_system_message_deliveries (
id TEXT PRIMARY KEY,
message_id TEXT NOT NULL REFERENCES automated_system_messages(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
event_key TEXT NOT NULL,
event_id TEXT NOT NULL,
scheduled_for TEXT NOT NULL,
delivered_at TEXT,
notification_id TEXT REFERENCES notifications(id) ON DELETE SET NULL,
created_at TEXT NOT NULL,
UNIQUE(message_id,user_id,event_key,event_id)
);
CREATE INDEX IF NOT EXISTS automated_system_message_deliveries_due_idx ON automated_system_message_deliveries(delivered_at,scheduled_for);
INSERT OR IGNORE INTO automated_system_messages(id,name,description,event_key,enabled,send_delay_minutes,source,title,body,link,actions_json,created_at,updated_at)
VALUES('welcome-agent','Welcome Agent','Introduces newly registered agents to SHD.build community channels and contribution workflows','account.created',1,0,'ISAC COMMUNITY NETWORK','Welcome to SHD.build','Welcome, Agent. SHD.build is your community intelligence hub for The Division Resurgence. Explore field news, search the game database, discover community builds and guides, or configure your own loadout in the Builder. ISAC also lets every registered agent inspect game data and enable Contributor Mode to suggest additions and corrections for Oversight review. Join the SHD Discord to meet other agents, share intelligence, and help the network grow.','/account','[{"label":"EXPLORE NEWS","href":"/news"},{"label":"QUERY DATABASE","href":"/database"},{"label":"VIEW COMMUNITY BUILDS","href":"/builds"},{"label":"READ FIELD GUIDES","href":"/guides"},{"label":"OPEN BUILDER","href":"/builder"},{"label":"CONTRIBUTE THROUGH ISAC","href":"/isac/contributor-guidelines"},{"label":"JOIN SHD DISCORD","href":"https://discord.gg/dhpd33VwQK"}]','2026-07-26T00:00:00.000Z','2026-07-26T00:00:00.000Z');
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);
@@ -207,6 +239,7 @@ INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-users-
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-audit-view','audit.view','View and search the immutable ISAC audit history');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-audit-revert','audit.revert','Attempt confirmed compensating reversals of supported ISAC audit events');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-metrics-view','metrics.view','View read-only application, runtime, and database health metrics');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-system-notifications-manage','system.notifications.manage','View and edit automated ISAC system notification messages');
INSERT OR IGNORE INTO permissions(id,name,description) VALUES('permission-avatars-review','avatars.review','Review and remove custom user avatars');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-isac-access');
INSERT OR IGNORE INTO role_permissions(role_id,permission_id) VALUES('role-data-editor','permission-data-manage');
+15
View File
@@ -129,6 +129,21 @@ 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.
ISAC System Configuration is a distinct navigation domain from System
Oversight. It contains operational configuration such as Automated Messages and
read-only observability such as System Metrics. Automated-message editors use
the standard horizontal edit lifecycle: successful edits close the editor,
validation preserves it, and event triggers remain explicit. Content, enabled
state, post-event delay, primary destination, and up to eight action callouts
are displayed as structured fields rather than raw JSON.
Automated ISAC messages may expose multiple square action callouts in the
notification inbox and detail view. Callouts wrap on desktop and become
full-width touch targets on mobile. Each retains a specific verb and
destination; do not replace the set with a generic “open” action. External
community actions such as Discord use secure HTTPS destinations while internal
actions use routed paths.
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
+26
View File
@@ -342,3 +342,29 @@ window. Daily rows are assigned to the next 10:00 AM wall-clock boundary in
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.
## Automated ISAC system messages
Automated message definitions live in `automated_system_messages` and are
managed through ISAC → System Configuration → Automated Messages by agents with
`system.notifications.manage`. Edits are recorded under the
`system_configuration` audit category. Definitions identify a lifecycle event,
enabled state, delay in minutes, message content, primary destination, and
structured action callouts.
`account.created` is emitted by password, magic-link, and Discord registration.
The delivery table has a unique message/user/event/event-ID constraint, so the
same account creation cannot deliver the Welcome Agent message more than once.
The default welcome introduces News, Database, Builds, Guides, Builder, ISAC
Contributor Mode, and the SHD Discord community.
Immediate definitions are delivered in the registration request. Definitions
with a delay are persisted in `automated_system_message_deliveries`. Production
must run the delayed dispatcher once per minute:
```text
* * * * * cd /path/to/shd.build && npm run notifications:system:production
```
Only one dispatcher instance should run for the embedded database. Delivered
rows retain both their delivery timestamp and generated notification ID.
+2
View File
@@ -19,6 +19,8 @@
"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:system": "dotenvx run -f .env.development -- node scripts/system-notification-dispatch.mjs",
"notifications:system:production": "dotenvx run -f .env.production -- node scripts/system-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",
+6
View File
@@ -93,6 +93,12 @@ for (const [name, definition] of [
await database.exec("UPDATE data_change_proposals SET draft_entity_id=id WHERE draft_entity_id IS NULL AND entity_id IS NULL");
await database.exec('CREATE UNIQUE INDEX IF NOT EXISTS data_change_proposals_draft_entity_idx ON data_change_proposals(draft_entity_id) WHERE draft_entity_id IS NOT NULL');
const notificationColumns = await (await database.prepare("PRAGMA table_info('notifications')")).all();
if (!notificationColumns.some((column) => column.name === 'actions_json')) {
console.log('Adding structured action callouts to notifications.');
await database.exec('ALTER TABLE notifications ADD COLUMN actions_json TEXT');
}
const proposalDefinition = await (await database.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'"
)).get();
+66
View File
@@ -0,0 +1,66 @@
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 delivery.id AS delivery_id,delivery.user_id,message.*
FROM automated_system_message_deliveries delivery
JOIN automated_system_messages message ON message.id=delivery.message_id
WHERE delivery.delivered_at IS NULL AND delivery.scheduled_for<=? AND message.enabled=1
ORDER BY delivery.scheduled_for LIMIT 250`)).all(now);
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(/\/+$/, '');
const escapeHtml = (value) => String(value).replace(/[&<>"']/g, (character) => ({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;'
})[character]);
let delivered = 0;
for (const message of due) {
const notificationId = randomUUID();
await (await database.prepare(`INSERT INTO notifications
(id,user_id,category,source,title,body,link,actions_json,mandatory,created_at)
VALUES(?,?,'isac',?,?,?,?,?,1,?)`)).run(
notificationId, message.user_id, message.source, message.title, message.body,
message.link ?? null, message.actions_json ?? null, now
);
await (await database.prepare(`UPDATE automated_system_message_deliveries
SET delivered_at=?,notification_id=? WHERE id=? AND delivered_at IS NULL`))
.run(now, notificationId, message.delivery_id);
const preference = await (await database.prepare(
"SELECT email_frequency FROM notification_preferences WHERE user_id=? AND category='isac'"
)).get(message.user_id);
if (transport && preference?.email_frequency === 'immediate') {
const identity = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(message.user_id);
if (typeof identity?.email === 'string') {
try {
await transport.sendMail({
from: process.env.SMTP_FROM,
to: identity.email,
subject: `SHD.build · ${message.title}`,
text: `${message.body}\n\n${message.link ? `${origin}${message.link}` : origin}`,
html: `<div style="background:#0a0c0d;color:#ecebe5;padding:28px;font-family:Arial,sans-serif"><p style="color:#f6a821;font-size:12px;letter-spacing:2px">${escapeHtml(message.source)}</p><h1>${escapeHtml(message.title)}</h1><p style="color:#a5aaab">${escapeHtml(message.body)}</p>${message.link ? `<a style="color:#f6a821" href="${escapeHtml(`${origin}${message.link}`)}">OPEN SHD.BUILD</a>` : ''}</div>`
});
await (await database.prepare('UPDATE notifications SET emailed_at=? WHERE id=?')).run(now, notificationId);
} catch (error) {
console.error(`Unable to email automated notification ${notificationId}`, error);
}
}
}
delivered += 1;
}
console.log(`Delivered ${delivered} automated ISAC system notification${delivered === 1 ? '' : 's'}.`);
+1
View File
@@ -222,6 +222,7 @@
@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}}
.system-message-editor{margin-bottom:10px}.system-message-editor-grid{display:grid;grid-template-columns:1.1fr 1.1fr .8fr;gap:7px;padding:9px;border:1px solid var(--shd-border);background:#0d1011}.system-message-editor-grid .wide{grid-column:span 1}.system-message-editor-grid .full{grid-column:1/-1}.system-message-editor-grid textarea{min-height:126px;resize:vertical}.system-message-editor-grid small{color:var(--shd-text-muted);font-size:5px}.system-message-enabled{display:grid;grid-template-columns:1fr auto;align-items:center;padding:8px;border:1px solid var(--shd-border);background:#111415}.system-message-enabled>span{grid-column:1/-1;color:var(--shd-text-muted);font-size:6px;letter-spacing:.8px}.system-message-enabled input{width:18px;margin:0;accent-color:var(--shd-orange)}.system-message-enabled b{color:var(--shd-orange);font-size:7px;letter-spacing:.8px}.system-message-actions{display:grid;margin-top:7px;border:1px solid var(--shd-border)}.system-message-actions>div{min-height:38px;display:grid;grid-template-columns:34px minmax(150px,.7fr) minmax(220px,1.3fr);align-items:center;gap:6px;padding:5px 7px;border-top:1px solid var(--shd-border-soft)}.system-message-actions>div:first-child{grid-template-columns:1fr auto;border-top:0;background:#0d1011}.system-message-actions>div:first-child span{font-size:7px;font-weight:900;letter-spacing:.8px}.system-message-actions>div:first-child small{color:var(--shd-text-muted);font-size:5px}.system-message-actions b{color:#5f6769;font-size:6px}.system-message-actions input{height:30px;margin:0;font-size:7px}.system-message-table .dashboard-row{grid-template-columns:1.3fr .7fr .7fr .7fr 64px}.system-message-table .dashboard-row>div{display:grid;min-width:0;gap:3px}.system-message-table .dashboard-row small{overflow:hidden;color:var(--shd-text-muted);font-size:6px;text-overflow:ellipsis;white-space:nowrap}.notification-actions{flex-wrap:wrap}.notification-detail-actions{flex-wrap:wrap}.notification-detail-actions>a{min-height:38px;display:inline-flex;align-items:center;justify-content:center;padding:0 12px}@media(max-width:900px){.system-message-editor-grid{grid-template-columns:1fr 1fr}.system-message-editor-grid .full{grid-column:1/-1}.system-message-table .dashboard-row{grid-template-columns:1fr}.system-message-table .dashboard-row.heading{display:none}.system-message-table .dashboard-row>button{min-height:44px}}@media(max-width:600px){.system-message-editor-grid{grid-template-columns:1fr}.system-message-editor-grid .full{grid-column:auto}.system-message-actions>div,.system-message-actions>div:first-child{grid-template-columns:1fr}.system-message-actions>div>b{display:none}.notification-detail-actions>a{width:100%}}
/* 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}}
+70
View File
@@ -0,0 +1,70 @@
import { randomUUID } from 'node:crypto';
import { getDatabase } from '$lib/server/database';
import { createNotification } from '$lib/server/notifications';
export const AUTOMATED_MESSAGE_EVENTS = [
{ value: 'account.created', label: 'ACCOUNT CREATED', description: 'After a new SHD.build account is created' }
] as const;
export type AutomatedMessageEvent = (typeof AUTOMATED_MESSAGE_EVENTS)[number]['value'];
type MessageRow = Record<string, unknown>;
function parseActions(value: unknown): Array<{ label: string; href: string }> {
try {
const parsed = JSON.parse(String(value ?? '[]'));
if (!Array.isArray(parsed)) return [];
return parsed
.filter((item) => item && typeof item.label === 'string' && typeof item.href === 'string')
.map((item) => ({ label: item.label.slice(0, 60), href: item.href.slice(0, 500) }))
.slice(0, 8);
} catch {
return [];
}
}
async function deliver(message: MessageRow, deliveryId: string, userId: string) {
const notificationId = await createNotification({
userId,
category: 'isac',
source: String(message.source),
title: String(message.title),
body: String(message.body),
link: typeof message.link === 'string' ? message.link : null,
actions: parseActions(message.actions_json),
mandatory: true
});
if (!notificationId) return;
const database = await getDatabase();
await (await database.prepare(`UPDATE automated_system_message_deliveries
SET delivered_at=?,notification_id=? WHERE id=? AND delivered_at IS NULL`))
.run(new Date().toISOString(), notificationId, deliveryId);
}
export async function triggerAutomatedSystemMessages(input: {
eventKey: AutomatedMessageEvent;
userId: string;
eventId: string;
}) {
const database = await getDatabase();
const messages = await (await database.prepare(
'SELECT * FROM automated_system_messages WHERE event_key=? AND enabled=1 ORDER BY name'
)).all(input.eventKey);
const now = new Date();
for (const message of messages) {
const existing = await (await database.prepare(`SELECT id FROM automated_system_message_deliveries
WHERE message_id=? AND user_id=? AND event_key=? AND event_id=?`))
.get(message.id, input.userId, input.eventKey, input.eventId);
if (existing) continue;
const deliveryId = randomUUID();
const scheduledFor = new Date(now.getTime() + Number(message.send_delay_minutes ?? 0) * 60_000);
await (await database.prepare(`INSERT OR IGNORE INTO automated_system_message_deliveries
(id,message_id,user_id,event_key,event_id,scheduled_for,created_at)
VALUES(?,?,?,?,?,?,?)`)).run(
deliveryId, message.id, input.userId, input.eventKey, input.eventId,
scheduledFor.toISOString(), now.toISOString()
);
if (Number(message.send_delay_minutes ?? 0) === 0) {
await deliver(message, deliveryId, input.userId);
}
}
}
+6 -3
View File
@@ -12,6 +12,7 @@ export async function createNotification(input: {
title: string;
body: string;
link?: string | null;
actions?: Array<{ label: string; href: string }>;
mandatory?: boolean;
}) {
const database = await getDatabase();
@@ -26,10 +27,12 @@ export async function createNotification(input: {
const id = randomUUID();
const now = new Date().toISOString();
await (await database.prepare(`INSERT INTO notifications
(id,user_id,category,source,title,body,link,mandatory,created_at)
VALUES(?,?,?,?,?,?,?,?,?)`)).run(
(id,user_id,category,source,title,body,link,actions_json,mandatory,created_at)
VALUES(?,?,?,?,?,?,?,?,?,?)`)).run(
id, input.userId, input.category, input.source, input.title.slice(0, 140),
input.body.slice(0, 2000), input.link ?? null, mandatory ? 1 : 0, now
input.body.slice(0, 2000), input.link ?? null,
input.actions?.length ? JSON.stringify(input.actions.slice(0, 8)) : null,
mandatory ? 1 : 0, now
);
if (emailFrequency === 'immediate') {
const identity = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(input.userId);
+10 -2
View File
@@ -40,6 +40,14 @@
if (notification.link === '/account') return { label: 'OPEN AGENT PROFILE', href: notification.link };
return { label: 'OPEN RELATED PAGE', href: notification.link };
};
const storedActions = (notification: any): Array<{ label: string; href: string }> => {
try {
const parsed = JSON.parse(String(notification.actions_json ?? '[]'));
return Array.isArray(parsed) ? parsed.filter((item) => item?.label && item?.href).slice(0, 8) : [];
} catch {
return [];
}
};
async function enableBrowser() {
if (!('Notification' in window)) { browserPermission = 'unsupported'; return; }
browserPermission = await Notification.requestPermission();
@@ -127,7 +135,7 @@
{#each data.notifications as notification}
<article class:unread={!notification.read_at} class:mandatory={Boolean(notification.mandatory)} class:isac-message={isIsac(notification)} class:positive={tone(notification) === 'positive'} class:negative={tone(notification) === 'negative'}>
<div class="notification-source"><img src={avatar(notification)} alt=""/><span><b>{notification.source}</b><small>{labels[notification.category]}{isIsac(notification) ? ' · SYSTEM' : ' · AGENT'}</small></span></div>
<div><div class="notification-message-header"><div>{#if severity(notification)}<span class="notification-severity" class:error-severity={severity(notification) === 'error'}>{severity(notification)?.toUpperCase()}</span>{/if}<h2>{notification.title}</h2></div><time>{date(notification.created_at)}</time></div>{#if isIsac(notification)}<SystemMessageBody body={notification.body}/>{:else}<p class="notification-body-preview">{notification.body}</p>{/if}<footer><div class="notification-actions"><button type="button" onclick={() => selected = notification}>VIEW DETAILS</button>{#if action(notification)}<a class="notification-response-action" href={action(notification)?.href}>{action(notification)?.label} →</a>{/if}</div>{#if !notification.read_at}<PendingForm action="?/markRead"><input type="hidden" name="id" value={notification.id}/><button>MARK READ</button></PendingForm>{:else}<span>READ</span>{/if}</footer></div>
<div><div class="notification-message-header"><div>{#if severity(notification)}<span class="notification-severity" class:error-severity={severity(notification) === 'error'}>{severity(notification)?.toUpperCase()}</span>{/if}<h2>{notification.title}</h2></div><time>{date(notification.created_at)}</time></div>{#if isIsac(notification)}<SystemMessageBody body={notification.body}/>{:else}<p class="notification-body-preview">{notification.body}</p>{/if}<footer><div class="notification-actions"><button type="button" onclick={() => selected = notification}>VIEW DETAILS</button>{#if storedActions(notification).length}{#each storedActions(notification) as callout}<a class="notification-response-action" href={callout.href}>{callout.label} →</a>{/each}{:else if action(notification)}<a class="notification-response-action" href={action(notification)?.href}>{action(notification)?.label} →</a>{/if}</div>{#if !notification.read_at}<PendingForm action="?/markRead"><input type="hidden" name="id" value={notification.id}/><button>MARK READ</button></PendingForm>{:else}<span>READ</span>{/if}</footer></div>
</article>
{:else}<div class="profile-empty"><span>◇</span><b>NO NOTIFICATIONS</b><p>No messages match the selected channel and state.</p></div>{/each}
</section>
@@ -140,7 +148,7 @@
<div class="notification-detail-state"><span>{labels[selected.category]}</span><b class:error-severity={severity(selected) === 'error'}>{severity(selected)?.toUpperCase() ?? (isIsac(selected) ? 'SYSTEM' : 'AGENT')}</b><time>{date(selected.created_at)}</time></div>
<h2 id="notification-detail-title">{selected.title}</h2>
{#if isIsac(selected)}<SystemMessageBody body={selected.body} expanded/>{:else}<p>{selected.body}</p>{/if}
<div class="form-actions">{#if !selected.read_at}<PendingForm action="?/markRead"><input type="hidden" name="id" value={selected.id}/><button class="secondary">MARK AS READ</button></PendingForm>{/if}<button type="button" class="secondary" onclick={() => selected = null}>CLOSE</button>{#if action(selected)}<a class="primary button" href={action(selected)?.href}>{action(selected)?.label} →</a>{/if}</div>
<div class="form-actions notification-detail-actions">{#if !selected.read_at}<PendingForm action="?/markRead"><input type="hidden" name="id" value={selected.id}/><button class="secondary">MARK AS READ</button></PendingForm>{/if}<button type="button" class="secondary" onclick={() => selected = null}>CLOSE</button>{#if storedActions(selected).length}{#each storedActions(selected) as callout}<a class="primary button" href={callout.href}>{callout.label} →</a>{/each}{:else if action(selected)}<a class="primary button" href={action(selected)?.href}>{action(selected)?.label} →</a>{/if}</div>
</div>
</div>
{/if}
@@ -6,6 +6,7 @@ import { getDatabase } from '$lib/server/database';
import { redirect } from '@sveltejs/kit';
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
import type { RequestHandler } from './$types';
import { triggerAutomatedSystemMessages } from '$lib/server/automated-messages';
const STATE_COOKIE = 'shd_discord_oauth_state';
@@ -120,6 +121,8 @@ export const GET: RequestHandler = async ({ cookies, url, fetch, request, getCli
await insertUser.run(id, username, discordUser.id, 'user', now);
await (await database.prepare('INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)'))
.run(id, discordUser.global_name || discordUser.username, now);
await triggerAutomatedSystemMessages({ eventKey: 'account.created', userId: id, eventId: id })
.catch((error) => console.error('Unable to deliver new-agent welcome message', error));
user = { id };
created = true;
}
+12 -2
View File
@@ -29,10 +29,14 @@
const oversightSections: Array<{ href: string; label: string; code: string; permission: string; reviewKey?: 'data' | 'avatars' }> = [
{ href: '/isac/data-moderation', label: 'Data Oversight', code: 'OVST', permission: 'data.approve', reviewKey: 'data' },
{ href: '/isac/avatar-oversight', label: 'Avatar Oversight', code: 'AVTR', permission: 'avatars.review', reviewKey: 'avatars' },
{ href: '/isac/audit', label: 'Audit history', code: 'AUDT', permission: 'audit.view' },
{ href: '/isac/metrics', label: 'System metrics', code: 'MTRX', permission: 'metrics.view' }
{ href: '/isac/audit', label: 'Audit history', code: 'AUDT', permission: 'audit.view' }
];
const visibleOversightSections = $derived(oversightSections.filter((section) => can(section.permission)));
const systemSections = [
{ href: '/isac/system-messages', label: 'Automated messages', code: 'MSG', permission: 'system.notifications.manage' },
{ href: '/isac/metrics', label: 'System metrics', code: 'MTRX', permission: 'metrics.view' }
];
const visibleSystemSections = $derived(systemSections.filter((section) => can(section.permission)));
const selectNumericValue = (event: FocusEvent) => {
const input = event.target;
if (input instanceof HTMLInputElement && input.type === 'number' && !input.disabled) input.select();
@@ -63,6 +67,12 @@
<a class:active={page.url.pathname.startsWith(section.href)} class:has-pending={reviewCount > 0} href={section.href}><small>{section.code}</small><span>{section.label}</span>{#if reviewCount}<em aria-label={`${reviewCount} item${reviewCount === 1 ? '' : 's'} awaiting review`} title="Awaiting review">{reviewCount}</em>{/if}<b>›</b></a>
{/each}</nav>
{/if}
{#if visibleSystemSections.length}
<div class="sidebar-label section-label"><span>SYSTEM CONFIGURATION</span><b>{String(visibleSystemSections.length).padStart(2, '0')} MODULE{visibleSystemSections.length === 1 ? '' : 'S'}</b></div>
<nav aria-label="System configuration">{#each visibleSystemSections as section}
<a class:active={page.url.pathname.startsWith(section.href)} href={section.href}><small>{section.code}</small><span>{section.label}</span><b>›</b></a>
{/each}</nav>
{/if}
<div class="sidebar-foot"><span>ISAC NODE</span><strong>NYC-01</strong><small>Access policy synchronized</small></div>
</aside>
<section class="admin-content" class:data-readonly={dataReadOnly} onfocusin={selectNumericValue} onsubmit={(event) => {
+1
View File
@@ -9,6 +9,7 @@ export const load: PageServerLoad = async ({ cookies }) => {
if (permissions.includes('users.roles')) redirect(303, '/isac/roles');
if (permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission))) redirect(303, '/isac/configuration');
if (permissions.includes('audit.view')) redirect(303, '/isac/audit');
if (permissions.includes('system.notifications.manage')) redirect(303, '/isac/system-messages');
if (permissions.includes('metrics.view')) redirect(303, '/isac/metrics');
redirect(303, '/account');
};
+2 -1
View File
@@ -72,7 +72,8 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
SUM(CASE WHEN entity_type='game_data' THEN 1 ELSE 0 END) AS game_data,
SUM(CASE WHEN entity_type='data_moderation' THEN 1 ELSE 0 END) AS data_moderation,
SUM(CASE WHEN entity_type IN ('user','user_access','user_moderation','identity') THEN 1 ELSE 0 END) AS users,
SUM(CASE WHEN entity_type IN ('role','role_management') THEN 1 ELSE 0 END) AS access
SUM(CASE WHEN entity_type IN ('role','role_management') THEN 1 ELSE 0 END) AS access,
SUM(CASE WHEN entity_type='system_configuration' THEN 1 ELSE 0 END) AS system_configuration
FROM audit_log`)).get()
]);
const pagination = getPagination(url, Number(total?.total ?? 0));
+1
View File
@@ -35,6 +35,7 @@
{value:'data_moderation',label:'DATA MODERATION',count:data.summary.data_moderation,description:'Proposal lifecycle decisions'},
{value:'user',label:'USER OPERATIONS',count:data.summary.users,description:'Access, moderation, identities'},
{value:'access_policy',label:'ACCESS POLICY',count:data.summary.access,description:'Role definition changes'}
,{value:'system_configuration',label:'SYSTEM CONFIG',count:data.summary.system_configuration,description:'Automated messages and options'}
]}/>
<form class="audit-filter">
<input name="q" value={data.filters.query} placeholder="ACTOR, TARGET ID, OR CHANGED VALUE"/>
+3 -2
View File
@@ -7,7 +7,7 @@ import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { recordIsacAudit } from '$lib/server/audit';
const filters = ['isac', 'data', 'users', 'audit'];
const filters = ['isac', 'data', 'users', 'audit', 'system'];
export const load: PageServerLoad = async ({ cookies, url }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'users.roles');
@@ -16,7 +16,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
const database = await getDatabase();
const filter = filters.includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
const { search, pattern } = getRegistrySearch(url);
const permissionPrefix = filter === 'isac' ? 'isac.%' : filter === 'data' ? 'data.%' : filter === 'users' ? 'users.%' : filter === 'audit' ? 'audit.%' : '';
const permissionPrefix = filter === 'isac' ? 'isac.%' : filter === 'data' ? 'data.%' : filter === 'users' ? 'users.%' : filter === 'audit' ? 'audit.%' : filter === 'system' ? 'system.%' : '';
const conditions = [
...(permissionPrefix ? [`EXISTS (
SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id
@@ -32,6 +32,7 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'data.%') THEN 1 ELSE 0 END) AS data,
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'users.%') THEN 1 ELSE 0 END) AS users,
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'audit.%') THEN 1 ELSE 0 END) AS audit
,SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'system.%') THEN 1 ELSE 0 END) AS system
FROM roles`)).get(),
(await database.prepare(`SELECT COUNT(*) AS total FROM roles ${where}`)).get(...filterArgs),
(await database.prepare(`SELECT id,name,description FROM permissions
+2 -1
View File
@@ -23,7 +23,8 @@
{value:'isac',label:'ISAC ACCESS',count:data.summary.isac,description:'Can open the console'},
{value:'data',label:'DATA OPERATIONS',count:data.summary.data,description:'Builder-data capabilities'},
{value:'users',label:'USER OPERATIONS',count:data.summary.users,description:'User-management capabilities'},
{value:'audit',label:'SYSTEM OVERSIGHT',count:data.summary.audit,description:'Audit-history visibility'}
{value:'audit',label:'SYSTEM OVERSIGHT',count:data.summary.audit,description:'Audit-history visibility'},
{value:'system',label:'SYSTEM CONFIG',count:data.summary.system,description:'Automated system capabilities'}
]}/>
{#if editing}
@@ -0,0 +1,78 @@
import { fail } from '@sveltejs/kit';
import { getSessionUser } from '$lib/server/auth/session';
import { requirePermission } from '$lib/server/auth/authorization';
import { getDatabase } from '$lib/server/database';
import { recordIsacAudit } from '$lib/server/audit';
import { AUTOMATED_MESSAGE_EVENTS } from '$lib/server/automated-messages';
import type { Actions, PageServerLoad } from './$types';
const parseActions = (value: unknown) => {
try {
const parsed = JSON.parse(String(value ?? '[]'));
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
};
export const load: PageServerLoad = async ({ cookies }) => {
await requirePermission(await getSessionUser(cookies), 'system.notifications.manage');
const database = await getDatabase();
const messages = await (await database.prepare(`SELECT message.*,
(SELECT COUNT(*) FROM automated_system_message_deliveries delivery WHERE delivery.message_id=message.id) AS delivery_count,
(SELECT MAX(delivered_at) FROM automated_system_message_deliveries delivery WHERE delivery.message_id=message.id) AS last_delivered_at
FROM automated_system_messages message ORDER BY name`)).all();
return {
events: AUTOMATED_MESSAGE_EVENTS,
messages: messages.map((message) => ({ ...message, actions: parseActions(message.actions_json) }))
};
};
export const actions: Actions = {
save: async ({ cookies, request }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'system.notifications.manage');
const form = await request.formData();
const id = String(form.get('id') ?? '').trim();
const source = String(form.get('source') ?? '').normalize('NFKC').trim();
const title = String(form.get('title') ?? '').normalize('NFKC').trim();
const body = String(form.get('body') ?? '').normalize('NFKC').trim();
const link = String(form.get('link') ?? '').trim();
const delay = Number(form.get('sendDelayMinutes') ?? 0);
const enabled = form.get('enabled') === 'on';
if (!id) return fail(400, { messageError: 'Choose an automated message to edit.' });
if (source.length < 2 || source.length > 80) return fail(400, { messageError: 'Source must be 2–80 characters.' });
if (title.length < 2 || title.length > 140) return fail(400, { messageError: 'Title must be 2–140 characters.' });
if (body.length < 20 || body.length > 2000) return fail(400, { messageError: 'Message content must be 20–2,000 characters.' });
if (!Number.isInteger(delay) || delay < 0 || delay > 10080) return fail(400, { messageError: 'Delay must be between 0 and 10,080 minutes.' });
const validHref = (href: string) => href.startsWith('/') || /^https:\/\/[^\s]+$/i.test(href);
if (link && !validHref(link)) return fail(400, { messageError: 'Primary destinations must use an internal path or secure HTTPS URL.' });
const actions = form.getAll('actionLabel').map((label, index) => ({
label: String(label).normalize('NFKC').trim(),
href: String(form.getAll('actionHref')[index] ?? '').trim()
})).filter((action) => action.label || action.href);
if (actions.length > 8) return fail(400, { messageError: 'Use no more than eight action callouts.' });
if (actions.some((action) => action.label.length < 2 || action.label.length > 60 || !validHref(action.href))) {
return fail(400, { messageError: 'Each action needs a 2–60 character label and a valid internal or HTTPS destination.' });
}
const database = await getDatabase();
const before = await (await database.prepare('SELECT * FROM automated_system_messages WHERE id=?')).get(id);
if (!before) return fail(404, { messageError: 'Automated message not found.' });
const now = new Date().toISOString();
const after = {
...before, source, title, body, link: link || null, actions_json: JSON.stringify(actions),
enabled: enabled ? 1 : 0, send_delay_minutes: delay, updated_at: now, updated_by: operator.id
};
await (await database.prepare(`UPDATE automated_system_messages SET
enabled=?,send_delay_minutes=?,source=?,title=?,body=?,link=?,actions_json=?,updated_at=?,updated_by=? WHERE id=?`))
.run(enabled ? 1 : 0, delay, source, title, body, link || null, JSON.stringify(actions), now, operator.id, id);
await recordIsacAudit({
actorId: operator.id,
category: 'system_configuration',
action: 'automated_message.updated',
entityId: id,
before,
after
});
return { messageSaved: true, editorMode: 'edit', resultToken: crypto.randomUUID() };
}
};
@@ -0,0 +1,50 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
let { data, form } = $props();
let editing = $state<any>(null);
let consumedToken = $state<string | null>(null);
const date = (value: unknown) => value ? new Date(String(value)).toLocaleString() : 'NEVER';
const edit = (message: any) => editing = { ...message, actions: [...message.actions, ...Array(Math.max(0, 8 - message.actions.length)).fill(null).map(() => ({ label: '', href: '' }))] };
$effect(() => {
if (form?.messageSaved && form?.resultToken && form.resultToken !== consumedToken) {
consumedToken = form.resultToken;
editing = null;
}
});
</script>
<svelte:head><title>Automated system messages — ISAC</title></svelte:head>
<div class="module-toolbar"><div><p class="eyebrow">SYSTEM CONFIGURATION</p><h2>AUTOMATED MESSAGES</h2><p>Control event-driven ISAC notifications, delivery timing, content, and action callouts.</p></div></div>
<div class="module-kpis"><div><span>DEFINITIONS</span><strong>{data.messages.length}</strong><small>Automated messages</small></div><div><span>ACTIVE</span><strong>{data.messages.filter((item: any) => item.enabled).length}</strong><small>Enabled triggers</small></div><div><span>EVENT TYPES</span><strong>{data.events.length}</strong><small>System lifecycle events</small></div></div>
{#if editing}
<section class="horizontal-editor system-message-editor">
<div class="editor-title"><div><p class="eyebrow">EDIT AUTOMATED MESSAGE</p><h3>{editing.name}</h3></div><button type="button" aria-label="Cancel editing automated message" onclick={() => editing = null}>×</button></div>
<PendingForm action="?/save">
<input type="hidden" name="id" value={editing.id}/>
<div class="system-message-editor-grid">
<label class="field wide">MESSAGE SOURCE<input name="source" value={editing.source} maxlength="80" required/></label>
<label class="field wide">TITLE<input name="title" value={editing.title} maxlength="140" required/></label>
<label class="field event-field">SEND EVENT<input value={data.events.find((event: any) => event.value === editing.event_key)?.label ?? editing.event_key} disabled/><small>{data.events.find((event: any) => event.value === editing.event_key)?.description}</small></label>
<label class="field">DELAY AFTER EVENT · MINUTES<input name="sendDelayMinutes" type="number" min="0" max="10080" step="1" value={editing.send_delay_minutes}/><small>0 sends immediately · maximum 7 days</small></label>
<label class="field system-message-enabled"><span>DELIVERY STATE</span><input name="enabled" type="checkbox" bind:checked={editing.enabled}/><b>{editing.enabled ? 'ENABLED' : 'DISABLED'}</b></label>
<label class="field full">MESSAGE CONTENT<textarea name="body" rows="7" maxlength="2000" required>{editing.body}</textarea></label>
<label class="field full">PRIMARY DESTINATION<input name="link" value={editing.link ?? ''} placeholder="/account"/></label>
</div>
<div class="system-message-actions"><div><span>ACTION CALLOUTS</span><small>Optional internal paths or secure external links · up to 8</small></div>
{#each editing.actions as action, index}
<div><b>{String(index + 1).padStart(2,'0')}</b><input name="actionLabel" value={action.label} placeholder="ACTION LABEL"/><input name="actionHref" value={action.href} placeholder="/destination or https://…"/></div>
{/each}
</div>
{#if form?.messageError}<p class="error">{form.messageError}</p>{/if}
<div class="form-actions"><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary">SAVE MESSAGE →</button></div>
</PendingForm>
</section>
{/if}
<section class="dashboard-table system-message-table">
<div class="dashboard-row heading"><span>MESSAGE</span><span>TRIGGER</span><span>DELIVERY</span><span>ACTIVITY</span><span></span></div>
{#each data.messages as message}
<div class="dashboard-row"><div><strong>{message.name}</strong><small>{message.description}</small></div><div><strong>{data.events.find((event: any) => event.value === message.event_key)?.label ?? message.event_key}</strong><small>{message.enabled ? 'ENABLED' : 'DISABLED'}</small></div><div><strong>{Number(message.send_delay_minutes) ? `${message.send_delay_minutes} MIN DELAY` : 'IMMEDIATE'}</strong><small>{message.title}</small></div><div><strong>{message.delivery_count} SENT</strong><small>LAST · {date(message.last_delivered_at)}</small></div><button class="table-action" onclick={() => edit(message)}>EDIT →</button></div>
{/each}
</section>
+5
View File
@@ -7,6 +7,7 @@ import { emailDeliveryConfigured, sendAuthenticationLink } from '$lib/server/aut
import { fail, redirect } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { triggerAutomatedSystemMessages } from '$lib/server/automated-messages';
export const load: PageServerLoad = async ({ url }) => ({
emailDeliveryConfigured: emailDeliveryConfigured(),
@@ -42,6 +43,8 @@ export const actions: Actions = {
.run(id, `agent_${id.replaceAll('-', '').slice(0, 16)}`, 'user', now);
await (await database.prepare('INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)'))
.run(id, 'New Agent', now);
await triggerAutomatedSystemMessages({ eventKey: 'account.created', userId: id, eventId: id })
.catch((error) => console.error('Unable to deliver new-agent welcome message', error));
identity = { user_id: id, created_at: null };
purpose = 'verify_email';
}
@@ -83,6 +86,8 @@ export const actions: Actions = {
await (await database.prepare('INSERT INTO user_profiles(user_id,display_name,updated_at) VALUES(?,?,?)'))
.run(id, displayName, now);
await ensureBootstrapAdmin(database, id);
await triggerAutomatedSystemMessages({ eventKey: 'account.created', userId: id, eventId: id })
.catch((error) => console.error('Unable to deliver new-agent welcome message', error));
const sessionId = await createSession(cookies, id);
await recordLogin(id, 'password', true, { request, getClientAddress, sessionId });
redirect(303, '/account');