Files
shd.build/src/routes/isac/data-moderation/+page.server.ts
T
jb 4777a3abd5
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Successful in 44s
Validate and deploy staging / deploy (push) Successful in 1m14s
feat: refine builder skill chip flows
2026-08-06 01:00:15 -04:00

662 lines
22 KiB
TypeScript

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() };
},
};