feat: add community publishing and news syndication
This commit is contained in:
@@ -3,7 +3,10 @@ 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 {
|
||||
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';
|
||||
@@ -11,258 +14,625 @@ import { createNotification } from '$lib/server/notifications';
|
||||
import { conditionalRuleSnapshot } from '$lib/server/conditional-effects';
|
||||
|
||||
const statuses = ['pending', 'approved', 'rejected', 'failed'];
|
||||
const modules = ['configuration', 'calculations', 'specializations', 'attributes', 'talents', 'brands', 'gear', 'weapons', 'weaponmods', 'gearmods', 'protocols', 'skillchips'];
|
||||
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 };
|
||||
| { 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)
|
||||
};
|
||||
}
|
||||
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;
|
||||
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),
|
||||
};
|
||||
}
|
||||
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,
|
||||
primaryOptions: await (await database.prepare(
|
||||
'SELECT stat_attribute_id FROM skill_chip_primary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id'
|
||||
)).all(id),
|
||||
secondaryOptions: await (await database.prepare(
|
||||
'SELECT stat_attribute_id FROM skill_chip_secondary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id'
|
||||
)).all(id),
|
||||
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
|
||||
};
|
||||
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,
|
||||
primaryOptions: await (
|
||||
await database.prepare(
|
||||
'SELECT stat_attribute_id FROM skill_chip_primary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id',
|
||||
)
|
||||
).all(id),
|
||||
secondaryOptions: await (
|
||||
await database.prepare(
|
||||
'SELECT stat_attribute_id FROM skill_chip_secondary_options WHERE skill_chip_set_id=? ORDER BY stat_attribute_id',
|
||||
)
|
||||
).all(id),
|
||||
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;
|
||||
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,
|
||||
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.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 };
|
||||
${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.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
|
||||
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() };
|
||||
}
|
||||
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() };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
<div class="isac-confirm-modal moderation-review" role="dialog" aria-modal="true" aria-labelledby="moderation-title">
|
||||
<div class="editor-title"><div><p class="eyebrow">CHANGE CONTROL / {reviewing.status.toUpperCase()}</p><h3 id="moderation-title">{reviewing.summary}</h3></div><button aria-label="Close review" onclick={() => reviewing = null}>×</button></div>
|
||||
<div class="moderation-metadata"><span>DATA MODULE <b>{moduleLabel(reviewing.module)}</b></span><span>REQUESTED ACTION <b>{operationLabel(reviewing.action)}</b></span><span>CONTRIBUTOR <b>{reviewing.proposer_name}</b></span><span>SUBMITTED <b>{date(reviewing.created_at)}</b></span></div>
|
||||
{#if reviewing.contributor_note}<div class="review-note"><span>CONTRIBUTOR RATIONALE</span><p>{reviewing.contributor_note}</p></div>{/if}
|
||||
<ProposalChangePreview module={reviewing.module} action={reviewing.action} payload={reviewing.payload_json} currentSnapshot={reviewing.current_snapshot}/>
|
||||
<details>
|
||||
<summary>RAW SUBMISSION EVIDENCE · {evidence(reviewing.payload_json).length} FIELDS</summary>
|
||||
|
||||
Reference in New Issue
Block a user