Add automated ISAC system notifications
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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');
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user