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, getRegistrySort } from '$lib/server/pagination'; import { recordIsacAudit } from '$lib/server/audit'; import type { Actions, PageServerLoad } from './$types'; import { createNotification } from '$lib/server/notifications'; import { conditionalRuleSnapshot } from '$lib/server/conditional-effects'; import { getSkillChipPrimaryProfiles, getSkillChipSecondaryPool, } from '$lib/server/skill-chip-profiles'; import { gameDataIconMetadata } from '$lib/server/game-data-icons'; import { weaponModSlotDefinitionById } from '$lib/weapons/mod-slots'; const statuses = ['pending', 'approved', 'rejected', 'failed']; const modules = [ 'configuration', 'calculations', 'specializations', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips', ]; type SerializedEntry = | { name: string; kind: 'text'; value: string } | { name: string; kind: 'file'; value: string; fileName: string; type: string; }; async function specializationProposalSnapshot(database: any, proposal: any) { const id = String(proposal.entity_id ?? ''); if (proposal.action === 'saveProgression') { return { record: await ( await database.prepare( "SELECT * FROM specialization_progression_config WHERE id='default'", ) ).get(), rankProfiles: await ( await database.prepare( 'SELECT * FROM specialization_rank_cost_profiles ORDER BY rank', ) ).all(), }; } if (!id) return null; if (proposal.action === 'saveSpecialization') { return { record: (await ( await database.prepare('SELECT * FROM specializations WHERE id=?') ).get(id)) ?? null, }; } if (proposal.action === 'saveFocus') { return { record: (await ( await database.prepare( 'SELECT * FROM specialization_focuses WHERE id=?', ) ).get(id)) ?? null, }; } if (proposal.action === 'saveAbility') { return { record: (await ( await database.prepare( 'SELECT * FROM specialization_abilities WHERE id=?', ) ).get(id)) ?? null, attack: (await ( await database.prepare( 'SELECT * FROM specialization_ability_attacks WHERE ability_id=?', ) ).get(id)) ?? null, effects: await ( await database.prepare( 'SELECT * FROM specialization_ability_effects WHERE ability_id=? ORDER BY effect_order', ) ).all(id), skillChipLinks: await ( await database.prepare( `SELECT ability_id,compatible_ability_id FROM specialization_ability_skill_chip_links WHERE ability_id=? OR compatible_ability_id=? ORDER BY ability_id,compatible_ability_id`, ) ).all(id, id), }; } if (proposal.action === 'saveCombatEffect') { return { record: (await ( await database.prepare( 'SELECT * FROM specialization_combat_effects WHERE id=?', ) ).get(id)) ?? null, attachments: await ( await database.prepare( 'SELECT id,node_id,rank,effect_order FROM specialization_tree_rank_combat_effects WHERE combat_effect_id=? ORDER BY node_id,rank,effect_order', ) ).all(id), }; } if (proposal.action === 'saveFocusAbilities') { return { record: (await ( await database.prepare( 'SELECT * FROM specialization_focuses WHERE id=?', ) ).get(id)) ?? null, abilities: await ( await database.prepare( 'SELECT ability_slot,ability_id FROM specialization_focus_abilities WHERE focus_id=? ORDER BY ability_slot', ) ).all(id), }; } if (proposal.action === 'saveNode') { return { record: (await ( await database.prepare( 'SELECT * FROM specialization_tree_nodes WHERE id=?', ) ).get(id)) ?? null, ranks: await ( await database.prepare( 'SELECT * FROM specialization_tree_node_ranks WHERE node_id=? ORDER BY rank', ) ).all(id), effects: await ( await database.prepare( 'SELECT * FROM specialization_tree_rank_effects WHERE node_id=? ORDER BY rank,effect_order', ) ).all(id), costs: await ( await database.prepare( 'SELECT * FROM specialization_tree_rank_costs WHERE node_id=? ORDER BY rank,resource_id', ) ).all(id), combatEffects: await ( await database.prepare( 'SELECT * FROM specialization_tree_rank_combat_effects WHERE node_id=? ORDER BY rank,effect_order', ) ).all(id), }; } return null; } async function skillChipProposalSnapshot(database: any, id: string) { const talent = await ( await database.prepare( 'SELECT * FROM skill_chip_talents WHERE skill_chip_set_id=?', ) ).get(id); return { record: (await ( await database.prepare('SELECT * FROM skill_chip_sets WHERE id=?') ).get(id)) ?? null, twoPiece: (await ( await database.prepare( 'SELECT * FROM skill_chip_two_piece_bonuses WHERE skill_chip_set_id=?', ) ).get(id)) ?? null, talent: talent ?? null, talentEffects: talent ? await ( await database.prepare( 'SELECT * FROM skill_chip_talent_effects WHERE talent_id=? ORDER BY sort_order', ) ).all(talent.id) : [], conditional: talent ? await conditionalRuleSnapshot( database, 'skill_chip_talent', String(talent.id), ) : null, }; } async function calculationProposalSnapshot(database: any, proposal: any) { const id = String(proposal.entity_id ?? ''); if (!id) return null; if (proposal.action === 'saveRevision') return { record: (await ( await database.prepare( 'SELECT * FROM calculation_revisions WHERE id=?', ) ).get(id)) ?? null, stages: await ( await database.prepare( 'SELECT * FROM calculation_stages WHERE revision_id=? ORDER BY display_order', ) ).all(id), profiles: await ( await database.prepare( 'SELECT * FROM attribute_calculation_profiles WHERE revision_id=? ORDER BY stage_key,stat_attribute_id', ) ).all(id), skillProfiles: await ( await database.prepare( 'SELECT * FROM skill_attribute_calculation_profiles WHERE revision_id=? ORDER BY stage_key,stat_attribute_id', ) ).all(id), }; if (proposal.action === 'saveCalibration') return { record: (await ( await database.prepare( 'SELECT * FROM calculation_calibration_cases WHERE id=?', ) ).get(id)) ?? null, }; return null; } 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 sort = getRegistrySort( url, { change: { asc: 'p.summary COLLATE NOCASE ASC,p.module COLLATE NOCASE ASC,p.action COLLATE NOCASE ASC', desc: 'p.summary COLLATE NOCASE DESC,p.module COLLATE NOCASE DESC,p.action COLLATE NOCASE DESC', }, contributor: { asc: 'COALESCE(profile.display_name,proposer.username) COLLATE NOCASE ASC', desc: 'COALESCE(profile.display_name,proposer.username) COLLATE NOCASE DESC', }, submitted: { asc: 'p.created_at ASC', desc: 'p.created_at DESC', defaultDirection: 'desc', }, status: { asc: 'p.status COLLATE NOCASE ASC', desc: 'p.status COLLATE NOCASE DESC', }, }, 'submitted', 'desc', ); 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 ${sort.orderBy},p.id LIMIT ? OFFSET ?`) ).all(...args, pagination.perPage, pagination.offset); const proposalsWithSnapshots = await Promise.all( proposals.map(async (proposal) => ({ ...proposal, current_snapshot: proposal.module === 'attributes' && proposal.entity_id ? ((await ( await database.prepare('SELECT * FROM stat_attributes WHERE id=?') ).get(String(proposal.entity_id))) ?? null) : proposal.module === 'skillchips' && proposal.action === 'savePrimaryProfile' ? await getSkillChipPrimaryProfiles(database) : proposal.module === 'skillchips' && proposal.action === 'saveSecondaryPool' ? await getSkillChipSecondaryPool(database) : proposal.module === 'weaponmods' && proposal.action === 'saveSlotIcon' && proposal.entity_id ? { slot: weaponModSlotDefinitionById( String(proposal.entity_id), ), icon: await gameDataIconMetadata( database, 'weapon_mod_slot', String(proposal.entity_id), ), } : proposal.module === 'skillchips' && proposal.entity_id ? await skillChipProposalSnapshot( database, String(proposal.entity_id), ) : proposal.module === 'calculations' ? await calculationProposalSnapshot(database, proposal) : proposal.module === 'specializations' ? await specializationProposalSnapshot(database, proposal) : null, })), ); const summaryRows = await ( await database.prepare( 'SELECT status,COUNT(*) AS count FROM data_change_proposals GROUP BY status', ) ).all(); return { proposals: proposalsWithSnapshots, pagination, status, module, search, sort, 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() }; }, };