Rename admin routes to ISAC

This commit is contained in:
JB
2026-07-25 20:55:13 -04:00
parent 0425fede8e
commit 67db106fba
38 changed files with 46 additions and 38 deletions
+212
View File
@@ -0,0 +1,212 @@
import { getSessionUser } from '$lib/server/auth/session';
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
import { getDatabase } from '$lib/server/database';
import { fail } from '@sveltejs/kit';
import { randomUUID } from 'node:crypto';
import type { Actions, PageServerLoad } from './$types';
import { getPagination } from '$lib/server/pagination';
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
import { getPendingEntities } from '$lib/server/data-moderation';
type SharedEffect = { statId: string; valueI: number; valueII: number };
export const load: PageServerLoad = async ({ cookies, url }) => {
const user = await requireDataView(await getSessionUser(cookies));
const db = await getDatabase();
const totalRow = await (await db.prepare('SELECT COUNT(*) AS total FROM talents')).get();
const filter = ['slot1', 'slot2', 'exotic', 'backpack', 'body-armor'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
const where = filter === 'slot1' ? 'WHERE EXISTS (SELECT 1 FROM weapon_talent_slots WHERE talent_id=talents.id AND slot=1)'
: filter === 'slot2' ? 'WHERE EXISTS (SELECT 1 FROM weapon_talent_slots WHERE talent_id=talents.id AND slot=2)'
: filter === 'exotic' ? "WHERE EXISTS (SELECT 1 FROM talent_weapon_scopes WHERE talent_id=talents.id AND scope='Exotic')"
: filter === 'backpack' ? "WHERE category='Backpack'"
: filter === 'body-armor' ? "WHERE category IN ('Vest','Body Armor')" : '';
const filteredRow = await (await db.prepare(`SELECT COUNT(*) AS total FROM talents ${where}`)).get();
const pagination = getPagination(url, Number(filteredRow?.total ?? 0));
const [talents, tiers, triggers, links, effects, stats, templates, weaponScopes, weaponSlots, exoticOwners, icons] = await Promise.all([
(await db.prepare(`SELECT * FROM talents ${where} ORDER BY active DESC,name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(pagination.perPage, pagination.offset),
(await db.prepare('SELECT id,talent_id,tier,description FROM talent_tiers')).all(),
(await db.prepare('SELECT * FROM talent_triggers ORDER BY active DESC,name COLLATE NOCASE')).all(),
(await db.prepare('SELECT talent_tier_id,trigger_id FROM talent_tier_triggers')).all(),
(await db.prepare('SELECT talent_tier_id,stat_attribute_id,value FROM talent_tier_effects')).all(),
(await db.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
(await db.prepare('SELECT talent_id,template FROM talent_description_templates')).all(),
(await db.prepare('SELECT talent_id,scope FROM talent_weapon_scopes')).all(),
(await db.prepare('SELECT talent_id,slot FROM weapon_talent_slots')).all(),
(await db.prepare('SELECT id,name,exotic_talent_id FROM weapons WHERE exotic_talent_id IS NOT NULL')).all(),
(await db.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all()
]);
const [triggerSummary, slotSummary, exoticSummary, gearSummary, pending] = await Promise.all([
(await db.prepare('SELECT COUNT(*) AS total FROM talent_triggers')).get(),
(await db.prepare(`SELECT
SUM(CASE WHEN slot=1 THEN 1 ELSE 0 END) AS slot1,
SUM(CASE WHEN slot=2 THEN 1 ELSE 0 END) AS slot2 FROM weapon_talent_slots`)).get(),
(await db.prepare("SELECT COUNT(*) AS total FROM talent_weapon_scopes WHERE scope='Exotic'")).get(),
(await db.prepare(`SELECT
SUM(CASE WHEN category='Backpack' THEN 1 ELSE 0 END) AS backpack,
SUM(CASE WHEN category IN ('Vest','Body Armor') THEN 1 ELSE 0 END) AS body_armor
FROM talents`)).get(),
getPendingEntities(user.id, [
{ module: 'talents', action: 'saveTrigger', kind: 'trigger', fields: ['description'] },
{ module: 'attributes', action: 'save', kind: 'stat', fields: ['unit'] }
])
]);
const pendingTriggers = pending.filter((item) => item.kind === 'trigger');
const pendingStats = pending.filter((item) => item.kind === 'stat');
return {
talents: talents.map((talent) => ({
...talent,
iconUrl: (() => { const icon = icons.find((item) => item.entity_id === talent.id); return icon ? gameDataIconUrl('talent', String(talent.id), icon.updated_at) : ''; })(),
category: talent.category === 'Vest'
? 'Body Armor'
: talent.category === 'Weapon' && weaponScopes.find((scope) => scope.talent_id === talent.id)?.scope === 'Exotic'
? 'Exotic Weapon'
: talent.category === 'Weapon' && Number(weaponSlots.find((slot) => slot.talent_id === talent.id)?.slot) === 1
? 'Weapon Talent 1'
: talent.category === 'Weapon' && Number(weaponSlots.find((slot) => slot.talent_id === talent.id)?.slot) === 2
? 'Weapon Talent 2'
: talent.category === 'Weapon'
? 'Weapon · Slot Unassigned'
: talent.category,
descriptionTemplate: templates.find((item) => item.talent_id === talent.id)?.template ?? '',
ownerWeaponId: exoticOwners.find((weapon) => weapon.exotic_talent_id === talent.id)?.id ?? null,
ownerWeaponName: exoticOwners.find((weapon) => weapon.exotic_talent_id === talent.id)?.name ?? null,
tiers: tiers.filter((tier) => tier.talent_id === talent.id).map((tier) => ({
...tier,
triggerIds: links.filter((link) => link.talent_tier_id === tier.id).map((link) => String(link.trigger_id)),
effects: effects.filter((effect) => effect.talent_tier_id === tier.id)
}))
})),
triggers: [...pendingTriggers, ...triggers],
stats: [...pendingStats, ...stats],
summary: {
total: Number(totalRow?.total ?? 0),
triggers: Number(triggerSummary?.total ?? 0),
slot1: Number(slotSummary?.slot1 ?? 0),
slot2: Number(slotSummary?.slot2 ?? 0),
exotic: Number(exoticSummary?.total ?? 0),
backpack: Number(gearSummary?.backpack ?? 0),
bodyArmor: Number(gearSummary?.body_armor ?? 0)
},
pagination,
filter
};
};
const parseEffects = (raw: FormDataEntryValue | null): SharedEffect[] => {
const value = JSON.parse(String(raw ?? '[]')) as Array<{ statId: unknown; valueI: unknown; valueII: unknown }>;
if (!Array.isArray(value)) throw new Error();
return value.map((effect) => ({
statId: String(effect.statId),
valueI: Number(effect.valueI),
valueII: Number(effect.valueII)
}));
};
const displayValue = (value: number, unit: unknown) => {
const normalizedUnit = unit === 'flat' ? '' : String(unit ?? '');
return normalizedUnit === 'Shots' ? `${value} ${normalizedUnit}` : `${value}${normalizedUnit}`;
};
const optionalPositiveNumber = (form: FormData, name: string) => {
const raw = String(form.get(name) ?? '').trim();
return raw ? Number(raw) : null;
};
const renderMechanicValue = (value: number, singular: string, plural = `${singular}s`) =>
`${value} ${value === 1 ? singular : plural}`;
const renderDescription = (
template: string,
effects: SharedEffect[],
units: Map<string, unknown>,
tier: 'I' | 'II',
durationSeconds: number | null,
maxStacks: number | null
) => template.replace(/\{(\d+|duration|max_stacks)\}/g, (placeholder, expression) => {
if (expression === 'duration') return durationSeconds === null ? placeholder : renderMechanicValue(durationSeconds, 'second');
if (expression === 'max_stacks') return maxStacks === null ? placeholder : renderMechanicValue(maxStacks, 'stack');
const effect = effects[Number(expression) - 1];
return effect ? displayValue(tier === 'I' ? effect.valueI : effect.valueII, units.get(effect.statId)) : placeholder;
});
export const actions: Actions = {
saveTrigger: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').trim(), description = String(form.get('description') ?? '').trim(), uptime = form.get('supportsUptime') === 'on';
const min = uptime ? Number(form.get('minUptime')) : null, max = uptime ? Number(form.get('maxUptime')) : null;
if (name.length < 2 || !description) return fail(400, { triggerError: 'A trigger name and description are required.' });
if (uptime && (!Number.isFinite(min) || !Number.isFinite(max) || Number(min) < 0 || Number(max) > 100 || Number(min) > Number(max))) return fail(400, { triggerError: 'Uptime must be a valid 0–100% range.' });
const db = await getDatabase();
if (await (await db.prepare('SELECT id FROM talent_triggers WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) return fail(409, { triggerError: 'That trigger already exists.' });
const now = new Date().toISOString(), triggerId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
const before = id ? await auditRecord('talent_triggers', id) : null;
if (id) await (await db.prepare('UPDATE talent_triggers SET name=?,description=?,supports_uptime=?,min_uptime=?,max_uptime=?,updated_at=? WHERE id=?')).run(name, description, uptime ? 1 : 0, min, max, now, id);
else await (await db.prepare('INSERT INTO talent_triggers(id,name,description,supports_uptime,min_uptime,max_uptime,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)')).run(triggerId, name, description, uptime ? 1 : 0, min, max, now, now);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'talent_trigger.updated' : 'talent_trigger.created', entityId: triggerId, before, after: await auditRecord('talent_triggers', triggerId) });
return { triggerSaved: true, triggerMode: id ? 'edit' : 'add', resultToken: randomUUID() };
},
saveTalent: async ({ request, cookies }) => {
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
const form = await request.formData();
let iconMutation;
try { iconMutation = await readIconMutation(form); }
catch (error) { if (error instanceof GameDataIconValidationError) return fail(400, { talentError: error.message }); throw error; }
const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').trim(), category = String(form.get('category') ?? '');
const descriptionTemplate = String(form.get('descriptionTemplate') ?? '').trim();
const cooldownSeconds = optionalPositiveNumber(form, 'cooldownSeconds');
const durationSeconds = optionalPositiveNumber(form, 'durationSeconds');
const maxStacks = optionalPositiveNumber(form, 'maxStacks');
const triggerIds = [...new Set(form.getAll('triggerIds').map(String))];
let sharedEffects: SharedEffect[];
try { sharedEffects = parseEffects(form.get('effects')); } catch { return fail(400, { talentError: 'Effects could not be read.' }); }
if (name.length < 2 || !['Weapon Talent 1', 'Weapon Talent 2', 'Backpack', 'Body Armor'].includes(category)) return fail(400, { talentError: 'Provide a valid talent name and category. Exotic talents are managed from their owning weapon.' });
if (!descriptionTemplate || !triggerIds.length || !sharedEffects.length) return fail(400, { talentError: 'A description template, trigger, and at least one effect are required.' });
if ([cooldownSeconds, durationSeconds].some((value) => value !== null && (!Number.isFinite(value) || value <= 0))) return fail(400, { talentError: 'Cooldown and duration must be positive numbers when defined.' });
if (maxStacks !== null && (!Number.isInteger(maxStacks) || maxStacks <= 0)) return fail(400, { talentError: 'Maximum stacks must be a positive whole number when defined.' });
if (sharedEffects.some((effect) => !effect.statId || !Number.isFinite(effect.valueI) || !Number.isFinite(effect.valueII))) return fail(400, { talentError: 'Every effect needs valid Tier I and Tier II values.' });
if (new Set(sharedEffects.map((effect) => effect.statId)).size !== sharedEffects.length) return fail(400, { talentError: 'Each affected attribute can only be selected once.' });
const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1]));
if (placeholders.some((index) => index < 1 || index > sharedEffects.length)) return fail(400, { talentError: 'Description placeholders must reference an existing effect.' });
if (descriptionTemplate.includes('{duration}') && durationSeconds === null) return fail(400, { talentError: 'The description uses {duration}, but no duration is defined.' });
if (descriptionTemplate.includes('{max_stacks}') && maxStacks === null) return fail(400, { talentError: 'The description uses {max_stacks}, but no maximum stack count is defined.' });
const storedCategory = category === 'Body Armor' ? 'Vest' : category.startsWith('Weapon Talent') ? 'Weapon' : category;
const db = await getDatabase();
if (await (await db.prepare('SELECT id FROM talents WHERE name=? COLLATE NOCASE AND category=? AND id<>?')).get(name, storedCategory, id || 'new')) return fail(409, { talentError: 'That talent already exists in this category.' });
const statRows = await (await db.prepare(`SELECT id,unit FROM stat_attributes WHERE active=1 AND id IN (${sharedEffects.map(() => '?').join(',')})`)).all(...sharedEffects.map((effect) => effect.statId));
if (statRows.length !== sharedEffects.length) return fail(400, { talentError: 'One or more selected attributes are unavailable.' });
const units = new Map(statRows.map((stat) => [String(stat.id), stat.unit]));
const talentId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(), now = new Date().toISOString();
const before = id ? { record: await auditRecord('talents', id), icon: await gameDataIconMetadata(db, 'talent', id), template: await (await db.prepare('SELECT template FROM talent_description_templates WHERE talent_id=?')).get(id), tiers: await (await db.prepare('SELECT tier,description FROM talent_tiers WHERE talent_id=? ORDER BY tier')).all(id) } : null;
if (id) await (await db.prepare('UPDATE talents SET name=?,category=?,cooldown_seconds=?,duration_seconds=?,max_stacks=?,updated_at=? WHERE id=?')).run(name, storedCategory, cooldownSeconds, durationSeconds, maxStacks, now, id);
else await (await db.prepare('INSERT INTO talents(id,name,category,cooldown_seconds,duration_seconds,max_stacks,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)')).run(talentId, name, storedCategory, cooldownSeconds, durationSeconds, maxStacks, now, now);
if (storedCategory === 'Weapon') {
await (await db.prepare(`INSERT INTO talent_weapon_scopes(talent_id,scope) VALUES(?,?)
ON CONFLICT(talent_id) DO UPDATE SET scope=excluded.scope`))
.run(talentId, 'Standard');
await (await db.prepare(`INSERT INTO weapon_talent_slots(talent_id,slot) VALUES(?,?)
ON CONFLICT(talent_id) DO UPDATE SET slot=excluded.slot`))
.run(talentId, category === 'Weapon Talent 1' ? 1 : 2);
} else {
await (await db.prepare('DELETE FROM talent_weapon_scopes WHERE talent_id=?')).run(talentId);
await (await db.prepare('DELETE FROM weapon_talent_slots WHERE talent_id=?')).run(talentId);
}
await (await db.prepare(`INSERT INTO talent_description_templates(talent_id,template,updated_at) VALUES(?,?,?)
ON CONFLICT(talent_id) DO UPDATE SET template=excluded.template,updated_at=excluded.updated_at`)).run(talentId, descriptionTemplate, now);
for (const tier of ['I', 'II'] as const) {
const description = renderDescription(descriptionTemplate, sharedEffects, units, tier, durationSeconds, maxStacks);
await (await db.prepare(`INSERT INTO talent_tiers(id,talent_id,tier,description,trigger_text,effect_text,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?)
ON CONFLICT(talent_id,tier) DO UPDATE SET description=excluded.description,updated_at=excluded.updated_at`)).run(randomUUID(), talentId, tier, description, '', '', now, now);
const tierRow = await (await db.prepare('SELECT id FROM talent_tiers WHERE talent_id=? AND tier=?')).get(talentId, tier);
const tierId = String(tierRow?.id);
await (await db.prepare('DELETE FROM talent_tier_triggers WHERE talent_tier_id=?')).run(tierId);
await (await db.prepare('DELETE FROM talent_tier_effects WHERE talent_tier_id=?')).run(tierId);
for (const triggerId of triggerIds) await (await db.prepare('INSERT INTO talent_tier_triggers(talent_tier_id,trigger_id) VALUES(?,?)')).run(tierId, triggerId);
for (const effect of sharedEffects) await (await db.prepare('INSERT INTO talent_tier_effects(id,talent_tier_id,stat_attribute_id,value,created_at) VALUES(?,?,?,?,?)')).run(randomUUID(), tierId, effect.statId, tier === 'I' ? effect.valueI : effect.valueII, now);
}
await applyIconMutation(db, 'talent', talentId, iconMutation);
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'talent.updated' : 'talent.created', entityId: talentId, before, after: { record: await auditRecord('talents', talentId), icon: await gameDataIconMetadata(db, 'talent', talentId), category, descriptionTemplate, triggerIds, effects: sharedEffects } });
return { talentSaved: true, talentMode: id ? 'edit' : 'add', resultToken: randomUUID() };
}
};