460 lines
14 KiB
TypeScript
460 lines
14 KiB
TypeScript
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<T>(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<unknown>(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<QualityValue[]>(
|
||
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 };
|
||
},
|
||
};
|