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'; import type { Database } from '@tursodatabase/database'; import { GameDataIconValidationError } from '$lib/server/game-data-icons'; import { ManagedImageValidationError } from '$lib/server/managed-images'; import { MediaMutationJournal } from '$lib/server/media-journal'; import { readTalentFormMedia, saveTalentFromFormInTransaction, TalentNotFoundError, type SaveTalentInTransactionResult, } from '../../routes/isac/talents/talent-save'; import { MechanicSourceFingerprintConflictError } from '$lib/server/mechanic-authored-sources'; import { parseProtocolForm, ProtocolFormValidationError, ProtocolNotFoundError, saveProtocolInTransaction, } from '../../routes/isac/protocols/protocol-save'; import { _saveSkillChipInTransaction, parseSkillChipForm, SkillChipFormValidationError, SkillChipNotFoundError, } from '../../routes/isac/skillchips/skillchip-save'; import { parseSpecializationCombatEffectForm, parseSpecializationNodeForm, saveSpecializationCombatEffectInTransaction, saveSpecializationNodeInTransaction, SpecializationFormValidationError, } from '../../routes/isac/specializations/specialization-save'; 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() }; } export const MAX_PROPOSAL_ENTRIES = 300; export const MAX_PROPOSAL_TEXT_ENTRY_CHARS = 100_000; export const MAX_PROPOSAL_TEXT_BYTES = 2 * 1024 * 1024; export const MAX_PROPOSAL_FILE_BYTES = 5 * 1024 * 1024; export const MAX_PROPOSAL_FILE_BYTES_TOTAL = 12 * 1024 * 1024; const MAX_PROPOSAL_FILE_BASE64_CHARS = 4 * Math.ceil(MAX_PROPOSAL_FILE_BYTES / 3); export const MAX_PROPOSAL_FIELD_NAME_CHARS = 200; export const MAX_PROPOSAL_FILE_NAME_CHARS = 255; export const MAX_PROPOSAL_FILE_TYPE_CHARS = 255; const PROPOSAL_CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/u; const PROPOSAL_BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u; export type SerializedEntry = | { name: string; kind: 'text'; value: string } | { name: string; kind: 'file'; value: string; fileName: string; type: string; }; export class ProposalPayloadValidationError extends Error { constructor(message: string) { super(message); this.name = 'ProposalPayloadValidationError'; } } export type PendingDefinition = { module: DataModule; action: string; kind: string; fields?: string[]; }; function validateProposalFieldName(name: string) { if ( Array.from(name).length > MAX_PROPOSAL_FIELD_NAME_CHARS || PROPOSAL_CONTROL_CHARACTERS.test(name) ) throw new ProposalPayloadValidationError( `Proposal field names must be at most ${MAX_PROPOSAL_FIELD_NAME_CHARS} characters and contain no control characters.`, ); } function validateProposalFileMetadata(fileName: string, type: string) { if ( Array.from(fileName).length > MAX_PROPOSAL_FILE_NAME_CHARS || PROPOSAL_CONTROL_CHARACTERS.test(fileName) ) throw new ProposalPayloadValidationError( `Proposal file names must be at most ${MAX_PROPOSAL_FILE_NAME_CHARS} characters and contain no control characters.`, ); if ( Array.from(type).length > MAX_PROPOSAL_FILE_TYPE_CHARS || PROPOSAL_CONTROL_CHARACTERS.test(type) ) throw new ProposalPayloadValidationError( `Proposal file types must be at most ${MAX_PROPOSAL_FILE_TYPE_CHARS} characters and contain no control characters.`, ); } function decodeSerializedProposalFile(value: string) { if (value.length > MAX_PROPOSAL_FILE_BASE64_CHARS) throw new ProposalPayloadValidationError('Proposal file data is invalid.'); if (!PROPOSAL_BASE64_PATTERN.test(value)) throw new ProposalPayloadValidationError('Proposal file data is invalid.'); const bytes = Buffer.from(value, 'base64'); if (!bytes.length || bytes.toString('base64') !== value) throw new ProposalPayloadValidationError('Proposal file data is invalid.'); return bytes; } export function validateSerializedProposalEntries(entries: SerializedEntry[]) { if (!Array.isArray(entries)) throw new ProposalPayloadValidationError('Proposal entries are invalid.'); if (entries.length > MAX_PROPOSAL_ENTRIES) throw new ProposalPayloadValidationError( `A proposal may contain at most ${MAX_PROPOSAL_ENTRIES} entries.`, ); let textBytes = 0; let fileBytes = 0; for (const entry of entries) { if (!entry || typeof entry !== 'object' || typeof entry.name !== 'string') throw new ProposalPayloadValidationError('Proposal entries are invalid.'); validateProposalFieldName(entry.name); if (entry.kind === 'text') { if (typeof entry.value !== 'string') throw new ProposalPayloadValidationError( 'Proposal entries are invalid.', ); if (Array.from(entry.value).length > MAX_PROPOSAL_TEXT_ENTRY_CHARS) throw new ProposalPayloadValidationError( `Proposal text entries must be at most ${MAX_PROPOSAL_TEXT_ENTRY_CHARS} characters.`, ); textBytes += Buffer.byteLength(entry.value, 'utf8'); if (textBytes > MAX_PROPOSAL_TEXT_BYTES) throw new ProposalPayloadValidationError( 'The aggregate proposal text exceeds the safe size limit.', ); continue; } if (entry.kind !== 'file') throw new ProposalPayloadValidationError('Proposal entries are invalid.'); if ( typeof entry.value !== 'string' || typeof entry.fileName !== 'string' || typeof entry.type !== 'string' ) throw new ProposalPayloadValidationError('Proposal entries are invalid.'); validateProposalFileMetadata(entry.fileName, entry.type); const bytes = decodeSerializedProposalFile(entry.value); if (bytes.length > MAX_PROPOSAL_FILE_BYTES) throw new ProposalPayloadValidationError( 'Proposal files exceed the safe per-file size limit.', ); fileBytes += bytes.length; if (fileBytes > MAX_PROPOSAL_FILE_BYTES_TOTAL) throw new ProposalPayloadValidationError( 'The aggregate proposal files exceed the safe size limit.', ); } } export async function serializeProposal( form: FormData, ): Promise { const entries: SerializedEntry[] = []; let entryCount = 0; let textBytes = 0; let fileBytes = 0; for (const [name, value] of form.entries()) { entryCount += 1; if (entryCount > MAX_PROPOSAL_ENTRIES) throw new ProposalPayloadValidationError( `A proposal may contain at most ${MAX_PROPOSAL_ENTRIES} entries.`, ); validateProposalFieldName(name); if (typeof value === 'string') { if (Array.from(value).length > MAX_PROPOSAL_TEXT_ENTRY_CHARS) throw new ProposalPayloadValidationError( `Proposal text entries must be at most ${MAX_PROPOSAL_TEXT_ENTRY_CHARS} characters.`, ); textBytes += Buffer.byteLength(value, 'utf8'); if (textBytes > MAX_PROPOSAL_TEXT_BYTES) throw new ProposalPayloadValidationError( 'The aggregate proposal text exceeds the safe size limit.', ); entries.push({ name, kind: 'text', value }); continue; } validateProposalFileMetadata(value.name, value.type); if (value.size === 0) continue; if (value.size > MAX_PROPOSAL_FILE_BYTES) throw new ProposalPayloadValidationError( 'Proposal files exceed the safe per-file size limit.', ); if (fileBytes + value.size > MAX_PROPOSAL_FILE_BYTES_TOTAL) throw new ProposalPayloadValidationError( 'The aggregate proposal files exceed the safe size limit.', ); const bytes = Buffer.from(await value.arrayBuffer()); if (bytes.length > MAX_PROPOSAL_FILE_BYTES) throw new ProposalPayloadValidationError( 'Proposal files exceed the safe per-file size limit.', ); fileBytes += bytes.length; if (fileBytes > MAX_PROPOSAL_FILE_BYTES_TOTAL) throw new ProposalPayloadValidationError( 'The aggregate proposal files exceed the safe size limit.', ); entries.push({ name, kind: 'file', value: bytes.toString('base64'), fileName: value.name, type: value.type, }); } return entries; } export function parseSerializedProposalEntries( payload: string, ): SerializedEntry[] { let parsed: unknown; try { parsed = JSON.parse(payload); validateSerializedProposalEntries(parsed as SerializedEntry[]); } catch (error) { if (error instanceof ProposalPayloadValidationError) throw error; throw new ProposalPayloadValidationError('Proposal entries are invalid.'); } return parsed as SerializedEntry[]; } export function deserializeProposal(payload: string): FormData { const entries = parseSerializedProposalEntries(payload); const form = new FormData(); for (const entry of entries) { if (entry.kind === 'text') form.append(entry.name, entry.value); else form.append( entry.name, new Blob([decodeSerializedProposalFile(entry.value)], { type: entry.type, }), entry.fileName, ); } return form; } export class ProposalNotPendingError extends Error { constructor() { super('That pending proposal no longer exists.'); this.name = 'ProposalNotPendingError'; } } export class ProposalDependenciesBlockedError extends Error { readonly dependencies: string; constructor(dependencies: string) { super(`Approve this proposal's dependencies first: ${dependencies}.`); this.name = 'ProposalDependenciesBlockedError'; this.dependencies = dependencies; } } export class ProposalDependencyCycleError extends Error { readonly proposalId: string; readonly dependencyId: string; constructor(proposalId: string, dependencyId: string) { super( `Adding dependency ${proposalId} -> ${dependencyId} would create a cycle.`, ); this.name = 'ProposalDependencyCycleError'; this.proposalId = proposalId; this.dependencyId = dependencyId; } } export class ProposalCanonicalValidationError extends Error { readonly status: number; constructor(status: number, message: string) { super(message); this.name = 'ProposalCanonicalValidationError'; this.status = status; } } const safeValidationMessage = (error: unknown) => { const message = error instanceof Error ? error.message : ''; return message && message.length <= 500 ? message.replace(/[\u0000-\u001f\u007f]/gu, ' ').trim() : 'The proposal did not pass current canonical validation.'; }; function canonicalFailure( error: unknown, ): ProposalCanonicalValidationError | null { if (error instanceof ProposalCanonicalValidationError) return new ProposalCanonicalValidationError( error.status, safeValidationMessage(error), ); if (error instanceof ProposalPayloadValidationError) return new ProposalCanonicalValidationError( 400, 'The stored proposal payload is invalid.', ); if (error instanceof MechanicSourceFingerprintConflictError) return new ProposalCanonicalValidationError( 409, safeValidationMessage(error), ); if ( error instanceof GameDataIconValidationError || error instanceof ManagedImageValidationError ) return new ProposalCanonicalValidationError( 400, safeValidationMessage(error), ); if ( error instanceof ProtocolFormValidationError || error instanceof SkillChipFormValidationError || error instanceof SpecializationFormValidationError ) return new ProposalCanonicalValidationError( error.status, safeValidationMessage(error), ); if ( error instanceof ProtocolNotFoundError || error instanceof TalentNotFoundError || error instanceof SkillChipNotFoundError ) return new ProposalCanonicalValidationError( 404, safeValidationMessage(error), ); if ( error instanceof Error && /no authored source|identity does not match|cannot be moved|does not belong to this node|submitted more than once|reused an existing ID|no longer exists/i.test( error.message, ) ) return new ProposalCanonicalValidationError( 409, safeValidationMessage(error), ); return null; } const SPECIALIZATION_NOT_FOUND_ERRORS = new Set([ 'The specialization tree node no longer exists.', 'The specialization combat effect no longer exists.', ]); const SPECIALIZATION_CONFLICT_ERRORS = new Set([ 'The node identity does not match the supplied record.', 'A saved tree node cannot be moved to another focus.', 'A combat-effect attachment was submitted more than once.', 'A submitted combat-effect attachment does not belong to this node.', 'A new combat-effect attachment reused an existing ID.', 'The combat-effect identity does not match the supplied record.', 'A saved combat effect cannot be moved to another specialization.', ]); function classifySpecializationTransactionError(error: unknown) { if (error instanceof SpecializationFormValidationError) return error; if (error instanceof MechanicSourceFingerprintConflictError) return error; if (!(error instanceof Error)) return null; const message = error.message.trim(); if (SPECIALIZATION_NOT_FOUND_ERRORS.has(message)) return new SpecializationFormValidationError( 404, safeValidationMessage(error), ); if (SPECIALIZATION_CONFLICT_ERRORS.has(message)) return new SpecializationFormValidationError( 409, safeValidationMessage(error), ); const isKnownValidationMessage = message === 'Every conditional effect requires an authored description.' || message === 'The authored mechanic owner does not match its attachment.' || message === 'One or more conditional effects are unavailable for this specialization.' || message === 'One or more authored description Attributes are unavailable.' || message.startsWith('The authored template ') || message.startsWith('Clause ') || message.startsWith('Conditional mechanic '); return isKnownValidationMessage ? new SpecializationFormValidationError(400, safeValidationMessage(error)) : null; } function enforcePhase2ProposalIdentity( proposal: Record, form: FormData, ) { const entityId = String(proposal.entity_id ?? '').trim(); if (entityId) { form.set('id', entityId); form.delete('_proposedEntityId'); return; } const draftEntityId = String(proposal.draft_entity_id ?? '').trim(); if (!draftEntityId) throw new ProposalCanonicalValidationError( 400, 'The proposal does not have a stored draft identity.', ); form.delete('id'); form.set('_proposedEntityId', draftEntityId); } async function replayPhase2Owner( database: Database, proposal: Record, reviewerId: string, form: FormData, proposalId: string, journal: MediaMutationJournal, ) { const mutationContext = { origin: 'proposal' as const, proposalId }; switch (`${String(proposal.module)}:${String(proposal.action)}`) { case 'talents:saveTalent': { const media = await readTalentFormMedia(form); const result: SaveTalentInTransactionResult = await saveTalentFromFormInTransaction(database, { form, operatorId: reviewerId, proposedEntityId: String(proposal.draft_entity_id ?? '').trim() || undefined, ...media, mutationContext, journal, }); if (!result.ok) throw new ProposalCanonicalValidationError( result.status, result.errors[0]?.message ?? 'The talent could not be saved.', ); return { entityId: result.talentId, mode: result.talentMode }; } case 'protocols:save': { const input = await parseProtocolForm( database, form, reviewerId, mutationContext, ); const result = await saveProtocolInTransaction(database, { ...input, journal, }); return { entityId: result.protocolId, mode: result.mode }; } case 'skillchips:save': { const input = await parseSkillChipForm( database, form, reviewerId, mutationContext, ); const result = await _saveSkillChipInTransaction(database, { ...input, journal, }); return { entityId: result.setId, mode: result.mode }; } case 'specializations:saveNode': { const input = await parseSpecializationNodeForm( database, form, reviewerId, mutationContext, ); try { const result = await saveSpecializationNodeInTransaction(database, { ...input, journal, }); return { entityId: input.nodeId, mode: input.id ? 'edit' : 'add', result, }; } catch (error) { throw classifySpecializationTransactionError(error) ?? error; } } case 'specializations:saveCombatEffect': { const input = await parseSpecializationCombatEffectForm( database, form, reviewerId, mutationContext, ); try { const result = await saveSpecializationCombatEffectInTransaction( database, input, ); return { entityId: input.effectId, mode: input.id ? 'edit' : 'add', result, }; } catch (error) { throw classifySpecializationTransactionError(error) ?? error; } } default: throw new Error('Unsupported Phase 2 proposal action.'); } } export type Phase2ApprovalResult = { proposal: Record; entityId: string; mode: string; }; /** Runs one Phase 2 owner replay and proposal decision in one immediate tx. */ export async function approvePhase2Proposal( database: Database, reviewerId: string, proposalId: string, note: string | null, ): Promise { const journal = new MediaMutationJournal(); try { const operation = database.transactionAsync(async (transaction) => { const tx = transaction as unknown as Database; const proposal = await ( await tx.prepare( "SELECT * FROM data_change_proposals WHERE id=? AND status='pending'", ) ).get(proposalId); if (!proposal) throw new ProposalNotPendingError(); const claimed = await ( await tx.prepare( "UPDATE data_change_proposals SET status='approved' WHERE id=? AND status='pending'", ) ).run(proposalId); if (!Number(claimed?.changes ?? 0)) throw new ProposalNotPendingError(); const unresolved = await ( await tx.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(proposalId); if (unresolved.length) { const names = unresolved .map((item) => `${item.summary} (${item.status})`) .join(', '); throw new ProposalDependenciesBlockedError(names); } let result: { entityId: string; mode: string }; try { const form = deserializeProposal(String(proposal.payload_json)); enforcePhase2ProposalIdentity(proposal as Record, form); result = await replayPhase2Owner( tx, proposal, reviewerId, form, proposalId, journal, ); } catch (error) { const failure = canonicalFailure(error); if (failure) throw failure; throw error; } const now = new Date().toISOString(); const updated = await ( await tx.prepare( "UPDATE data_change_proposals SET applied_entity_id=?,reviewed_by=?,review_note=?,reviewed_at=?,applied_at=? WHERE id=? AND status='approved'", ) ).run(result.entityId, reviewerId, note || null, now, now, proposalId); if (!Number(updated?.changes ?? 0)) throw new ProposalNotPendingError(); await recordIsacAudit({ actorId: reviewerId, category: 'data_moderation', action: 'proposal.approved', entityId: proposalId, before: { status: 'pending' }, after: { status: 'approved', module: proposal.module, action: proposal.action, proposedBy: proposal.proposed_by, appliedEntityId: result.entityId, }, database: tx, }); return { proposal, entityId: result.entityId, mode: result.mode }; }); const result = await operation.immediate(); journal.clear(); return result; } catch (error) { await journal.rollback(error); throw error; } } export async function failPhase2Proposal( database: Database, reviewerId: string, proposalId: string, message: string, ) { const operation = database.transactionAsync(async (transaction) => { const tx = transaction as unknown as Database; const proposal = await ( await tx.prepare( "SELECT * FROM data_change_proposals WHERE id=? AND status='pending'", ) ).get(proposalId); if (!proposal) throw new ProposalNotPendingError(); const now = new Date().toISOString(); const updated = await ( await tx.prepare( "UPDATE data_change_proposals SET status='failed',reviewed_by=?,review_note=?,reviewed_at=? WHERE id=? AND status='pending'", ) ).run(reviewerId, message, now, proposalId); if (!Number(updated?.changes ?? 0)) throw new ProposalNotPendingError(); await recordIsacAudit({ actorId: reviewerId, category: 'data_moderation', action: 'proposal.failed', entityId: proposalId, before: { status: 'pending' }, after: { status: 'failed', messagePresent: Boolean(message) }, database: tx, }); return proposal as Record; }); return operation.immediate(); } export async function refreshProposalDependencies( proposalId: string, payload: string, suppliedDatabase?: Database, ) { const textValues = serializedTextValues(payload); // A malformed persisted payload cannot provide reliable dependency evidence. // Leave existing links intact rather than mutating state during recovery. if (!textValues) return []; const database = suppliedDatabase ?? (await getDatabase()); const text = textValues.map((entry) => entry.value).join('\n'); const candidates = await ( await database.prepare(`SELECT id,draft_entity_id FROM data_change_proposals WHERE id<>? AND status='pending' AND draft_entity_id IS NOT NULL`) ).all(proposalId); const dependencies = candidates.filter((candidate) => text.includes(String(candidate.draft_entity_id)), ); const existingLinks = await ( await database.prepare( 'SELECT proposal_id,depends_on_proposal_id FROM data_change_proposal_dependencies', ) ).all(); const links = new Map(); for (const link of existingLinks) { const proposalLinks = links.get(String(link.proposal_id)) ?? []; proposalLinks.push(String(link.depends_on_proposal_id)); links.set(String(link.proposal_id), proposalLinks); } const reachesProposal = (start: string) => { const visited = new Set(); const toVisit = [start]; while (toVisit.length) { const current = toVisit.pop()!; if (current === proposalId) return true; if (visited.has(current)) continue; visited.add(current); toVisit.push(...(links.get(current) ?? [])); } return false; }; for (const dependency of dependencies) { const dependencyId = String(dependency.id); if (reachesProposal(dependencyId)) throw new ProposalDependencyCycleError(proposalId, dependencyId); } 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, ): Array> | null => { try { return parseSerializedProposalEntries(payload).filter( (entry): entry is Extract => entry.kind === 'text', ); } catch (error) { if (error instanceof ProposalPayloadValidationError) return null; throw error; } }; 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 unusable = values === null; const safeValues = values ?? []; const field = (name: string) => safeValues.find((entry) => entry.name === name)?.value ?? ''; const fieldValue = (name: string) => { const matches = safeValues .filter((entry) => entry.name === name) .map((entry) => entry.value); return matches.length > 1 ? matches : (matches[0] ?? ''); }; return { id: String(row.draft_entity_id ?? row.id), proposalId: String(row.id), pending: true, active: 1, ...(unusable ? { unusable: true } : {}), kind: definition.kind, name: unusable ? 'UNUSABLE PROPOSAL' : field('name'), proposerName: String(row.proposer_name), ...Object.fromEntries( (definition.fields ?? []).map((name) => [ name, unusable ? '' : 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 serializeProposal(form)); const database = await getDatabase(); const createProposal = database.transactionAsync(async (transaction) => { const tx = transaction as unknown as Database; await ( await tx.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 tx.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 tx.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, contributorNotePresent: Boolean(contributorNote), }, database: tx, }); }); await createProposal.immediate(); 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), }; }