diff --git a/database/schema.sql b/database/schema.sql index ba88760..e0eaeb6 100644 --- a/database/schema.sql +++ b/database/schema.sql @@ -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'); diff --git a/docs/DESIGN_SYSTEM.md b/docs/DESIGN_SYSTEM.md index 1b0d5f8..c52a934 100644 --- a/docs/DESIGN_SYSTEM.md +++ b/docs/DESIGN_SYSTEM.md @@ -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 diff --git a/docs/architecture.md b/docs/architecture.md index f2b5357..9e78977 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/package.json b/package.json index 77aaf5e..8a97a9e 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs index 246d536..e36ca5c 100644 --- a/scripts/db-migrate.mjs +++ b/scripts/db-migrate.mjs @@ -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(); diff --git a/scripts/system-notification-dispatch.mjs b/scripts/system-notification-dispatch.mjs new file mode 100644 index 0000000..5577676 --- /dev/null +++ b/scripts/system-notification-dispatch.mjs @@ -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) => ({ + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' +})[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: `

${escapeHtml(message.source)}

${escapeHtml(message.title)}

${escapeHtml(message.body)}

${message.link ? `OPEN SHD.BUILD` : ''}
` + }); + 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'}.`); diff --git a/src/app.css b/src/app.css index a77e460..3c00eca 100644 --- a/src/app.css +++ b/src/app.css @@ -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}} diff --git a/src/lib/server/automated-messages.ts b/src/lib/server/automated-messages.ts new file mode 100644 index 0000000..7ea14d0 --- /dev/null +++ b/src/lib/server/automated-messages.ts @@ -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; + +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); + } + } +} diff --git a/src/lib/server/notifications.ts b/src/lib/server/notifications.ts index e1f4a03..9183096 100644 --- a/src/lib/server/notifications.ts +++ b/src/lib/server/notifications.ts @@ -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); diff --git a/src/routes/account/notifications/+page.svelte b/src/routes/account/notifications/+page.svelte index 5bfb402..b723715 100644 --- a/src/routes/account/notifications/+page.svelte +++ b/src/routes/account/notifications/+page.svelte @@ -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}
{notification.source}{labels[notification.category]}{isIsac(notification) ? ' · SYSTEM' : ' · AGENT'}
-
{#if severity(notification)}{severity(notification)?.toUpperCase()}{/if}

{notification.title}

{#if isIsac(notification)}{:else}

{notification.body}

{/if}
+
{#if severity(notification)}{severity(notification)?.toUpperCase()}{/if}

{notification.title}

{#if isIsac(notification)}{:else}

{notification.body}

{/if}
{#if storedActions(notification).length}{#each storedActions(notification) as callout}{callout.label} →{/each}{:else if action(notification)}{action(notification)?.label} →{/if}
{#if !notification.read_at}{:else}READ{/if}
{:else}
◇NO NOTIFICATIONS

No messages match the selected channel and state.

{/each} @@ -140,7 +148,7 @@
{labels[selected.category]}{severity(selected)?.toUpperCase() ?? (isIsac(selected) ? 'SYSTEM' : 'AGENT')}

{selected.title}

{#if isIsac(selected)}{:else}

{selected.body}

{/if} -
{#if !selected.read_at}{/if}{#if action(selected)}{action(selected)?.label} →{/if}
+
{#if !selected.read_at}{/if}{#if storedActions(selected).length}{#each storedActions(selected) as callout}{callout.label} →{/each}{:else if action(selected)}{action(selected)?.label} →{/if}
{/if} diff --git a/src/routes/auth/discord/callback/+server.ts b/src/routes/auth/discord/callback/+server.ts index c454efe..12510ef 100644 --- a/src/routes/auth/discord/callback/+server.ts +++ b/src/routes/auth/discord/callback/+server.ts @@ -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; } diff --git a/src/routes/isac/+layout.svelte b/src/routes/isac/+layout.svelte index 447a455..71a1966 100644 --- a/src/routes/isac/+layout.svelte +++ b/src/routes/isac/+layout.svelte @@ -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 @@ 0} href={section.href}>{section.code}{section.label}{#if reviewCount}{reviewCount}{/if}› {/each} {/if} + {#if visibleSystemSections.length} + + + {/if}
{ diff --git a/src/routes/isac/+page.server.ts b/src/routes/isac/+page.server.ts index a233315..114449c 100644 --- a/src/routes/isac/+page.server.ts +++ b/src/routes/isac/+page.server.ts @@ -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'); }; diff --git a/src/routes/isac/audit/+page.server.ts b/src/routes/isac/audit/+page.server.ts index 4e4235b..7837704 100644 --- a/src/routes/isac/audit/+page.server.ts +++ b/src/routes/isac/audit/+page.server.ts @@ -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)); diff --git a/src/routes/isac/audit/+page.svelte b/src/routes/isac/audit/+page.svelte index 2723a87..d88ec89 100644 --- a/src/routes/isac/audit/+page.svelte +++ b/src/routes/isac/audit/+page.svelte @@ -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'} ]}/>
diff --git a/src/routes/isac/roles/+page.server.ts b/src/routes/isac/roles/+page.server.ts index f34810c..6e1cfa2 100644 --- a/src/routes/isac/roles/+page.server.ts +++ b/src/routes/isac/roles/+page.server.ts @@ -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 diff --git a/src/routes/isac/roles/+page.svelte b/src/routes/isac/roles/+page.svelte index edd827f..b26f988 100644 --- a/src/routes/isac/roles/+page.svelte +++ b/src/routes/isac/roles/+page.svelte @@ -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} diff --git a/src/routes/isac/system-messages/+page.server.ts b/src/routes/isac/system-messages/+page.server.ts new file mode 100644 index 0000000..4f75aa4 --- /dev/null +++ b/src/routes/isac/system-messages/+page.server.ts @@ -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() }; + } +}; diff --git a/src/routes/isac/system-messages/+page.svelte b/src/routes/isac/system-messages/+page.svelte new file mode 100644 index 0000000..23623db --- /dev/null +++ b/src/routes/isac/system-messages/+page.svelte @@ -0,0 +1,50 @@ + + +Automated system messages — ISAC +

SYSTEM CONFIGURATION

AUTOMATED MESSAGES

Control event-driven ISAC notifications, delivery timing, content, and action callouts.

+
DEFINITIONS{data.messages.length}Automated messages
ACTIVE{data.messages.filter((item: any) => item.enabled).length}Enabled triggers
EVENT TYPES{data.events.length}System lifecycle events
+ +{#if editing} +
+

EDIT AUTOMATED MESSAGE

{editing.name}

+ + +
+ + + + + + + +
+
ACTION CALLOUTSOptional internal paths or secure external links · up to 8
+ {#each editing.actions as action, index} +
{String(index + 1).padStart(2,'0')}
+ {/each} +
+ {#if form?.messageError}

{form.messageError}

{/if} +
+
+
+{/if} + +
+
MESSAGETRIGGERDELIVERYACTIVITY
+ {#each data.messages as message} +
{message.name}{message.description}
{data.events.find((event: any) => event.value === message.event_key)?.label ?? message.event_key}{message.enabled ? 'ENABLED' : 'DISABLED'}
{Number(message.send_delay_minutes) ? `${message.send_delay_minutes} MIN DELAY` : 'IMMEDIATE'}{message.title}
{message.delivery_count} SENTLAST · {date(message.last_delivered_at)}
+ {/each} +
diff --git a/src/routes/sign-in/+page.server.ts b/src/routes/sign-in/+page.server.ts index c5591ff..eb56fe7 100644 --- a/src/routes/sign-in/+page.server.ts +++ b/src/routes/sign-in/+page.server.ts @@ -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');