Rename admin routes to ISAC

This commit is contained in:
JB
2026-07-25 20:55:13 -04:00
parent 0425fede8e
commit 67db106fba
38 changed files with 46 additions and 38 deletions
@@ -0,0 +1,137 @@
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import { getSessionUser } from '$lib/server/auth/session';
import { requirePermission } from '$lib/server/auth/authorization';
import { getDatabase } from '$lib/server/database';
import { deserializeProposal, refreshProposalDependencies } from '$lib/server/data-moderation';
import { getPagination } from '$lib/server/pagination';
import { recordIsacAudit } from '$lib/server/audit';
import type { Actions, PageServerLoad } from './$types';
import { createNotification } from '$lib/server/notifications';
const statuses = ['pending', 'approved', 'rejected', 'failed'];
const modules = ['attributes', 'talents', 'brands', 'gear', 'weapons', 'protocols', 'skillchips'];
type SerializedEntry =
| { name: string; kind: 'text'; value: string }
| { name: string; kind: 'file'; value: string; fileName: string; type: string };
export const load: PageServerLoad = async ({ cookies, url }) => {
await requirePermission(await getSessionUser(cookies), 'data.approve');
const database = await getDatabase();
const status = statuses.includes(url.searchParams.get('status') ?? '') ? String(url.searchParams.get('status')) : 'pending';
const module = modules.includes(url.searchParams.get('module') ?? '') ? String(url.searchParams.get('module')) : '';
const search = (url.searchParams.get('search') ?? '').trim().slice(0, 100);
const conditions = ['p.status=?'];
const args: unknown[] = [status];
if (module) { conditions.push('p.module=?'); args.push(module); }
if (search) { conditions.push("(p.summary LIKE ? ESCAPE '\\' COLLATE NOCASE OR proposer.username LIKE ? ESCAPE '\\' COLLATE NOCASE OR profile.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE)"); const value = `%${search.replaceAll('\\','\\\\').replaceAll('%','\\%').replaceAll('_','\\_')}%`; args.push(value, value, value); }
const where = `WHERE ${conditions.join(' AND ')}`;
const total = Number((await (await database.prepare(`SELECT COUNT(*) AS total FROM data_change_proposals p JOIN users proposer ON proposer.id=p.proposed_by LEFT JOIN user_profiles profile ON profile.user_id=proposer.id ${where}`)).get(...args))?.total ?? 0);
const pagination = getPagination(url, total);
const proposals = await (await database.prepare(`SELECT p.*,COALESCE(profile.display_name,proposer.username) AS proposer_name,
COALESCE(reviewer_profile.display_name,reviewer.username) AS reviewer_name
FROM data_change_proposals p
JOIN users proposer ON proposer.id=p.proposed_by
LEFT JOIN user_profiles profile ON profile.user_id=proposer.id
LEFT JOIN users reviewer ON reviewer.id=p.reviewed_by
LEFT JOIN user_profiles reviewer_profile ON reviewer_profile.user_id=reviewer.id
${where} ORDER BY p.created_at DESC LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset);
const summaryRows = await (await database.prepare('SELECT status,COUNT(*) AS count FROM data_change_proposals GROUP BY status')).all();
return { proposals, pagination, status, module, search, summary: Object.fromEntries(summaryRows.map((row) => [String(row.status), Number(row.count)])), modules };
};
export const actions: Actions = {
correct: async ({ cookies, request }) => {
const reviewer = await requirePermission(await getSessionUser(cookies), 'data.approve');
const form = await request.formData();
const id = String(form.get('id') ?? '');
let corrections: Array<{ index: number; value: string }>;
try {
const parsed = JSON.parse(String(form.get('corrections') ?? '[]'));
if (!Array.isArray(parsed)) throw new Error();
corrections = parsed.map((item) => ({ index: Number(item.index), value: String(item.value ?? '') }));
} catch {
return fail(400, { moderationError: 'The proposed corrections could not be read.' });
}
if (corrections.length > 250 || corrections.some((item) => !Number.isInteger(item.index) || item.index < 0 || item.value.length > 100_000)) {
return fail(400, { moderationError: 'One or more corrected fields exceed the safe proposal limits.' });
}
const database = await getDatabase();
const proposal = await (await database.prepare("SELECT * FROM data_change_proposals WHERE id=? AND status='pending'")).get(id);
if (!proposal) return fail(404, { moderationError: 'That pending proposal no longer exists.' });
const entries = JSON.parse(String(proposal.payload_json)) as SerializedEntry[];
const changes: Array<{ index: number; name: string; before: string; after: string }> = [];
for (const correction of corrections) {
const entry = entries[correction.index];
if (!entry || entry.kind !== 'text') return fail(400, { moderationError: 'Only existing text and numeric proposal fields may be corrected.' });
if (entry.value !== correction.value) {
changes.push({ index: correction.index, name: entry.name, before: entry.value, after: correction.value });
entry.value = correction.value;
}
}
if (!changes.length) return fail(400, { moderationError: 'No proposal fields were changed.' });
const payload = JSON.stringify(entries);
const oldDependencies = (await (await database.prepare('SELECT depends_on_proposal_id FROM data_change_proposal_dependencies WHERE proposal_id=? ORDER BY depends_on_proposal_id')).all(id)).map((row) => String(row.depends_on_proposal_id));
const name = entries.find((entry) => entry.kind === 'text' && entry.name === 'name')?.value.trim();
const summary = `${String(proposal.action).replace(/([A-Z])/g, ' $1').toUpperCase()} · ${name || proposal.entity_id || 'NEW RECORD'}`;
await (await database.prepare('UPDATE data_change_proposals SET payload_json=?,summary=? WHERE id=?')).run(payload, summary, id);
const dependencies = await refreshProposalDependencies(id, payload);
await recordIsacAudit({
actorId: reviewer.id,
category: 'data_moderation',
action: 'proposal.corrected',
entityId: id,
before: { summary: proposal.summary, dependencies: oldDependencies, fields: changes.map((change) => ({ index: change.index, name: change.name, value: change.before })) },
after: { summary, dependencies, fields: changes.map((change) => ({ index: change.index, name: change.name, value: change.after })) }
});
return { moderationSaved: true, moderationToken: randomUUID() };
},
approve: async ({ cookies, request, fetch, url }) => {
const reviewer = await requirePermission(await getSessionUser(cookies), 'data.approve');
const form = await request.formData();
const id = String(form.get('id') ?? '');
const note = String(form.get('note') ?? '').trim().slice(0, 500);
const database = await getDatabase();
const proposal = await (await database.prepare("SELECT * FROM data_change_proposals WHERE id=? AND status='pending'")).get(id);
if (!proposal) return fail(404, { moderationError: 'That pending proposal no longer exists.' });
const unresolved = await (await database.prepare(`SELECT dependency.id,dependency.summary,dependency.status
FROM data_change_proposal_dependencies link
JOIN data_change_proposals dependency ON dependency.id=link.depends_on_proposal_id
WHERE link.proposal_id=? AND dependency.status<>'approved'`)).all(id);
if (unresolved.length) {
const names = unresolved.map((item) => `${item.summary} (${item.status})`).join(', ');
return fail(409, { moderationError: `Approve this proposal's dependencies first: ${names}.` });
}
const target = new URL(`/isac/${proposal.module}?/${proposal.action}`, url.origin);
const proposalForm = deserializeProposal(String(proposal.payload_json));
if (proposal.draft_entity_id && !proposal.entity_id) proposalForm.set('_proposedEntityId', String(proposal.draft_entity_id));
const response = await fetch(target, { method: 'POST', body: proposalForm, headers: { origin: url.origin } });
const now = new Date().toISOString();
if (!response.ok) {
const details = (await response.text()).slice(0, 1000);
await (await database.prepare("UPDATE data_change_proposals SET status='failed',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=?")).run(reviewer.id, details || note || 'Canonical validation failed.', now, id);
await recordIsacAudit({ actorId: reviewer.id, category: 'data_moderation', action: 'proposal.failed', entityId: id, before: { status: 'pending' }, after: { status: 'failed', responseStatus: response.status } });
await createNotification({ userId: String(proposal.proposed_by), category: 'contributions', source: 'ISAC DATA OVERSIGHT', title: 'Contribution could not be deployed', body: `${proposal.summary} did not pass current validation. Oversight evidence is available in your notification inbox.`, link: `/isac/${proposal.module}`, mandatory: true });
return fail(409, { moderationError: 'The proposal did not pass the current canonical validation and was marked failed.' });
}
await (await database.prepare("UPDATE data_change_proposals SET status='approved',applied_entity_id=COALESCE(entity_id,draft_entity_id),reviewed_by=?,review_note=?,reviewed_at=?,applied_at=? WHERE id=?")).run(reviewer.id, note || null, now, now, id);
await recordIsacAudit({ actorId: reviewer.id, category: 'data_moderation', action: 'proposal.approved', entityId: id, before: { status: 'pending' }, after: { status: 'approved', module: proposal.module, action: proposal.action, proposedBy: proposal.proposed_by, appliedEntityId: proposal.entity_id ?? proposal.draft_entity_id } });
await createNotification({ userId: String(proposal.proposed_by), category: 'contributions', source: 'ISAC DATA OVERSIGHT', title: 'Contribution approved', body: `${proposal.summary} was approved and deployed to the live builder data set.`, link: `/isac/${proposal.module}`, mandatory: true });
return { moderationSaved: true, moderationToken: randomUUID() };
},
reject: async ({ cookies, request }) => {
const reviewer = await requirePermission(await getSessionUser(cookies), 'data.approve');
const form = await request.formData();
const id = String(form.get('id') ?? '');
const note = String(form.get('note') ?? '').trim().slice(0, 500);
if (note.length < 3) return fail(400, { moderationError: 'Add a brief reason for rejecting this proposal.' });
const database = await getDatabase();
const proposal = await (await database.prepare("SELECT module,action,proposed_by FROM data_change_proposals WHERE id=? AND status='pending'")).get(id);
if (!proposal) return fail(404, { moderationError: 'That pending proposal no longer exists.' });
const now = new Date().toISOString();
await (await database.prepare("UPDATE data_change_proposals SET status='rejected',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=?")).run(reviewer.id, note, now, id);
await recordIsacAudit({ actorId: reviewer.id, category: 'data_moderation', action: 'proposal.rejected', entityId: id, before: { status: 'pending' }, after: { status: 'rejected', note, module: proposal.module, action: proposal.action, proposedBy: proposal.proposed_by } });
await createNotification({ userId: String(proposal.proposed_by), category: 'contributions', source: 'ISAC DATA OVERSIGHT', title: 'Contribution needs revision', body: `${String(proposal.action).replace(/([A-Z])/g, ' $1').trim()} was not approved. Oversight reason: ${note}`, link: `/isac/${proposal.module}`, mandatory: true });
return { moderationSaved: true, moderationToken: randomUUID() };
}
};
@@ -0,0 +1,110 @@
<script lang="ts">
import PendingForm from '$lib/components/PendingForm.svelte';
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
import KpiFilters from '$lib/components/KpiFilters.svelte';
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
let { data, form } = $props();
let reviewing = $state<any>(null);
let decision = $state<'approve' | 'correct' | 'reject'>('approve');
let correctionEntries = $state<Array<{ index: number; name: string; value: string; kind: string; fileName?: string }>>([]);
let handledToken = $state('');
const date = (value: string) => new Date(value).toLocaleString();
const evidence = (payload: string) => (JSON.parse(payload) as any[]).map((entry) =>
entry.kind === 'file' ? { name: entry.name, kind: 'file', fileName: entry.fileName, type: entry.type, encodedBytes: entry.value.length } : entry
);
const operationLabel = (value: string) => value.replace(/([A-Z])/g, ' $1').replaceAll('_', ' ').trim().toUpperCase();
const moduleLabel = (value: string) => value === 'skillchips' ? 'SKILL CHIPS' : value.toUpperCase();
const moduleOptions = $derived(data.modules.map((module) => ({ id: String(module), name: moduleLabel(String(module)) })));
const openReview = (proposal: any) => {
reviewing = proposal;
decision = 'approve';
correctionEntries = (JSON.parse(proposal.payload_json) as any[]).map((entry, index) => ({
index, name: entry.name, value: entry.kind === 'text' ? entry.value : '', kind: entry.kind, fileName: entry.fileName
}));
};
const correctionPayload = () => JSON.stringify(correctionEntries.filter((entry) => entry.kind === 'text').map(({ index, value }) => ({ index, value })));
const longField = (entry: { name: string; value: string }) => entry.value.length > 80 || ['description', 'descriptionTemplate', 'effects', 'tiers', 'augmentEffects', 'baseBonuses'].includes(entry.name) || entry.value.startsWith('[') || entry.value.startsWith('{');
$effect(() => {
if (form?.moderationSaved && form.moderationToken && form.moderationToken !== handledToken) {
handledToken = form.moderationToken;
reviewing = null;
}
});
</script>
<svelte:head><title>Data Oversight — ISAC</title></svelte:head>
<div class="module-toolbar moderation-toolbar">
<div><p class="eyebrow">DATA OVERSIGHT / CONTRIBUTION CONTROL</p><h2>DATA OVERSIGHT</h2><p>Review, correct, and decide contributor intelligence before deployment to the live builder registry.</p></div>
<div class="queue-readout"><span>PENDING REVIEW</span><strong>{data.summary.pending ?? 0}</strong><small>CHANGE REQUESTS</small></div>
</div>
<KpiFilters parameter="status" active={data.status} items={[
{value:'pending',label:'PENDING',count:data.summary.pending ?? 0,description:'Awaiting operator decision'},
{value:'approved',label:'APPROVED',count:data.summary.approved ?? 0,description:'Applied to live data'},
{value:'rejected',label:'REJECTED',count:data.summary.rejected ?? 0,description:'Declined by oversight'},
{value:'failed',label:'FAILED',count:data.summary.failed ?? 0,description:'Blocked by validation'}
]}/>
<form class="audit-filter moderation-filter">
<input name="search" value={data.search} placeholder="CHANGE, RECORD, OR CONTRIBUTOR"/>
<SearchableAssignment items={moduleOptions} mode="single" compact fieldName="module" value={data.module} subjectLabel="DATA MODULE" searchPlaceholder="Search data modules…" emptySelectionLabel="ALL DATA MODULES"/>
<input type="hidden" name="status" value={data.status}/>
<button class="secondary">SEARCH QUEUE →</button>
</form>
<RegistryPagination pagination={data.pagination}/>
<div class="dashboard-table moderation-queue">
<div class="dashboard-row moderation-row table-heading"><span>CHANGE</span><span>CONTRIBUTOR</span><span>SUBMITTED</span><span>STATUS</span><span></span></div>
{#each data.proposals as proposal}
<div class="dashboard-row moderation-row mobile-detail-row">
<div class="change-identity"><span>{proposal.module === 'skillchips' ? 'CHIP' : proposal.module.slice(0,4).toUpperCase()}</span><div><strong>{proposal.summary}</strong><small>{moduleLabel(proposal.module)} / {operationLabel(proposal.action)}</small></div></div>
<div><strong>{proposal.proposer_name}</strong><small>AGENT {String(proposal.proposed_by).slice(0,8).toUpperCase()}</small></div>
<span>{date(proposal.created_at)}</span>
<span class="proposal-state" class:pending={proposal.status === 'pending'} class:approved={proposal.status === 'approved'} class:declined={proposal.status === 'rejected' || proposal.status === 'failed'}><i></i>{proposal.status.toUpperCase()}</span>
{#if proposal.status === 'pending'}<button onclick={() => openReview(proposal)}>REVIEW →</button>{:else}<button onclick={() => openReview(proposal)}>EVIDENCE →</button>{/if}
<button class="mobile-row-open" aria-label={`${proposal.status === 'pending' ? 'Review' : 'View evidence for'} ${proposal.summary}`} onclick={() => openReview(proposal)}></button>
</div>
{:else}<div class="empty-dashboard"><strong>NO {data.status.toUpperCase()} CHANGE REQUESTS</strong><span>Adjust the module or search filters to inspect another part of the queue.</span></div>{/each}
</div>
{#if reviewing}
<div class="isac-modal-backdrop" role="presentation">
<div class="isac-confirm-modal moderation-review" role="dialog" aria-modal="true" aria-labelledby="moderation-title">
<div class="editor-title"><div><p class="eyebrow">CHANGE CONTROL / {reviewing.status.toUpperCase()}</p><h3 id="moderation-title">{reviewing.summary}</h3></div><button aria-label="Close review" onclick={() => reviewing = null}>×</button></div>
<div class="moderation-metadata"><span>DATA MODULE <b>{moduleLabel(reviewing.module)}</b></span><span>REQUESTED ACTION <b>{operationLabel(reviewing.action)}</b></span><span>CONTRIBUTOR <b>{reviewing.proposer_name}</b></span><span>SUBMITTED <b>{date(reviewing.created_at)}</b></span></div>
<div class="evidence-header"><div><span>PROPOSED FORM PAYLOAD</span><small>CANONICAL VALIDATION RUNS WHEN APPROVED</small></div><b>{evidence(reviewing.payload_json).length} FIELDS</b></div>
<pre class="moderation-evidence">{JSON.stringify(evidence(reviewing.payload_json), null, 2)}</pre>
{#if reviewing.review_note}<div class="review-note"><span>OVERSIGHT NOTE</span><p>{reviewing.review_note}</p></div>{/if}
{#if reviewing.status === 'pending'}
<div class="decision-tabs oversight-decision-tabs"><button class:active={decision === 'approve'} onclick={() => decision = 'approve'}><b>✓</b><span>APPROVE<small>APPLY TO LIVE DATA</small></span></button><button class:active={decision === 'correct'} onclick={() => decision = 'correct'}><b>✎</b><span>CORRECT<small>FIX MINOR ERRORS</small></span></button><button class:active={decision === 'reject'} onclick={() => decision = 'reject'}><b>×</b><span>REJECT<small>RETURN FOR REVISION</small></span></button></div>
{#if decision === 'correct'}
<PendingForm action="?/correct">
{#snippet children(pending)}
<input type="hidden" name="id" value={reviewing.id}/><input type="hidden" name="corrections" value={correctionPayload()}/>
<div class="proposal-correction-head"><div><span>OVERSIGHT CORRECTIONS</span><small>FILES REMAIN IMMUTABLE · CHANGES ARE AUDIT LOGGED</small></div><b>{correctionEntries.filter((entry) => entry.kind === 'text').length} EDITABLE FIELDS</b></div>
<div class="proposal-correction-grid">
{#each correctionEntries as entry}
{#if entry.kind === 'text'}
<label class="field"><span>{operationLabel(entry.name)} <small>FIELD {entry.index + 1}</small></span>{#if longField(entry)}<textarea bind:value={entry.value}></textarea>{:else}<input bind:value={entry.value}/>{/if}</label>
{:else}
<div class="immutable-proposal-file"><span>{operationLabel(entry.name)}</span><b>{entry.fileName}</b><small>ORIGINAL FILE EVIDENCE · NOT EDITABLE</small></div>
{/if}
{/each}
</div>
{#if form?.moderationError}<p class="error">{form.moderationError}</p>{/if}
<div class="moderation-warning"><b>!</b><span>SAVE ONLY MINOR CORRECTIONS. MATERIAL CHANGES SHOULD BE REJECTED FOR CONTRIBUTOR REVISION.</span></div>
<div class="form-actions"><button type="button" class="secondary" onclick={() => reviewing = null} disabled={pending}>CANCEL</button><button class="primary" disabled={pending} aria-busy={pending}>{pending ? 'SAVING…' : 'SAVE CORRECTIONS →'}</button></div>
{/snippet}
</PendingForm>
{:else}
<PendingForm action={decision === 'approve' ? '?/approve' : '?/reject'}>
{#snippet children(pending)}
<input type="hidden" name="id" value={reviewing.id}/>
<label class="field">{decision === 'approve' ? 'OPTIONAL OVERSIGHT NOTE' : 'REJECTION REASON — REQUIRED'}<textarea name="note" required={decision === 'reject'} placeholder={decision === 'approve' ? 'Record context for the audit trail…' : 'Explain what the contributor should revise…'}></textarea></label>
{#if form?.moderationError}<p class="error">{form.moderationError}</p>{/if}
<div class="moderation-warning" class:rejecting={decision === 'reject'}><b>{decision === 'approve' ? '!' : '×'}</b><span>{decision === 'approve' ? 'APPROVAL WILL RUN VALIDATION AND MUTATE THE LIVE BUILDER DATA SET.' : 'REJECTION WILL PRESERVE THIS REQUEST AND YOUR REASON IN THE AUDIT TRAIL.'}</span></div>
<div class="form-actions"><button type="button" class="secondary" onclick={() => reviewing = null} disabled={pending}>CANCEL</button><button class={decision === 'approve' ? 'primary' : 'danger'} disabled={pending} aria-busy={pending}>{pending ? 'PROCESSING…' : decision === 'approve' ? 'CONFIRM & DEPLOY →' : 'CONFIRM REJECTION →'}</button></div>
{/snippet}
</PendingForm>
{/if}
{:else}<div class="form-actions"><button class="primary" onclick={() => reviewing = null}>CLOSE EVIDENCE</button></div>{/if}
</div>
</div>
{/if}