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> > = { 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 { 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 => 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), }; }