Files
shd.build/src/lib/server/data-moderation.ts
T
jb 57068b61cd
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 28s
Validate and deploy staging / deploy (push) Successful in 1m3s
feat: expand ISAC content modeling
2026-08-03 01:23:20 -04:00

337 lines
11 KiB
TypeScript

import { randomUUID } from 'node:crypto';
import { getDatabase } from '$lib/server/database';
import { getPermissions } from '$lib/server/auth/authorization';
import type { AuthUser } from '$lib/server/auth/session';
import { recordIsacAudit } from '$lib/server/audit';
import { getContributorMode } from '$lib/server/contributor-mode';
import { notifyOversightReview } from '$lib/server/oversight-notifications';
export const DATA_MODULES = [
'configuration',
'calculations',
'specializations',
'attributes',
'talents',
'brands',
'gear',
'weapons',
'weaponmods',
'gearmods',
'protocols',
'skillchips',
] as const;
export type DataModule = (typeof DATA_MODULES)[number];
const proposalResultKeys: Partial<
Record<DataModule, Record<string, { saved: string; mode?: string }>>
> = {
configuration: { saveCoreRanges: { saved: 'configurationSaved' } },
calculations: {
saveRevision: { saved: 'revisionSaved', mode: 'revisionMode' },
saveCalibration: { saved: 'calibrationSaved', mode: 'calibrationMode' },
},
attributes: { save: { saved: 'saved', mode: 'editorMode' } },
brands: { save: { saved: 'brandSaved', mode: 'brandMode' } },
gear: {
save: { saved: 'gearSaved', mode: 'gearMode' },
saveAugmentProfile: { saved: 'augmentProfileSaved' },
},
weapons: { save: { saved: 'weaponSaved', mode: 'weaponMode' } },
weaponmods: {
save: { saved: 'weaponModSaved', mode: 'weaponModMode' },
saveSlotIcon: { saved: 'weaponModSlotIconSaved' },
saveSecondaryPool: {
saved: 'weaponModSecondaryPoolSaved',
mode: 'weaponModSecondaryPoolMode',
},
},
gearmods: {
save: { saved: 'gearModSaved', mode: 'gearModMode' },
saveSecondaryPool: {
saved: 'gearModSecondaryPoolSaved',
mode: 'gearModSecondaryPoolMode',
},
},
talents: { saveTalent: { saved: 'talentSaved', mode: 'talentMode' } },
protocols: {
save: { saved: 'protocolSaved', mode: 'protocolMode' },
saveProfile: { saved: 'profileSaved' },
},
skillchips: {
save: { saved: 'skillChipSaved', mode: 'skillChipMode' },
savePrimaryProfile: { saved: 'skillChipPrimaryProfileSaved' },
saveSecondaryPool: { saved: 'skillChipSecondaryPoolSaved' },
},
specializations: {
saveProgression: { saved: 'progressionSaved' },
saveSpecialization: {
saved: 'specializationSaved',
mode: 'specializationMode',
},
saveFocus: { saved: 'focusSaved', mode: 'focusMode' },
saveAbility: { saved: 'abilitySaved', mode: 'abilityMode' },
saveCombatEffect: { saved: 'combatEffectSaved', mode: 'combatEffectMode' },
saveFocusAbilities: { saved: 'focusAbilitiesSaved' },
saveNode: { saved: 'nodeSaved', mode: 'nodeMode' },
},
};
export function proposalActionResult(
module: DataModule,
action: string,
edit: boolean,
proposalId: string,
) {
const keys = proposalResultKeys[module]?.[action];
return keys
? {
[keys.saved]: true,
...(keys.mode ? { [keys.mode]: edit ? 'edit' : 'add' } : {}),
proposalId,
resultToken: randomUUID(),
}
: { proposalQueued: true, proposalId, resultToken: randomUUID() };
}
type SerializedEntry =
| { name: string; kind: 'text'; value: string }
| {
name: string;
kind: 'file';
value: string;
fileName: string;
type: string;
};
type PendingDefinition = {
module: DataModule;
action: string;
kind: string;
fields?: string[];
};
async function serialize(form: FormData): Promise<SerializedEntry[]> {
const entries: SerializedEntry[] = [];
for (const [name, value] of form.entries()) {
if (typeof value === 'string') entries.push({ name, kind: 'text', value });
else if (value.size > 0)
entries.push({
name,
kind: 'file',
value: Buffer.from(await value.arrayBuffer()).toString('base64'),
fileName: value.name,
type: value.type,
});
}
return entries;
}
export function deserializeProposal(payload: string): FormData {
const form = new FormData();
for (const entry of JSON.parse(payload) as SerializedEntry[]) {
if (entry.kind === 'text') form.append(entry.name, entry.value);
else
form.append(
entry.name,
new Blob([Buffer.from(entry.value, 'base64')], { type: entry.type }),
entry.fileName,
);
}
return form;
}
export async function refreshProposalDependencies(
proposalId: string,
payload: string,
) {
const database = await getDatabase();
const text = serializedTextValues(payload)
.map((entry) => entry.value)
.join('\n');
const candidates = await (
await database.prepare(`SELECT id,draft_entity_id FROM data_change_proposals
WHERE id<>? AND draft_entity_id IS NOT NULL`)
).all(proposalId);
const dependencies = candidates.filter((candidate) =>
text.includes(String(candidate.draft_entity_id)),
);
await (
await database.prepare(
'DELETE FROM data_change_proposal_dependencies WHERE proposal_id=?',
)
).run(proposalId);
for (const dependency of dependencies) {
await (
await database.prepare(
'INSERT INTO data_change_proposal_dependencies(proposal_id,depends_on_proposal_id) VALUES(?,?)',
)
).run(proposalId, dependency.id);
}
return dependencies.map((dependency) => String(dependency.id));
}
const serializedTextValues = (payload: string) =>
(JSON.parse(payload) as SerializedEntry[]).filter(
(entry): entry is Extract<SerializedEntry, { kind: 'text' }> =>
entry.kind === 'text',
);
export async function getPendingEntities(
_userId: string,
definitions: PendingDefinition[],
): Promise<
Array<{
id: string;
proposalId: string;
pending: true;
active: number;
kind: string;
name: string;
proposerName: string;
[key: string]: unknown;
}>
> {
if (!definitions.length) return [];
const database = await getDatabase();
const clauses = definitions
.map(() => '(proposal.module=? AND proposal.action=?)')
.join(' OR ');
const args = definitions.flatMap(({ module, action }) => [module, action]);
const rows = await (
await database.prepare(`SELECT proposal.id,proposal.module,proposal.action,proposal.draft_entity_id,proposal.payload_json,proposal.created_at,
COALESCE(profile.display_name,proposer.username) AS proposer_name
FROM data_change_proposals proposal
JOIN users proposer ON proposer.id=proposal.proposed_by
LEFT JOIN user_profiles profile ON profile.user_id=proposer.id
WHERE proposal.status='pending' AND proposal.entity_id IS NULL AND (${clauses})
ORDER BY proposal.created_at DESC`)
).all(...args);
return rows.map((row) => {
const definition = definitions.find(
(item) => item.module === row.module && item.action === row.action,
)!;
const values = serializedTextValues(String(row.payload_json));
const field = (name: string) =>
values.find((entry) => entry.name === name)?.value ?? '';
const fieldValue = (name: string) => {
const matches = values
.filter((entry) => entry.name === name)
.map((entry) => entry.value);
return matches.length > 1 ? matches : (matches[0] ?? '');
};
return {
id: String(row.draft_entity_id),
proposalId: String(row.id),
pending: true,
active: 1,
kind: definition.kind,
name: field('name'),
proposerName: String(row.proposer_name),
...Object.fromEntries(
(definition.fields ?? []).map((name) => [name, fieldValue(name)]),
),
};
});
}
export async function queueDataProposal(
user: AuthUser,
module: DataModule,
action: string,
form: FormData,
) {
const id = randomUUID();
const contributorNote = String(form.get('contributorNote') ?? '')
.normalize('NFKC')
.trim();
if (
contributorNote.length > 1_000 ||
/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(contributorNote)
) {
throw new Error(
'Contributor rationale must contain at most 1,000 visible characters.',
);
}
form.delete('contributorNote');
const entityId = String(form.get('id') ?? '').trim() || null;
const draftEntityId = entityId ? null : randomUUID();
const name = String(form.get('name') ?? '').trim();
const summary = `${action.replace(/([A-Z])/g, ' $1').toUpperCase()} · ${name || entityId || 'NEW RECORD'}`;
const now = new Date().toISOString();
const payload = JSON.stringify(await serialize(form));
const database = await getDatabase();
await (
await database.prepare(`INSERT INTO data_change_proposals
(id,module,action,entity_id,draft_entity_id,summary,contributor_note,payload_json,status,proposed_by,created_at)
VALUES(?,?,?,?,?,?,?,?,'pending',?,?)`)
).run(
id,
module,
action,
entityId,
draftEntityId,
summary,
contributorNote || null,
payload,
user.id,
now,
);
const text = serializedTextValues(payload)
.map((entry) => entry.value)
.join('\n');
const candidates = await (
await database.prepare(`SELECT id,draft_entity_id FROM data_change_proposals
WHERE status='pending' AND draft_entity_id IS NOT NULL`)
).all();
const dependencies = candidates.filter(
(candidate) =>
candidate.id !== id && text.includes(String(candidate.draft_entity_id)),
);
for (const dependency of dependencies) {
await (
await database.prepare(
'INSERT OR IGNORE INTO data_change_proposal_dependencies(proposal_id,depends_on_proposal_id) VALUES(?,?)',
)
).run(id, dependency.id);
}
await recordIsacAudit({
actorId: user.id,
category: 'data_moderation',
action: 'proposal.created',
entityId: id,
before: null,
after: {
module,
action,
entityId,
draftEntityId,
dependencies: dependencies.map((item) => item.id),
summary,
contributorNote: contributorNote || null,
},
});
await notifyOversightReview({
category: 'data_changes',
itemId: id,
title: 'Data change awaiting review',
summary: `${summary} was submitted to the Oversight queue and is ready for review.`,
link: `/isac/data-moderation?search=${encodeURIComponent(name || entityId || id)}`,
}).catch((error) =>
console.error('Unable to notify Data Oversight reviewers', error),
);
return { id, entityId, draftEntityId };
}
export async function shouldModerateDataMutation(user: AuthUser | null) {
const permissions = await getPermissions(user);
const eligibleToPropose =
permissions.includes('*') || permissions.includes('data.propose');
const contributorMode =
user && eligibleToPropose ? await getContributorMode(user.id) : null;
return {
canManage: permissions.includes('*') || permissions.includes('data.manage'),
canPropose: eligibleToPropose,
contributorEnabled: Boolean(contributorMode?.enabled),
};
}