Rename admin routes to ISAC
This commit is contained in:
@@ -0,0 +1,200 @@
|
||||
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 { getPagination } from '$lib/server/pagination';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
|
||||
|
||||
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'];
|
||||
const attributeTypes = ['Firepower', 'Toughness', 'Engineering'];
|
||||
const secondaryRoles = ['Core', 'Damage', 'Health'];
|
||||
const expectedSecondaryAttributes: Record<string, Record<string, string>> = {
|
||||
Firepower: { Core: 'Firepower', Damage: 'All Weapon Damage', Health: 'Health' },
|
||||
Toughness: { Core: 'Toughness', Damage: 'All Weapon Damage', Health: 'Health' },
|
||||
Engineering: { Core: 'Engineering', Damage: 'Skill Damage', Health: 'Health' }
|
||||
};
|
||||
|
||||
const parseList = (value: FormDataEntryValue | null): any[] => {
|
||||
try {
|
||||
const parsed = JSON.parse(String(value ?? '[]'));
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
async function protocolSnapshot(id: string) {
|
||||
const database = await getDatabase();
|
||||
const talent = await (await database.prepare('SELECT * FROM os_protocol_talents WHERE protocol_id=?')).get(id);
|
||||
return {
|
||||
record: await auditRecord('os_protocols', id),
|
||||
icon: await gameDataIconMetadata(database, 'os_protocol', id),
|
||||
primaryEffect: await (await database.prepare('SELECT * FROM os_protocol_primary_effects WHERE protocol_id=?')).get(id) ?? null,
|
||||
talent: talent ?? null,
|
||||
talentTriggers: talent ? await (await database.prepare('SELECT trigger_id FROM os_protocol_talent_triggers WHERE talent_id=? ORDER BY trigger_id')).all(talent.id) : [],
|
||||
talentEffects: talent ? await (await database.prepare('SELECT * FROM os_protocol_talent_effects WHERE talent_id=? ORDER BY sort_order')).all(talent.id) : []
|
||||
};
|
||||
}
|
||||
|
||||
async function profileSnapshot(id: string) {
|
||||
const database = await getDatabase();
|
||||
return {
|
||||
record: await (await database.prepare('SELECT * FROM os_protocol_secondary_profiles WHERE id=?')).get(id) ?? null,
|
||||
effects: await (await database.prepare('SELECT * FROM os_protocol_secondary_effects WHERE profile_id=? ORDER BY effect_role')).all(id)
|
||||
};
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
await requireDataView(await getSessionUser(cookies));
|
||||
const database = await getDatabase();
|
||||
const requestedFilter = url.searchParams.get('filter') ?? '';
|
||||
const filter = [...qualities.map((quality) => quality.toLowerCase()), ...attributeTypes.map((type) => type.toLowerCase()), 'active', 'archived'].includes(requestedFilter)
|
||||
? requestedFilter : '';
|
||||
const conditions: string[] = [];
|
||||
const args: unknown[] = [];
|
||||
if (filter === 'active' || filter === 'archived') conditions.push(`active=${filter === 'active' ? 1 : 0}`);
|
||||
else if (qualities.some((quality) => quality.toLowerCase() === filter)) {
|
||||
conditions.push('quality=?');
|
||||
args.push(qualities.find((quality) => quality.toLowerCase() === filter));
|
||||
} else if (attributeTypes.some((type) => type.toLowerCase() === filter)) {
|
||||
conditions.push('primary_attribute_type=?');
|
||||
args.push(attributeTypes.find((type) => type.toLowerCase() === filter));
|
||||
}
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const [summary, filtered] = 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 quality='Standard' THEN 1 ELSE 0 END) AS standard,
|
||||
SUM(CASE WHEN quality='Specialized' THEN 1 ELSE 0 END) AS specialized,
|
||||
SUM(CASE WHEN quality='Superior' THEN 1 ELSE 0 END) AS superior,
|
||||
SUM(CASE WHEN quality='High-end' THEN 1 ELSE 0 END) AS high_end
|
||||
FROM os_protocols`)).get(),
|
||||
(await database.prepare(`SELECT COUNT(*) AS total FROM os_protocols ${where}`)).get(...args)
|
||||
]);
|
||||
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
||||
const [protocols, primaryEffects, profiles, secondaryEffects, talents, talentTriggers, talentEffects, stats, triggers, icons] = await Promise.all([
|
||||
(await database.prepare(`SELECT * FROM os_protocols ${where} ORDER BY active DESC,quality,name COLLATE NOCASE LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset),
|
||||
(await database.prepare('SELECT * FROM os_protocol_primary_effects')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_secondary_profiles ORDER BY quality,primary_attribute_type')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_secondary_effects ORDER BY effect_role')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_talents')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_talent_triggers')).all(),
|
||||
(await database.prepare('SELECT * FROM os_protocol_talent_effects ORDER BY sort_order')).all(),
|
||||
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT id,name,description FROM talent_triggers WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='os_protocol'")).all()
|
||||
]);
|
||||
const configuredProfiles = profiles.map((profile) => ({ ...profile, effects: secondaryEffects.filter((effect) => effect.profile_id === profile.id) }));
|
||||
return {
|
||||
protocols: protocols.map((protocol) => {
|
||||
const talent = talents.find((item) => item.protocol_id === protocol.id);
|
||||
return {
|
||||
...protocol,
|
||||
iconUrl: (() => { const icon = icons.find((item) => item.entity_id === protocol.id); return icon ? gameDataIconUrl('os_protocol', String(protocol.id), icon.updated_at) : ''; })(),
|
||||
primaryEffect: primaryEffects.find((item) => item.protocol_id === protocol.id) ?? null,
|
||||
secondaryProfile: configuredProfiles.find((profile) => profile.quality === protocol.quality && profile.primary_attribute_type === protocol.primary_attribute_type) ?? null,
|
||||
talent: talent ? {
|
||||
...talent,
|
||||
triggerIds: talentTriggers.filter((item) => item.talent_id === talent.id).map((item) => String(item.trigger_id)),
|
||||
effects: talentEffects.filter((item) => item.talent_id === talent.id)
|
||||
} : null
|
||||
};
|
||||
}),
|
||||
profiles: qualities.flatMap((quality) => attributeTypes.map((attributeType) => configuredProfiles.find((profile) =>
|
||||
profile.quality === quality && profile.primary_attribute_type === attributeType
|
||||
) ?? { id: '', quality, primary_attribute_type: attributeType, effects: [] })),
|
||||
stats, triggers, summary, filter, pagination
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
saveProfile: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const form = await request.formData();
|
||||
const quality = String(form.get('quality') ?? '');
|
||||
const attributeType = String(form.get('attributeType') ?? '');
|
||||
if (!qualities.includes(quality) || !attributeTypes.includes(attributeType)) return fail(400, { profileError: 'Choose a valid quality and attribute type.' });
|
||||
const database = await getDatabase();
|
||||
const expected = expectedSecondaryAttributes[attributeType];
|
||||
const statRows = await (await database.prepare(`SELECT id,name FROM stat_attributes WHERE active=1 AND name IN (?,?,?) COLLATE NOCASE`)).all(expected.Core, expected.Damage, expected.Health);
|
||||
const statByName = new Map(statRows.map((stat) => [String(stat.name).toLowerCase(), String(stat.id)]));
|
||||
if (statRows.length !== 3) return fail(400, { profileError: `Create active stats named ${Object.values(expected).join(', ')} before configuring this profile.` });
|
||||
const effects = secondaryRoles.map((role) => ({ role, statId: statByName.get(expected[role].toLowerCase()) ?? '', value: Number(form.get(`value${role}`)) }));
|
||||
if (effects.some((effect) => !effect.statId || !Number.isFinite(effect.value) || effect.value < 0)) return fail(400, { profileError: 'Every shared secondary attribute requires a non-negative value.' });
|
||||
const existing = await (await database.prepare('SELECT id FROM os_protocol_secondary_profiles WHERE quality=? AND primary_attribute_type=?')).get(quality, attributeType);
|
||||
const profileId = String(existing?.id ?? randomUUID()), now = new Date().toISOString();
|
||||
const before = existing ? await profileSnapshot(profileId) : null;
|
||||
if (existing) await (await database.prepare('UPDATE os_protocol_secondary_profiles SET updated_at=? WHERE id=?')).run(now, profileId);
|
||||
else await (await database.prepare('INSERT INTO os_protocol_secondary_profiles(id,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?)')).run(profileId, quality, attributeType, now, now);
|
||||
await (await database.prepare('DELETE FROM os_protocol_secondary_effects WHERE profile_id=?')).run(profileId);
|
||||
for (const effect of effects) await (await database.prepare('INSERT INTO os_protocol_secondary_effects(id,profile_id,effect_role,stat_attribute_id,value) VALUES(?,?,?,?,?)')).run(randomUUID(), profileId, effect.role, effect.statId, effect.value);
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: existing ? 'os_protocol_profile.updated' : 'os_protocol_profile.created', entityId: profileId, before, after: await profileSnapshot(profileId) });
|
||||
return { profileSaved: true, resultToken: randomUUID() };
|
||||
},
|
||||
save: 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, { protocolError: error.message }); throw error; }
|
||||
const id = String(form.get('id') ?? '');
|
||||
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
||||
const quality = String(form.get('quality') ?? '');
|
||||
const attributeType = String(form.get('attributeType') ?? '');
|
||||
const primaryStatId = String(form.get('primaryStatId') ?? '');
|
||||
const primaryBaseValue = Number(form.get('primaryBaseValue'));
|
||||
const talentName = String(form.get('talentName') ?? '').normalize('NFKC').trim();
|
||||
const descriptionTemplate = String(form.get('descriptionTemplate') ?? '').normalize('NFKC').trim();
|
||||
const triggerIds = [...new Set(form.getAll('triggerIds').map(String).filter(Boolean))];
|
||||
const talentEffects = parseList(form.get('talentEffects')).map((item, index) => ({
|
||||
statId: String(item.statId ?? ''), baseValue: Number(item.baseValue), sortOrder: index + 1
|
||||
}));
|
||||
if (name.length < 2 || name.length > 80) return fail(400, { protocolError: 'Protocol names must be 2–80 characters.' });
|
||||
if (!qualities.includes(quality) || !attributeTypes.includes(attributeType)) return fail(400, { protocolError: 'Choose a valid quality and primary attribute type.' });
|
||||
if (!primaryStatId || !Number.isFinite(primaryBaseValue) || primaryBaseValue < 0) return fail(400, { protocolError: 'Choose the protocol’s unique primary stat and a non-negative level-1 base value.' });
|
||||
if (talentName.length < 2 || talentName.length > 80 || !descriptionTemplate || !triggerIds.length || !talentEffects.length) return fail(400, { protocolError: 'The unique talent requires a name, description template, trigger, and at least one effect.' });
|
||||
if (talentEffects.length > 12 || talentEffects.some((item) => !item.statId || !Number.isFinite(item.baseValue))) return fail(400, { protocolError: 'Talent effects require valid attributes and base values.' });
|
||||
if (new Set(talentEffects.map((item) => item.statId)).size !== talentEffects.length) return fail(400, { protocolError: 'Each talent effect may reference an attribute only once.' });
|
||||
const placeholders = [...descriptionTemplate.matchAll(/\{(\d+)\}/g)].map((match) => Number(match[1]));
|
||||
if (placeholders.some((index) => index < 1 || index > talentEffects.length)) return fail(400, { protocolError: 'Description placeholders must reference configured talent effects.' });
|
||||
const database = await getDatabase();
|
||||
if (!await (await database.prepare('SELECT id FROM os_protocol_secondary_profiles WHERE quality=? AND primary_attribute_type=?')).get(quality, attributeType)) return fail(400, { protocolError: 'Configure the shared secondary profile for this quality and attribute type first.' });
|
||||
if (await (await database.prepare('SELECT id FROM os_protocols WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) return fail(409, { protocolError: 'That OS Protocol already exists.' });
|
||||
const existingTalent = id ? await (await database.prepare('SELECT id FROM os_protocol_talents WHERE protocol_id=?')).get(id) : null;
|
||||
if (await (await database.prepare('SELECT id FROM os_protocol_talents WHERE name=? COLLATE NOCASE AND id<>?')).get(talentName, existingTalent?.id ?? 'new')) return fail(409, { protocolError: 'That protocol talent name is already in use.' });
|
||||
const statIds = [...new Set([primaryStatId, ...talentEffects.map((item) => item.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, { protocolError: 'One or more selected attributes are unavailable.' });
|
||||
const validTriggers = await (await database.prepare(`SELECT COUNT(*) AS count FROM talent_triggers WHERE active=1 AND id IN (${triggerIds.map(() => '?').join(',')})`)).get(...triggerIds);
|
||||
if (Number(validTriggers?.count) !== triggerIds.length) return fail(400, { protocolError: 'One or more selected triggers are unavailable.' });
|
||||
const protocolId = id || randomUUID(), talentId = String(existingTalent?.id ?? randomUUID()), now = new Date().toISOString();
|
||||
const before = id ? await protocolSnapshot(id) : null;
|
||||
if (id) {
|
||||
if (!await auditRecord('os_protocols', id)) return fail(404, { protocolError: 'OS Protocol not found.' });
|
||||
await (await database.prepare('UPDATE os_protocols SET name=?,quality=?,primary_attribute_type=?,active=1,updated_at=? WHERE id=?')).run(name, quality, attributeType, now, id);
|
||||
} else await (await database.prepare('INSERT INTO os_protocols(id,name,quality,primary_attribute_type,created_at,updated_at) VALUES(?,?,?,?,?,?)')).run(protocolId, name, quality, attributeType, now, now);
|
||||
await (await database.prepare(`INSERT INTO os_protocol_primary_effects(id,protocol_id,stat_attribute_id,base_value) VALUES(?,?,?,?)
|
||||
ON CONFLICT(protocol_id) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,base_value=excluded.base_value`)).run(randomUUID(), protocolId, primaryStatId, primaryBaseValue);
|
||||
await (await database.prepare(`INSERT INTO os_protocol_talents(id,protocol_id,name,description_template,created_at,updated_at) VALUES(?,?,?,?,?,?)
|
||||
ON CONFLICT(protocol_id) DO UPDATE SET name=excluded.name,description_template=excluded.description_template,updated_at=excluded.updated_at`)).run(talentId, protocolId, talentName, descriptionTemplate, now, now);
|
||||
await (await database.prepare('DELETE FROM os_protocol_talent_triggers WHERE talent_id=?')).run(talentId);
|
||||
for (const triggerId of triggerIds) await (await database.prepare('INSERT INTO os_protocol_talent_triggers(talent_id,trigger_id) VALUES(?,?)')).run(talentId, triggerId);
|
||||
await (await database.prepare('DELETE FROM os_protocol_talent_effects WHERE talent_id=?')).run(talentId);
|
||||
for (const effect of talentEffects) await (await database.prepare('INSERT INTO os_protocol_talent_effects(id,talent_id,stat_attribute_id,base_value,sort_order) VALUES(?,?,?,?,?)')).run(randomUUID(), talentId, effect.statId, effect.baseValue, effect.sortOrder);
|
||||
await applyIconMutation(database, 'os_protocol', protocolId, iconMutation);
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'os_protocol.updated' : 'os_protocol.created', entityId: protocolId, before, after: await protocolSnapshot(protocolId) });
|
||||
return { protocolSaved: true, protocolMode: 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 protocolSnapshot(id);
|
||||
if (!before.record) return fail(404, { protocolError: 'OS Protocol not found.' });
|
||||
await (await (await getDatabase()).prepare('UPDATE os_protocols SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'os_protocol.archived', entityId: id, before, after: await protocolSnapshot(id) });
|
||||
return { protocolArchived: true };
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user