feat: add community publishing and news syndication
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
import { getPermissions, requirePermission } from '$lib/server/auth/authorization';
|
||||
import {
|
||||
getPermissions,
|
||||
requirePermission,
|
||||
} from '$lib/server/auth/authorization';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { getPagination, getRegistrySort } from '$lib/server/pagination';
|
||||
@@ -8,83 +11,137 @@ import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const reversibleActions = new Set([
|
||||
'attribute.updated', 'attribute.archived',
|
||||
'brand_set.archived', 'gear.archived', 'weapon.archived',
|
||||
'role.updated', 'access.updated'
|
||||
'attribute.updated',
|
||||
'attribute.archived',
|
||||
'brand_set.archived',
|
||||
'gear.archived',
|
||||
'weapon.archived',
|
||||
'role.updated',
|
||||
'access.updated',
|
||||
]);
|
||||
|
||||
const parseSnapshot = (value: unknown): Record<string, any> | null => {
|
||||
if (typeof value !== 'string' || !value) return null;
|
||||
try { const parsed = JSON.parse(value); return parsed && typeof parsed === 'object' ? parsed : null; } catch { return null; }
|
||||
if (typeof value !== 'string' || !value) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
return parsed && typeof parsed === 'object' ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const normalize = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) {
|
||||
const normalized = value.map(normalize);
|
||||
return normalized.every((item) => ['string', 'number', 'boolean'].includes(typeof item))
|
||||
? normalized.sort((left, right) => String(left).localeCompare(String(right)))
|
||||
: normalized;
|
||||
}
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
return Object.fromEntries(Object.entries(value as Record<string, unknown>)
|
||||
.filter(([key]) => !['created_at', 'updated_at'].includes(key))
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => [key, normalize(item)]));
|
||||
if (Array.isArray(value)) {
|
||||
const normalized = value.map(normalize);
|
||||
return normalized.every((item) =>
|
||||
['string', 'number', 'boolean'].includes(typeof item),
|
||||
)
|
||||
? normalized.sort((left, right) =>
|
||||
String(left).localeCompare(String(right)),
|
||||
)
|
||||
: normalized;
|
||||
}
|
||||
if (!value || typeof value !== 'object') return value;
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.filter(([key]) => !['created_at', 'updated_at'].includes(key))
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, item]) => [key, normalize(item)]),
|
||||
);
|
||||
};
|
||||
const comparable = (value: unknown): string => JSON.stringify(normalize(value));
|
||||
|
||||
const sameRecord = (current: Record<string, unknown> | null, expected: Record<string, unknown> | null) => {
|
||||
if (!current || !expected) return false;
|
||||
return Object.entries(expected).every(([key, value]) =>
|
||||
!['created_at', 'updated_at'].includes(key) && comparable(current[key]) === comparable(value)
|
||||
);
|
||||
const sameRecord = (
|
||||
current: Record<string, unknown> | null,
|
||||
expected: Record<string, unknown> | null,
|
||||
) => {
|
||||
if (!current || !expected) return false;
|
||||
return Object.entries(expected).every(
|
||||
([key, value]) =>
|
||||
!['created_at', 'updated_at'].includes(key) &&
|
||||
comparable(current[key]) === comparable(value),
|
||||
);
|
||||
};
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'audit.view');
|
||||
const canRevert = (await getPermissions(operator)).includes('*') || (await getPermissions(operator)).includes('audit.revert');
|
||||
const database = await getDatabase();
|
||||
const query = url.searchParams.get('q')?.trim().slice(0, 120) ?? '';
|
||||
const category = url.searchParams.get('category')?.trim() ?? '';
|
||||
const action = url.searchParams.get('action')?.trim() ?? '';
|
||||
const sort = getRegistrySort(url, {
|
||||
event: {
|
||||
asc: 'audit_log.action COLLATE NOCASE ASC,audit_log.entity_type COLLATE NOCASE ASC',
|
||||
desc: 'audit_log.action COLLATE NOCASE DESC,audit_log.entity_type COLLATE NOCASE DESC'
|
||||
},
|
||||
operator: {
|
||||
asc: 'COALESCE(user_profiles.display_name,users.username) COLLATE NOCASE ASC',
|
||||
desc: 'COALESCE(user_profiles.display_name,users.username) COLLATE NOCASE DESC'
|
||||
},
|
||||
target: { asc: 'audit_log.entity_id COLLATE NOCASE ASC', desc: 'audit_log.entity_id COLLATE NOCASE DESC' },
|
||||
time: {
|
||||
asc: 'audit_log.created_at ASC',
|
||||
desc: 'audit_log.created_at DESC',
|
||||
defaultDirection: 'desc'
|
||||
}
|
||||
}, 'time', 'desc');
|
||||
const conditions: string[] = [];
|
||||
const args: unknown[] = [];
|
||||
if (query) {
|
||||
conditions.push(`(audit_log.entity_id LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'audit.view',
|
||||
);
|
||||
const canRevert =
|
||||
(await getPermissions(operator)).includes('*') ||
|
||||
(await getPermissions(operator)).includes('audit.revert');
|
||||
const database = await getDatabase();
|
||||
const query = url.searchParams.get('q')?.trim().slice(0, 120) ?? '';
|
||||
const category = url.searchParams.get('category')?.trim() ?? '';
|
||||
const action = url.searchParams.get('action')?.trim() ?? '';
|
||||
const sort = getRegistrySort(
|
||||
url,
|
||||
{
|
||||
event: {
|
||||
asc: 'audit_log.action COLLATE NOCASE ASC,audit_log.entity_type COLLATE NOCASE ASC',
|
||||
desc: 'audit_log.action COLLATE NOCASE DESC,audit_log.entity_type COLLATE NOCASE DESC',
|
||||
},
|
||||
operator: {
|
||||
asc: 'COALESCE(user_profiles.display_name,users.username) COLLATE NOCASE ASC',
|
||||
desc: 'COALESCE(user_profiles.display_name,users.username) COLLATE NOCASE DESC',
|
||||
},
|
||||
target: {
|
||||
asc: 'audit_log.entity_id COLLATE NOCASE ASC',
|
||||
desc: 'audit_log.entity_id COLLATE NOCASE DESC',
|
||||
},
|
||||
time: {
|
||||
asc: 'audit_log.created_at ASC',
|
||||
desc: 'audit_log.created_at DESC',
|
||||
defaultDirection: 'desc',
|
||||
},
|
||||
},
|
||||
'time',
|
||||
'desc',
|
||||
);
|
||||
const conditions: string[] = [];
|
||||
const args: unknown[] = [];
|
||||
if (query) {
|
||||
conditions.push(`(audit_log.entity_id LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR audit_log.before_json LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR audit_log.after_json LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR users.username LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR user_profiles.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE)`);
|
||||
const pattern = `%${query.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||
args.push(pattern, pattern, pattern, pattern, pattern);
|
||||
}
|
||||
if (category === 'user') conditions.push("audit_log.entity_type IN ('user','user_access','user_moderation','identity')");
|
||||
else if (category === 'access_policy') conditions.push("audit_log.entity_type IN ('role','role_management')");
|
||||
else if (category) { conditions.push('audit_log.entity_type=?'); args.push(category); }
|
||||
if (action) { conditions.push('audit_log.action=?'); args.push(action); }
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const [total, categories, actions, summary] = await Promise.all([
|
||||
(await database.prepare(`SELECT COUNT(*) AS total FROM audit_log
|
||||
JOIN users ON users.id=audit_log.actor_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id ${where}`)).get(...args),
|
||||
(await database.prepare('SELECT entity_type,COUNT(*) AS count FROM audit_log GROUP BY entity_type ORDER BY entity_type')).all(),
|
||||
(await database.prepare('SELECT action,COUNT(*) AS count FROM audit_log GROUP BY action ORDER BY action')).all(),
|
||||
(await database.prepare(`SELECT COUNT(*) AS total,
|
||||
const pattern = `%${query.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||
args.push(pattern, pattern, pattern, pattern, pattern);
|
||||
}
|
||||
if (category === 'user')
|
||||
conditions.push(
|
||||
"audit_log.entity_type IN ('user','user_access','user_moderation','identity')",
|
||||
);
|
||||
else if (category === 'access_policy')
|
||||
conditions.push("audit_log.entity_type IN ('role','role_management')");
|
||||
else if (category) {
|
||||
conditions.push('audit_log.entity_type=?');
|
||||
args.push(category);
|
||||
}
|
||||
if (action) {
|
||||
conditions.push('audit_log.action=?');
|
||||
args.push(action);
|
||||
}
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const [total, categories, actions, summary] = await Promise.all([
|
||||
(
|
||||
await database.prepare(`SELECT COUNT(*) AS total FROM audit_log
|
||||
JOIN users ON users.id=audit_log.actor_id LEFT JOIN user_profiles ON user_profiles.user_id=users.id ${where}`)
|
||||
).get(...args),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT entity_type,COUNT(*) AS count FROM audit_log GROUP BY entity_type ORDER BY entity_type',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT action,COUNT(*) AS count FROM audit_log GROUP BY action ORDER BY action',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(`SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN entity_type='game_data' THEN 1 ELSE 0 END) AS game_data,
|
||||
SUM(CASE WHEN entity_type='data_moderation' THEN 1 ELSE 0 END) AS data_moderation,
|
||||
SUM(CASE WHEN entity_type='community_moderation' THEN 1 ELSE 0 END) AS community_moderation,
|
||||
@@ -93,95 +150,315 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
SUM(CASE WHEN entity_type='system_configuration' THEN 1 ELSE 0 END) AS system_configuration,
|
||||
SUM(CASE WHEN entity_type='calculation_configuration' THEN 1 ELSE 0 END) AS calculation_configuration,
|
||||
SUM(CASE WHEN entity_type='ai_assistance' THEN 1 ELSE 0 END) AS ai_assistance
|
||||
FROM audit_log`)).get()
|
||||
]);
|
||||
const pagination = getPagination(url, Number(total?.total ?? 0));
|
||||
const entries = await (await database.prepare(`SELECT audit_log.*,
|
||||
FROM audit_log`)
|
||||
).get(),
|
||||
]);
|
||||
const pagination = getPagination(url, Number(total?.total ?? 0));
|
||||
const entries = await (
|
||||
await database.prepare(`SELECT audit_log.*,
|
||||
COALESCE(user_profiles.display_name,users.username) AS actor_name,users.login_username AS actor_login
|
||||
FROM audit_log JOIN users ON users.id=audit_log.actor_id
|
||||
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||
${where} ORDER BY ${sort.orderBy},audit_log.id LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset);
|
||||
const revertedIds = entries.length ? await (await database.prepare(`SELECT entity_id FROM audit_log
|
||||
WHERE entity_type='audit_control' AND action='audit.reverted' AND entity_id IN (${entries.map(() => '?').join(',')})`)).all(...entries.map((entry) => entry.id)) : [];
|
||||
const reverted = new Set(revertedIds.map((entry) => String(entry.entity_id)));
|
||||
return {
|
||||
entries: entries.map((entry) => ({ ...entry, reversible: canRevert && reversibleActions.has(String(entry.action)) && Boolean(entry.before_json) && !reverted.has(String(entry.id)), reverted: reverted.has(String(entry.id)) })),
|
||||
categories, actions, summary, pagination, filters: { query, category, action }, canRevert, sort
|
||||
};
|
||||
${where} ORDER BY ${sort.orderBy},audit_log.id LIMIT ? OFFSET ?`)
|
||||
).all(...args, pagination.perPage, pagination.offset);
|
||||
const revertedIds = entries.length
|
||||
? await (
|
||||
await database.prepare(`SELECT entity_id FROM audit_log
|
||||
WHERE entity_type='audit_control' AND action='audit.reverted' AND entity_id IN (${entries.map(() => '?').join(',')})`)
|
||||
).all(...entries.map((entry) => entry.id))
|
||||
: [];
|
||||
const reverted = new Set(revertedIds.map((entry) => String(entry.entity_id)));
|
||||
return {
|
||||
entries: entries.map((entry) => ({
|
||||
...entry,
|
||||
reversible:
|
||||
canRevert &&
|
||||
reversibleActions.has(String(entry.action)) &&
|
||||
Boolean(entry.before_json) &&
|
||||
!reverted.has(String(entry.id)),
|
||||
reverted: reverted.has(String(entry.id)),
|
||||
})),
|
||||
categories,
|
||||
actions,
|
||||
summary,
|
||||
pagination,
|
||||
filters: { query, category, action },
|
||||
canRevert,
|
||||
sort,
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
revert: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'audit.revert');
|
||||
const operatorPermissions = await getPermissions(operator);
|
||||
const isFullAdmin = operatorPermissions.includes('*');
|
||||
const eventId = String((await request.formData()).get('eventId') ?? '');
|
||||
const database = await getDatabase();
|
||||
const event = await (await database.prepare('SELECT * FROM audit_log WHERE id=?')).get(eventId);
|
||||
if (!event) return fail(404, { revertError: 'Audit event not found.' });
|
||||
if (!reversibleActions.has(String(event.action)) || !event.before_json) return fail(400, { revertError: 'This event does not contain a supported reversible change.' });
|
||||
if (await (await database.prepare("SELECT id FROM audit_log WHERE entity_type='audit_control' AND action='audit.reverted' AND entity_id=?")).get(eventId)) {
|
||||
return fail(409, { revertError: 'This audit event has already been reverted.' });
|
||||
}
|
||||
const before = parseSnapshot(event.before_json), after = parseSnapshot(event.after_json);
|
||||
if (!before || !after) return fail(400, { revertError: 'The stored snapshots cannot be safely interpreted.' });
|
||||
const action = String(event.action), targetId = String(event.entity_id);
|
||||
let current: Record<string, unknown> | null = null;
|
||||
let restored: Record<string, unknown> | null = null;
|
||||
try {
|
||||
if (action.startsWith('attribute.')) {
|
||||
current = await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(targetId) ?? null;
|
||||
if (!sameRecord(current, after)) return fail(409, { revertError: 'The attribute changed after this event. Reversion was blocked to preserve newer work.' });
|
||||
await (await database.prepare(`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,
|
||||
weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,active=?,updated_at=? WHERE id=?`))
|
||||
.run(before.name,before.description ?? null,before.type,before.category,before.calculation_type,before.unit,before.gear_min,before.gear_max,before.weapon_min,before.weapon_max,before.skill_chip_min,before.skill_chip_max,before.cap_value,before.active,new Date().toISOString(),targetId);
|
||||
restored = await (await database.prepare('SELECT * FROM stat_attributes WHERE id=?')).get(targetId) ?? null;
|
||||
} else if (action.endsWith('.archived')) {
|
||||
const table = action.startsWith('brand_set.') ? 'brand_sets' : action.startsWith('gear.') ? 'gear' : action.startsWith('weapon.') ? 'weapons' : '';
|
||||
if (!table) return fail(400, { revertError: 'Unsupported archive category.' });
|
||||
current = await (await database.prepare(`SELECT * FROM ${table} WHERE id=?`)).get(targetId) ?? null;
|
||||
if (!sameRecord(current, after)) return fail(409, { revertError: 'The record changed after it was archived. Reversion was blocked.' });
|
||||
await (await database.prepare(`UPDATE ${table} SET active=?,updated_at=? WHERE id=?`)).run(before.active, new Date().toISOString(), targetId);
|
||||
restored = await (await database.prepare(`SELECT * FROM ${table} WHERE id=?`)).get(targetId) ?? null;
|
||||
} else if (action === 'role.updated') {
|
||||
const currentRole = await (await database.prepare('SELECT name,description FROM roles WHERE id=?')).get(targetId);
|
||||
const currentPermissions = await (await database.prepare(`SELECT permissions.name FROM role_permissions JOIN permissions ON permissions.id=role_permissions.permission_id WHERE role_id=? ORDER BY permissions.name`)).all(targetId);
|
||||
current = { ...currentRole, permissions: currentPermissions.map((row) => row.name) };
|
||||
if (comparable(current) !== comparable(after)) return fail(409, { revertError: 'The role changed after this event. Reversion was blocked.' });
|
||||
const permissionNames = Array.isArray(before.permissions) ? before.permissions.map(String) : [];
|
||||
if (permissionNames.includes('system.admin') && !isFullAdmin) return fail(403, { revertError: 'Only a full administrator can restore unrestricted system access.' });
|
||||
const permissionRows = permissionNames.length ? await (await database.prepare(`SELECT id,name FROM permissions WHERE name IN (${permissionNames.map(() => '?').join(',')})`)).all(...permissionNames) : [];
|
||||
if (permissionRows.length !== permissionNames.length) return fail(409, { revertError: 'A previous permission no longer exists.' });
|
||||
await (await database.prepare('UPDATE roles SET name=?,description=? WHERE id=?')).run(before.name, before.description ?? null, targetId);
|
||||
await (await database.prepare('DELETE FROM role_permissions WHERE role_id=?')).run(targetId);
|
||||
for (const permission of permissionRows) await (await database.prepare('INSERT INTO role_permissions(role_id,permission_id) VALUES(?,?)')).run(targetId, permission.id);
|
||||
restored = { name: before.name, description: before.description ?? null, permissions: permissionNames };
|
||||
} else if (action === 'access.updated') {
|
||||
const currentRoles = await (await database.prepare('SELECT role_id FROM user_roles WHERE user_id=? ORDER BY role_id')).all(targetId);
|
||||
const currentPermissions = await (await database.prepare('SELECT permission_id FROM user_permissions WHERE user_id=? ORDER BY permission_id')).all(targetId);
|
||||
current = { roleIds: currentRoles.map((row) => row.role_id), permissionIds: currentPermissions.map((row) => row.permission_id) };
|
||||
if (comparable(current) !== comparable(after)) return fail(409, { revertError: 'The user’s access changed after this event. Reversion was blocked.' });
|
||||
const roleIds = Array.isArray(before.roleIds) ? before.roleIds.map(String) : [];
|
||||
const permissionIds = Array.isArray(before.permissionIds) ? before.permissionIds.map(String) : [];
|
||||
const restoresAdminRole = roleIds.includes('role-admin');
|
||||
const restoresSystemPermission = permissionIds.includes('permission-system-admin');
|
||||
if ((restoresAdminRole || restoresSystemPermission) && !isFullAdmin) return fail(403, { revertError: 'Only a full administrator can restore administrator access.' });
|
||||
const roleCount = roleIds.length ? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM roles WHERE id IN (${roleIds.map(() => '?').join(',')})`)).get(...roleIds))?.count) : 0;
|
||||
const permissionCount = permissionIds.length ? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`)).get(...permissionIds))?.count) : 0;
|
||||
if (roleCount !== roleIds.length || permissionCount !== permissionIds.length) return fail(409, { revertError: 'A previous role or permission no longer exists.' });
|
||||
await (await database.prepare('DELETE FROM user_roles WHERE user_id=?')).run(targetId);
|
||||
await (await database.prepare('DELETE FROM user_permissions WHERE user_id=?')).run(targetId);
|
||||
for (const roleId of roleIds) await (await database.prepare('INSERT INTO user_roles(user_id,role_id) VALUES(?,?)')).run(targetId, roleId);
|
||||
for (const permissionId of permissionIds) await (await database.prepare('INSERT INTO user_permissions(user_id,permission_id) VALUES(?,?)')).run(targetId, permissionId);
|
||||
restored = { roleIds, permissionIds };
|
||||
} else {
|
||||
return fail(400, { revertError: 'This change requires a specialized recovery workflow that is not available yet.' });
|
||||
}
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && 'status' in error) throw error;
|
||||
return fail(409, { revertError: 'The previous state could not be restored because related data has changed.' });
|
||||
}
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'audit_control', action: 'audit.reverted', entityId: eventId, before: current, after: { originalEventId: eventId, originalAction: action, targetId, restored } });
|
||||
return { reverted: true, resultToken: randomUUID() };
|
||||
}
|
||||
revert: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'audit.revert',
|
||||
);
|
||||
const operatorPermissions = await getPermissions(operator);
|
||||
const isFullAdmin = operatorPermissions.includes('*');
|
||||
const eventId = String((await request.formData()).get('eventId') ?? '');
|
||||
const database = await getDatabase();
|
||||
const event = await (
|
||||
await database.prepare('SELECT * FROM audit_log WHERE id=?')
|
||||
).get(eventId);
|
||||
if (!event) return fail(404, { revertError: 'Audit event not found.' });
|
||||
if (!reversibleActions.has(String(event.action)) || !event.before_json)
|
||||
return fail(400, {
|
||||
revertError:
|
||||
'This event does not contain a supported reversible change.',
|
||||
});
|
||||
if (
|
||||
await (
|
||||
await database.prepare(
|
||||
"SELECT id FROM audit_log WHERE entity_type='audit_control' AND action='audit.reverted' AND entity_id=?",
|
||||
)
|
||||
).get(eventId)
|
||||
) {
|
||||
return fail(409, {
|
||||
revertError: 'This audit event has already been reverted.',
|
||||
});
|
||||
}
|
||||
const before = parseSnapshot(event.before_json),
|
||||
after = parseSnapshot(event.after_json);
|
||||
if (!before || !after)
|
||||
return fail(400, {
|
||||
revertError: 'The stored snapshots cannot be safely interpreted.',
|
||||
});
|
||||
const action = String(event.action),
|
||||
targetId = String(event.entity_id);
|
||||
let current: Record<string, unknown> | null = null;
|
||||
let restored: Record<string, unknown> | null = null;
|
||||
try {
|
||||
if (action.startsWith('attribute.')) {
|
||||
current =
|
||||
(await (
|
||||
await database.prepare('SELECT * FROM stat_attributes WHERE id=?')
|
||||
).get(targetId)) ?? null;
|
||||
if (!sameRecord(current, after))
|
||||
return fail(409, {
|
||||
revertError:
|
||||
'The attribute changed after this event. Reversion was blocked to preserve newer work.',
|
||||
});
|
||||
await (
|
||||
await database.prepare(`UPDATE stat_attributes SET name=?,description=?,type=?,category=?,calculation_type=?,unit=?,gear_min=?,gear_max=?,
|
||||
weapon_min=?,weapon_max=?,skill_chip_min=?,skill_chip_max=?,cap_value=?,active=?,updated_at=? WHERE id=?`)
|
||||
).run(
|
||||
before.name,
|
||||
before.description ?? null,
|
||||
before.type,
|
||||
before.category,
|
||||
before.calculation_type,
|
||||
before.unit,
|
||||
before.gear_min,
|
||||
before.gear_max,
|
||||
before.weapon_min,
|
||||
before.weapon_max,
|
||||
before.skill_chip_min,
|
||||
before.skill_chip_max,
|
||||
before.cap_value,
|
||||
before.active,
|
||||
new Date().toISOString(),
|
||||
targetId,
|
||||
);
|
||||
restored =
|
||||
(await (
|
||||
await database.prepare('SELECT * FROM stat_attributes WHERE id=?')
|
||||
).get(targetId)) ?? null;
|
||||
} else if (action.endsWith('.archived')) {
|
||||
const table = action.startsWith('brand_set.')
|
||||
? 'brand_sets'
|
||||
: action.startsWith('gear.')
|
||||
? 'gear'
|
||||
: action.startsWith('weapon.')
|
||||
? 'weapons'
|
||||
: '';
|
||||
if (!table)
|
||||
return fail(400, { revertError: 'Unsupported archive category.' });
|
||||
current =
|
||||
(await (
|
||||
await database.prepare(`SELECT * FROM ${table} WHERE id=?`)
|
||||
).get(targetId)) ?? null;
|
||||
if (!sameRecord(current, after))
|
||||
return fail(409, {
|
||||
revertError:
|
||||
'The record changed after it was archived. Reversion was blocked.',
|
||||
});
|
||||
await (
|
||||
await database.prepare(
|
||||
`UPDATE ${table} SET active=?,updated_at=? WHERE id=?`,
|
||||
)
|
||||
).run(before.active, new Date().toISOString(), targetId);
|
||||
restored =
|
||||
(await (
|
||||
await database.prepare(`SELECT * FROM ${table} WHERE id=?`)
|
||||
).get(targetId)) ?? null;
|
||||
} else if (action === 'role.updated') {
|
||||
const currentRole = await (
|
||||
await database.prepare(
|
||||
'SELECT name,description FROM roles WHERE id=?',
|
||||
)
|
||||
).get(targetId);
|
||||
const currentPermissions = await (
|
||||
await database.prepare(
|
||||
`SELECT permissions.name FROM role_permissions JOIN permissions ON permissions.id=role_permissions.permission_id WHERE role_id=? ORDER BY permissions.name`,
|
||||
)
|
||||
).all(targetId);
|
||||
current = {
|
||||
...currentRole,
|
||||
permissions: currentPermissions.map((row) => row.name),
|
||||
};
|
||||
if (comparable(current) !== comparable(after))
|
||||
return fail(409, {
|
||||
revertError:
|
||||
'The role changed after this event. Reversion was blocked.',
|
||||
});
|
||||
const permissionNames = Array.isArray(before.permissions)
|
||||
? before.permissions.map(String)
|
||||
: [];
|
||||
if (permissionNames.includes('system.admin') && !isFullAdmin)
|
||||
return fail(403, {
|
||||
revertError:
|
||||
'Only a full administrator can restore unrestricted system access.',
|
||||
});
|
||||
const permissionRows = permissionNames.length
|
||||
? await (
|
||||
await database.prepare(
|
||||
`SELECT id,name FROM permissions WHERE name IN (${permissionNames.map(() => '?').join(',')})`,
|
||||
)
|
||||
).all(...permissionNames)
|
||||
: [];
|
||||
if (permissionRows.length !== permissionNames.length)
|
||||
return fail(409, {
|
||||
revertError: 'A previous permission no longer exists.',
|
||||
});
|
||||
await (
|
||||
await database.prepare(
|
||||
'UPDATE roles SET name=?,description=? WHERE id=?',
|
||||
)
|
||||
).run(before.name, before.description ?? null, targetId);
|
||||
await (
|
||||
await database.prepare('DELETE FROM role_permissions WHERE role_id=?')
|
||||
).run(targetId);
|
||||
for (const permission of permissionRows)
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO role_permissions(role_id,permission_id) VALUES(?,?)',
|
||||
)
|
||||
).run(targetId, permission.id);
|
||||
restored = {
|
||||
name: before.name,
|
||||
description: before.description ?? null,
|
||||
permissions: permissionNames,
|
||||
};
|
||||
} else if (action === 'access.updated') {
|
||||
const currentRoles = await (
|
||||
await database.prepare(
|
||||
'SELECT role_id FROM user_roles WHERE user_id=? ORDER BY role_id',
|
||||
)
|
||||
).all(targetId);
|
||||
const currentPermissions = await (
|
||||
await database.prepare(
|
||||
'SELECT permission_id FROM user_permissions WHERE user_id=? ORDER BY permission_id',
|
||||
)
|
||||
).all(targetId);
|
||||
current = {
|
||||
roleIds: currentRoles.map((row) => row.role_id),
|
||||
permissionIds: currentPermissions.map((row) => row.permission_id),
|
||||
};
|
||||
if (comparable(current) !== comparable(after))
|
||||
return fail(409, {
|
||||
revertError:
|
||||
'The user’s access changed after this event. Reversion was blocked.',
|
||||
});
|
||||
const roleIds = Array.isArray(before.roleIds)
|
||||
? before.roleIds.map(String)
|
||||
: [];
|
||||
const permissionIds = Array.isArray(before.permissionIds)
|
||||
? before.permissionIds.map(String)
|
||||
: [];
|
||||
const restoresAdminRole = roleIds.includes('role-admin');
|
||||
const restoresSystemPermission = permissionIds.includes(
|
||||
'permission-system-admin',
|
||||
);
|
||||
if ((restoresAdminRole || restoresSystemPermission) && !isFullAdmin)
|
||||
return fail(403, {
|
||||
revertError:
|
||||
'Only a full administrator can restore administrator access.',
|
||||
});
|
||||
const roleCount = roleIds.length
|
||||
? Number(
|
||||
(
|
||||
await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM roles WHERE id IN (${roleIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).get(...roleIds)
|
||||
)?.count,
|
||||
)
|
||||
: 0;
|
||||
const permissionCount = permissionIds.length
|
||||
? Number(
|
||||
(
|
||||
await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).get(...permissionIds)
|
||||
)?.count,
|
||||
)
|
||||
: 0;
|
||||
if (
|
||||
roleCount !== roleIds.length ||
|
||||
permissionCount !== permissionIds.length
|
||||
)
|
||||
return fail(409, {
|
||||
revertError: 'A previous role or permission no longer exists.',
|
||||
});
|
||||
await (
|
||||
await database.prepare('DELETE FROM user_roles WHERE user_id=?')
|
||||
).run(targetId);
|
||||
await (
|
||||
await database.prepare('DELETE FROM user_permissions WHERE user_id=?')
|
||||
).run(targetId);
|
||||
for (const roleId of roleIds)
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO user_roles(user_id,role_id) VALUES(?,?)',
|
||||
)
|
||||
).run(targetId, roleId);
|
||||
for (const permissionId of permissionIds)
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO user_permissions(user_id,permission_id) VALUES(?,?)',
|
||||
)
|
||||
).run(targetId, permissionId);
|
||||
restored = { roleIds, permissionIds };
|
||||
} else {
|
||||
return fail(400, {
|
||||
revertError:
|
||||
'This change requires a specialized recovery workflow that is not available yet.',
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (error && typeof error === 'object' && 'status' in error) throw error;
|
||||
return fail(409, {
|
||||
revertError:
|
||||
'The previous state could not be restored because related data has changed.',
|
||||
});
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'audit_control',
|
||||
action: 'audit.reverted',
|
||||
entityId: eventId,
|
||||
before: current,
|
||||
after: {
|
||||
originalEventId: eventId,
|
||||
originalAction: action,
|
||||
targetId,
|
||||
restored,
|
||||
},
|
||||
});
|
||||
return { reverted: true, resultToken: randomUUID() };
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user