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
+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');