import { auditRecord, recordIsacAudit } from '$lib/server/audit'; import { requireDataView, requirePermission, } from '$lib/server/auth/authorization'; import { getSessionUser } from '$lib/server/auth/session'; import { getDatabase } from '$lib/server/database'; import { getPendingEntities } from '$lib/server/data-moderation'; import { getPagination, getRegistrySearch, getRegistrySort, } from '$lib/server/pagination'; import { applyGameDataRender, ManagedImageValidationError, managedImageUrl, processManagedImage, readGameDataRenderMutation, } from '$lib/server/managed-images'; import { fail } from '@sveltejs/kit'; import { randomUUID } from 'node:crypto'; import type { Actions, PageServerLoad } from './$types'; import { MOD_QUALITIES, normalizeModSecondaryPool, } from '$lib/mods/secondary-pool'; import { getModSecondaryPool, replaceModSecondaryPool, } from '$lib/server/mod-secondary-pools'; const qualities = MOD_QUALITIES; type QualityValue = { quality: string; primaryLevel1Min: number; primaryLevel1Max: number; primaryLevel20Min: number; primaryLevel20Max: number; }; function parseJson(value: FormDataEntryValue | null, fallback: T): T { try { return JSON.parse(String(value ?? '')) as T; } catch { return fallback; } } async function snapshot(id: string) { const database = await getDatabase(); return { record: await auditRecord('gear_mods', id), qualityValues: await ( await database.prepare( 'SELECT * FROM gear_mod_quality_values WHERE gear_mod_id=? ORDER BY quality', ) ).all(id), render: (await ( await database.prepare( "SELECT * FROM managed_images WHERE source_entity_type='gear_mod_render' AND source_entity_id=?", ) ).get(id)) ?? null, }; } export const load: PageServerLoad = async ({ cookies, url }) => { const user = await requireDataView(await getSessionUser(cookies)); const database = await getDatabase(); const requestedFilter = url.searchParams.get('filter') ?? ''; const filter = ['active', 'pending', 'archived'].includes(requestedFilter) ? requestedFilter : ''; const requestedItemId = url.searchParams.get('item')?.trim() ?? ''; const { search, pattern } = getRegistrySearch(url); const sort = getRegistrySort( url, { name: { asc: 'name COLLATE NOCASE ASC,id ASC', desc: 'name COLLATE NOCASE DESC,id DESC', }, state: { asc: 'active ASC,name COLLATE NOCASE ASC', desc: 'active DESC,name COLLATE NOCASE ASC', defaultDirection: 'desc', }, }, 'name', ); const conditions = [ ...(requestedItemId ? ['id=?'] : filter === 'active' ? ['active=1'] : filter === 'archived' ? ['active=0'] : filter === 'pending' ? ['1=0'] : []), ...(!requestedItemId && search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : []), ]; const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const args = [ ...(requestedItemId ? [requestedItemId] : []), ...(!requestedItemId && search ? [pattern] : []), ]; const [summary, filtered, pendingStats, pendingMods] = await Promise.all([ ( await database.prepare(`SELECT COUNT(*) AS total, SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active, SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived FROM gear_mods`) ).get(), ( await database.prepare(`SELECT COUNT(*) AS total FROM gear_mods ${where}`) ).get(...args), getPendingEntities(user.id, [ { module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'], }, ]), getPendingEntities(user.id, [ { module: 'gearmods', action: 'save', kind: 'gearmod', fields: ['qualityValues'], }, ]), ]); const pendingRows = requestedItemId ? pendingMods.filter((item) => item.id === requestedItemId) : filter === 'archived' || filter === 'active' ? [] : pendingMods.filter( (item) => !search || item.name.toLocaleLowerCase().includes(search.toLocaleLowerCase()), ); const pagination = getPagination( url, Number(filtered?.total ?? 0) + pendingRows.length, ); const pendingOnPage = pagination.page === 1 ? pendingRows.slice(0, pagination.perPage) : []; const databaseOffset = Math.max(0, pagination.offset - pendingRows.length); const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length); const [mods, qualityRows, stats, renders, secondaryPool] = await Promise.all([ databaseLimit ? ( await database.prepare(`SELECT * FROM gear_mods ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`) ).all(...args, databaseLimit, databaseOffset) : [], (await database.prepare('SELECT * FROM gear_mod_quality_values')).all(), ( await database.prepare( 'SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE', ) ).all(), ( await database.prepare( "SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='gear_mod_render'", ) ).all(), getModSecondaryPool(database, 'gear'), ]); const parsedPending = pendingOnPage.map((item) => ({ ...item, qualityValues: parseJson(String(item.qualityValues), []), })); return { mods: [ ...parsedPending, ...mods.map((mod) => ({ ...mod, qualityValues: qualityRows .filter((row) => row.gear_mod_id === mod.id) .map((row) => ({ quality: row.quality, primaryLevel1Min: Number(row.primary_level_1_min), primaryLevel1Max: Number(row.primary_level_1_max), primaryLevel20Min: Number(row.primary_level_20_min), primaryLevel20Max: Number(row.primary_level_20_max), })), renderUrl: (() => { const render = renders.find( (item) => item.source_entity_id === mod.id, ); return render ? managedImageUrl(String(render.id), render.updated_at) : ''; })(), })), ], secondaryPool, stats: [...pendingStats, ...stats], qualities, summary: { ...summary, total: Number(summary?.total ?? 0) + pendingMods.length, pending: pendingMods.length, }, filter, requestedItemId, pagination, sort, search, }; }; export const actions: Actions = { saveSecondaryPool: async ({ request, cookies }) => { const operator = await requirePermission( await getSessionUser(cookies), 'data.manage', ); const form = await request.formData(); if (String(form.get('id') ?? '') !== 'gear-mod-secondary-pool') { return fail(400, { gearModSecondaryPoolError: 'The shared Gear Mod pool target is invalid.', }); } const normalized = normalizeModSecondaryPool( parseJson(form.get('secondaryRanges'), null), ); if (normalized.error) { return fail(400, { gearModSecondaryPoolError: normalized.error }); } const database = await getDatabase(); const statIds = normalized.pool.map((range) => range.statId); const validStats = await ( await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${statIds.map(() => '?').join(',')})`) ).get(...statIds); if (Number(validStats?.count) !== statIds.length) { return fail(400, { gearModSecondaryPoolError: 'One or more selected Attributes are unavailable.', }); } await database.exec('BEGIN IMMEDIATE'); try { const before = await getModSecondaryPool(database, 'gear'); await replaceModSecondaryPool(database, 'gear', normalized.pool); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'gear_mod_secondary_pool.updated', entityId: 'gear-mod-secondary-pool', before, after: await getModSecondaryPool(database, 'gear'), database, }); await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } return { gearModSecondaryPoolSaved: true, gearModSecondaryPoolMode: 'edit', resultToken: randomUUID(), }; }, save: async ({ request, cookies }) => { const operator = await requirePermission( await getSessionUser(cookies), 'data.manage', ); const form = await request.formData(); const id = String(form.get('id') ?? ''); const renderMutation = await readGameDataRenderMutation(form); try { if (renderMutation.file) renderMutation.processed = await processManagedImage( renderMutation.file, false, false, true, ); } catch (error) { if (error instanceof ManagedImageValidationError) return fail(400, { gearModError: error.message }); throw error; } const name = String(form.get('name') ?? '') .normalize('NFKC') .trim(); if (form.has('secondaryRanges')) { return fail(400, { gearModError: 'Secondary Attributes must be submitted through the shared pool editor.', }); } const qualityValues = parseJson( form.get('qualityValues'), [], ); if (name.length < 2 || name.length > 100) return fail(400, { gearModError: 'Gear Mod names must be 2–100 characters.', }); if ( qualityValues.length !== qualities.length || new Set(qualityValues.map((row) => row.quality)).size !== qualities.length || qualityValues.some( (row) => !qualities.includes(row.quality as (typeof qualities)[number]) || [ row.primaryLevel1Min, row.primaryLevel1Max, row.primaryLevel20Min, row.primaryLevel20Max, ].some((value) => !Number.isFinite(Number(value))) || Number(row.primaryLevel1Max) < Number(row.primaryLevel1Min) || Number(row.primaryLevel20Max) < Number(row.primaryLevel20Min) || Number(row.primaryLevel20Min) < Number(row.primaryLevel1Min) || Number(row.primaryLevel20Max) < Number(row.primaryLevel1Max), ) ) { return fail(400, { gearModError: 'Configure valid primary Level 1 and Level 20 ranges for all four qualities.', }); } const database = await getDatabase(); if ( await ( await database.prepare( 'SELECT id FROM gear_mods WHERE name=? COLLATE NOCASE AND id<>?', ) ).get(name, id || 'new') ) { return fail(409, { gearModError: 'That Gear Mod name is already registered.', }); } const modId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(); const before = id ? await snapshot(id) : null; if (id && !before?.record) return fail(404, { gearModError: 'Gear Mod not found.' }); const now = new Date().toISOString(); await database.exec('BEGIN'); try { if (id) await ( await database.prepare( 'UPDATE gear_mods SET name=?,active=1,updated_at=? WHERE id=?', ) ).run(name, now, id); else await ( await database.prepare( 'INSERT INTO gear_mods(id,name,created_at,updated_at) VALUES(?,?,?,?)', ) ).run(modId, name, now, now); await ( await database.prepare( 'DELETE FROM gear_mod_quality_values WHERE gear_mod_id=?', ) ).run(modId); for (const row of qualityValues) await ( await database.prepare(`INSERT INTO gear_mod_quality_values (gear_mod_id,quality,primary_level_1_min,primary_level_1_max,primary_level_20_min,primary_level_20_max) VALUES(?,?,?,?,?,?)`) ).run( modId, row.quality, row.primaryLevel1Min, row.primaryLevel1Max, row.primaryLevel20Min, row.primaryLevel20Max, ); await database.exec('COMMIT'); } catch (error) { await database.exec('ROLLBACK'); throw error; } await applyGameDataRender( database, 'gear_mod', modId, name, renderMutation, operator.id, ); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'gear_mod.updated' : 'gear_mod.created', entityId: modId, before, after: await snapshot(modId), }); return { gearModSaved: true, gearModMode: id ? 'edit' : 'add', resultToken: randomUUID(), }; }, archive: async ({ request, cookies }) => { const operator = await requirePermission( await getSessionUser(cookies), 'data.manage', ); const id = String((await request.formData()).get('id') ?? ''); const before = await snapshot(id); if (!before.record) return fail(404, { gearModError: 'Gear Mod not found.' }); await ( await ( await getDatabase() ).prepare('UPDATE gear_mods SET active=0,updated_at=? WHERE id=?') ).run(new Date().toISOString(), id); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'gear_mod.archived', entityId: id, before, after: await snapshot(id), }); return { gearModArchived: true }; }, unarchive: async ({ request, cookies }) => { const operator = await requirePermission( await getSessionUser(cookies), 'data.manage', ); const id = String((await request.formData()).get('id') ?? ''); const before = await snapshot(id); if (!before.record) return fail(404, { gearModError: 'Gear Mod not found.' }); await ( await ( await getDatabase() ).prepare('UPDATE gear_mods SET active=1,updated_at=? WHERE id=?') ).run(new Date().toISOString(), id); await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'gear_mod.unarchived', entityId: id, before, after: await snapshot(id), }); return { gearModUnarchived: true }; }, };