feat: add community publishing and news syndication
This commit is contained in:
@@ -6,56 +6,106 @@ import type { LayoutServerLoad } from './$types';
|
||||
import { getContributorMode } from '$lib/server/contributor-mode';
|
||||
|
||||
export const load: LayoutServerLoad = async ({ cookies }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const permissions = await getPermissions(user);
|
||||
if (!permissions.includes('*') && !permissions.includes('isac.access')) redirect(303, '/account');
|
||||
const canSeeData = permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission));
|
||||
const canManageData = permissions.includes('*') || permissions.includes('data.manage');
|
||||
const canProposeData = permissions.includes('*') || permissions.includes('data.propose');
|
||||
const canApproveData = permissions.includes('*') || permissions.includes('data.approve');
|
||||
const canReviewAvatars = permissions.includes('*') || permissions.includes('avatars.review');
|
||||
const canReviewCommunity = permissions.includes('*')
|
||||
|| permissions.includes('community.comments.moderate')
|
||||
|| permissions.includes('community.profiles.moderate');
|
||||
const contributorMode = !canManageData && canProposeData ? await getContributorMode(user.id) : null;
|
||||
const database = canSeeData || canReviewAvatars || canReviewCommunity ? await getDatabase() : null;
|
||||
const pendingRows = canSeeData
|
||||
? await (await database!.prepare("SELECT module,COUNT(*) AS count FROM data_change_proposals WHERE status='pending' GROUP BY module")).all()
|
||||
: [];
|
||||
const pendingProposalTotal = pendingRows.reduce((total, row) => total + Number(row.count), 0);
|
||||
const pendingAvatarReviews = canReviewAvatars
|
||||
? Number((await (await database!.prepare("SELECT COUNT(*) AS count FROM avatar_reviews WHERE status='pending'")).get())?.count ?? 0)
|
||||
: 0;
|
||||
const canReviewComments = permissions.includes('*') || permissions.includes('community.comments.moderate');
|
||||
const canReviewProfiles = permissions.includes('*') || permissions.includes('community.profiles.moderate');
|
||||
const communityReviewTypes = [
|
||||
...(canReviewComments ? ['comment', 'guide'] : []),
|
||||
...(canReviewProfiles ? ['profile_bio'] : [])
|
||||
];
|
||||
const communityTypePlaceholders = communityReviewTypes.map(() => '?').join(',');
|
||||
const pendingCommunityReviews = canReviewCommunity && communityReviewTypes.length
|
||||
? Number((await (await database!.prepare(`SELECT
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const permissions = await getPermissions(user);
|
||||
if (!permissions.includes('*') && !permissions.includes('isac.access'))
|
||||
redirect(303, '/account');
|
||||
const canSeeData =
|
||||
permissions.includes('*') ||
|
||||
['data.view', 'data.manage', 'data.propose', 'data.approve'].some(
|
||||
(permission) => permissions.includes(permission),
|
||||
);
|
||||
const canManageData =
|
||||
permissions.includes('*') || permissions.includes('data.manage');
|
||||
const canProposeData =
|
||||
permissions.includes('*') || permissions.includes('data.propose');
|
||||
const canApproveData =
|
||||
permissions.includes('*') || permissions.includes('data.approve');
|
||||
const canReviewAvatars =
|
||||
permissions.includes('*') || permissions.includes('avatars.review');
|
||||
const canReviewCommunity =
|
||||
permissions.includes('*') ||
|
||||
permissions.includes('community.comments.moderate') ||
|
||||
permissions.includes('community.profiles.moderate') ||
|
||||
permissions.includes('community.guides.moderate');
|
||||
const contributorMode =
|
||||
!canManageData && canProposeData ? await getContributorMode(user.id) : null;
|
||||
const database =
|
||||
canSeeData || canReviewAvatars || canReviewCommunity
|
||||
? await getDatabase()
|
||||
: null;
|
||||
const pendingRows = canSeeData
|
||||
? await (
|
||||
await database!.prepare(
|
||||
"SELECT module,COUNT(*) AS count FROM data_change_proposals WHERE status='pending' GROUP BY module",
|
||||
)
|
||||
).all()
|
||||
: [];
|
||||
const pendingProposalTotal = pendingRows.reduce(
|
||||
(total, row) => total + Number(row.count),
|
||||
0,
|
||||
);
|
||||
const pendingAvatarReviews = canReviewAvatars
|
||||
? Number(
|
||||
(
|
||||
await (
|
||||
await database!.prepare(
|
||||
"SELECT COUNT(*) AS count FROM avatar_reviews WHERE status='pending'",
|
||||
)
|
||||
).get()
|
||||
)?.count ?? 0,
|
||||
)
|
||||
: 0;
|
||||
const canReviewComments =
|
||||
permissions.includes('*') ||
|
||||
permissions.includes('community.comments.moderate');
|
||||
const canReviewProfiles =
|
||||
permissions.includes('*') ||
|
||||
permissions.includes('community.profiles.moderate');
|
||||
const canReviewGuides =
|
||||
permissions.includes('*') ||
|
||||
permissions.includes('community.guides.moderate');
|
||||
const communityReviewTypes = [
|
||||
...(canReviewComments ? ['comment'] : []),
|
||||
...(canReviewGuides ? ['guide'] : []),
|
||||
...(canReviewProfiles ? ['profile_bio'] : []),
|
||||
];
|
||||
const communityTypePlaceholders = communityReviewTypes
|
||||
.map(() => '?')
|
||||
.join(',');
|
||||
const pendingCommunityReviews =
|
||||
canReviewCommunity && communityReviewTypes.length
|
||||
? Number(
|
||||
(
|
||||
await (
|
||||
await database!.prepare(`SELECT
|
||||
(SELECT COUNT(*) FROM content_moderation_scans scan
|
||||
WHERE scan.content_type IN (${communityTypePlaceholders})
|
||||
AND scan.status IN ('review','priority_review','error')
|
||||
AND NOT EXISTS(SELECT 1 FROM content_moderation_reviews review WHERE review.scan_id=scan.id))
|
||||
+
|
||||
(SELECT COUNT(*) FROM user_content_reports
|
||||
WHERE content_type IN (${communityTypePlaceholders}) AND status='pending') AS count`))
|
||||
.get(...communityReviewTypes, ...communityReviewTypes))?.count ?? 0)
|
||||
: 0;
|
||||
const configuredBuildNumber = process.env.SHD_BUILD_NUMBER?.trim() || '0';
|
||||
const buildNumber = /^\d+$/.test(configuredBuildNumber) ? configuredBuildNumber : '0';
|
||||
return {
|
||||
permissions,
|
||||
contributorMode,
|
||||
buildNumber,
|
||||
pendingProposals: Object.fromEntries(pendingRows.map((row) => [String(row.module), Number(row.count)])),
|
||||
oversightReviewCounts: {
|
||||
data: canApproveData ? pendingProposalTotal : 0,
|
||||
avatars: pendingAvatarReviews,
|
||||
community: pendingCommunityReviews
|
||||
}
|
||||
};
|
||||
WHERE content_type IN (${communityTypePlaceholders}) AND status='pending') AS count`)
|
||||
).get(...communityReviewTypes, ...communityReviewTypes)
|
||||
)?.count ?? 0,
|
||||
)
|
||||
: 0;
|
||||
const configuredBuildNumber = process.env.SHD_BUILD_NUMBER?.trim() || '0';
|
||||
const buildNumber = /^\d+$/.test(configuredBuildNumber)
|
||||
? configuredBuildNumber
|
||||
: '0';
|
||||
return {
|
||||
permissions,
|
||||
contributorMode,
|
||||
buildNumber,
|
||||
pendingProposals: Object.fromEntries(
|
||||
pendingRows.map((row) => [String(row.module), Number(row.count)]),
|
||||
),
|
||||
oversightReviewCounts: {
|
||||
data: canApproveData ? pendingProposalTotal : 0,
|
||||
avatars: pendingAvatarReviews,
|
||||
community: pendingCommunityReviews,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
const dataModeClass = $derived(dataMode.toLowerCase().replace(' ', '-'));
|
||||
const isacNode = 'NYC-01';
|
||||
let readOnlyBlocked = $state(false);
|
||||
let contributorNote = $state('');
|
||||
const dataSections = [
|
||||
{ href: '/isac/configuration', label: 'Global configuration', module: 'configuration' },
|
||||
{ href: '/isac/calculations', label: 'Combat calculations', module: 'calculations' },
|
||||
@@ -36,7 +37,8 @@
|
||||
const oversightSections: Array<{ href: string; label: string; permission?: string; permissions?: string[]; reviewKey?: 'data' | 'avatars' | 'community' }> = [
|
||||
{ href: '/isac/data-moderation', label: 'Data Oversight', permission: 'data.approve', reviewKey: 'data' },
|
||||
{ href: '/isac/avatar-oversight', label: 'Avatar Oversight', permission: 'avatars.review', reviewKey: 'avatars' },
|
||||
{ href: '/isac/community', label: 'Community Oversight', permissions: ['community.comments.moderate', 'community.profiles.moderate'], reviewKey: 'community' },
|
||||
{ href: '/isac/community', label: 'Community Oversight', permissions: ['community.comments.moderate', 'community.profiles.moderate', 'community.guides.moderate'], reviewKey: 'community' },
|
||||
{ href: '/isac/community/guides', label: 'Guide Oversight', permissions: ['community.guides.moderate', 'guides.curate'] },
|
||||
{ href: '/isac/audit', label: 'Audit history', permission: 'audit.view' }
|
||||
];
|
||||
const visibleOversightSections = $derived(oversightSections.filter((section) =>
|
||||
@@ -128,13 +130,23 @@
|
||||
event.preventDefault();
|
||||
readOnlyBlocked = true;
|
||||
}
|
||||
const form = event.target;
|
||||
if (form instanceof HTMLFormElement && contributorEnabled && can('data.propose') && !can('data.manage') && form.method.toLowerCase() === 'post') {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'hidden'; input.name = 'contributorNote'; input.value = contributorNote;
|
||||
form.append(input);
|
||||
}
|
||||
}}>
|
||||
{#if dataModulePage && page.url.searchParams.has('proposal')}<div class="proposal-submitted" role="status"><b>CHANGE QUEUED</b><span>Your suggestion is awaiting data moderation and has not changed the live data set.</span></div>{/if}
|
||||
{#if dataReadOnly}<div class="data-access-banner"><div><b>READ-ONLY DATA ACCESS</b><span>You can inspect registries without submitting changes.</span></div>{#if can('data.propose')}<a class="mode-toggle" href={`/isac/contributor-guidelines?returnTo=${encodeURIComponent(page.url.pathname + page.url.search)}`}>ENABLE CONTRIBUTOR MODE →</a>{/if}</div>
|
||||
{:else if dataModulePage && contributorEnabled && can('data.propose') && !can('data.manage')}<div class="data-access-banner contributor"><div><b>CONTRIBUTOR MODE</b><span>Suggestions enter moderation and do not immediately change live data.</span></div><form method="POST" action="/isac/contributor-guidelines?/disable"><input type="hidden" name="returnTo" value={page.url.pathname + page.url.search}/><button class="mode-toggle" type="submit">RETURN TO READ-ONLY</button></form></div>{/if}
|
||||
{#if dataModulePage && readOnlyBlocked}<p class="error data-access-error" role="alert">This account has read-only game-data access.</p>{/if}
|
||||
{#if dataModulePage && !dataReadOnly}<div class="mutation-policy-strip"><MutationConsequence/></div>{/if}
|
||||
{#if dataModulePage && !dataReadOnly}<div class="mutation-policy-strip"><MutationConsequence/>{#if contributorEnabled && can('data.propose') && !can('data.manage')}<label class="contributor-note"><span>CONTRIBUTOR RATIONALE <small>OPTIONAL · SHARED WITH OVERSIGHT</small></span><textarea bind:value={contributorNote} maxlength="1000" rows="2" placeholder="Explain the observed inaccuracy, source, or reason for this change…"></textarea></label>{/if}</div>{/if}
|
||||
{@render children()}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.contributor-note{display:grid;gap:6px;margin-top:8px;padding:10px;border:1px solid var(--shd-border);background:var(--shd-surface)}.contributor-note>span{display:flex;justify-content:space-between;gap:8px;color:var(--shd-text);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px}.contributor-note small{color:var(--shd-text-muted);font:inherit}.contributor-note textarea{width:100%;resize:vertical}
|
||||
</style>
|
||||
|
||||
@@ -4,14 +4,23 @@ import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
const permissions = await getPermissions(await getSessionUser(cookies));
|
||||
if (permissions.includes('*') || permissions.includes('users.view')) redirect(303, '/isac/users');
|
||||
if (permissions.includes('users.roles')) redirect(303, '/isac/roles');
|
||||
if (permissions.includes('*') || ['data.view','data.manage','data.propose','data.approve'].some((permission) => permissions.includes(permission))) redirect(303, '/isac/configuration');
|
||||
if (permissions.includes('audit.view')) redirect(303, '/isac/audit');
|
||||
if (permissions.includes('site.settings.manage')) redirect(303, '/isac/site-controls');
|
||||
if (permissions.includes('images.manage')) redirect(303, '/isac/images');
|
||||
if (permissions.includes('system.notifications.manage')) redirect(303, '/isac/system-messages');
|
||||
if (permissions.includes('metrics.view')) redirect(303, '/isac/metrics');
|
||||
redirect(303, '/account');
|
||||
const permissions = await getPermissions(await getSessionUser(cookies));
|
||||
if (permissions.includes('*') || permissions.includes('users.view'))
|
||||
redirect(303, '/isac/users');
|
||||
if (permissions.includes('users.roles')) redirect(303, '/isac/roles');
|
||||
if (
|
||||
permissions.includes('*') ||
|
||||
['data.view', 'data.manage', 'data.propose', 'data.approve'].some(
|
||||
(permission) => permissions.includes(permission),
|
||||
)
|
||||
)
|
||||
redirect(303, '/isac/configuration');
|
||||
if (permissions.includes('audit.view')) redirect(303, '/isac/audit');
|
||||
if (permissions.includes('site.settings.manage'))
|
||||
redirect(303, '/isac/site-controls');
|
||||
if (permissions.includes('images.manage')) redirect(303, '/isac/images');
|
||||
if (permissions.includes('system.notifications.manage'))
|
||||
redirect(303, '/isac/system-messages');
|
||||
if (permissions.includes('metrics.view')) redirect(303, '/isac/metrics');
|
||||
redirect(303, '/account');
|
||||
};
|
||||
|
||||
@@ -1,116 +1,327 @@
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
|
||||
import {
|
||||
requireDataView,
|
||||
requirePermission,
|
||||
} from '$lib/server/auth/authorization';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination';
|
||||
import {
|
||||
getPagination,
|
||||
getRegistrySearch,
|
||||
getRegistrySort,
|
||||
} from '$lib/server/pagination';
|
||||
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const user = await requireDataView(await getSessionUser(cookies));
|
||||
const database = await getDatabase();
|
||||
const pending = (await getPendingEntities(user.id, [{
|
||||
module: 'attributes',
|
||||
action: 'save',
|
||||
kind: 'attribute',
|
||||
fields: ['description', 'type', 'category', 'calculationType', 'unit', 'gearMin', 'gearMax', 'weaponMin', 'weaponMax', 'chipMin', 'chipMax', 'capValue']
|
||||
}])).map((stat) => ({
|
||||
...stat,
|
||||
calculation_type: stat.calculationType,
|
||||
gear_min: stat.gearMin || null,
|
||||
gear_max: stat.gearMax || null,
|
||||
weapon_min: stat.weaponMin || null,
|
||||
weapon_max: stat.weaponMax || null,
|
||||
skill_chip_min: stat.chipMin || null,
|
||||
skill_chip_max: stat.chipMax || null,
|
||||
cap_value: stat.capValue || null
|
||||
}));
|
||||
const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
|
||||
const user = await requireDataView(await getSessionUser(cookies));
|
||||
const database = await getDatabase();
|
||||
const pending = (
|
||||
await getPendingEntities(user.id, [
|
||||
{
|
||||
module: 'attributes',
|
||||
action: 'save',
|
||||
kind: 'attribute',
|
||||
fields: [
|
||||
'description',
|
||||
'type',
|
||||
'category',
|
||||
'calculationType',
|
||||
'unit',
|
||||
'gearMin',
|
||||
'gearMax',
|
||||
'weaponMin',
|
||||
'weaponMax',
|
||||
'chipMin',
|
||||
'chipMax',
|
||||
'capValue',
|
||||
],
|
||||
},
|
||||
])
|
||||
).map((stat) => ({
|
||||
...stat,
|
||||
calculation_type: stat.calculationType,
|
||||
gear_min: stat.gearMin || null,
|
||||
gear_max: stat.gearMax || null,
|
||||
weapon_min: stat.weaponMin || null,
|
||||
weapon_max: stat.weaponMax || null,
|
||||
skill_chip_min: stat.chipMin || null,
|
||||
skill_chip_max: stat.chipMax || null,
|
||||
cap_value: stat.capValue || null,
|
||||
}));
|
||||
const summary = await (
|
||||
await database.prepare(`SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
||||
SUM(CASE WHEN type='Offensive' THEN 1 ELSE 0 END) AS offensive,
|
||||
SUM(CASE WHEN type='Defensive' THEN 1 ELSE 0 END) AS defensive FROM stat_attributes`)).get();
|
||||
const filter = ['active', 'offensive', 'defensive'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const sort = getRegistrySort(url, {
|
||||
name: { asc: 'name COLLATE NOCASE ASC, unit COLLATE NOCASE ASC, id ASC', desc: 'name COLLATE NOCASE DESC, unit COLLATE NOCASE DESC, id DESC' },
|
||||
type: { asc: 'type COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC', desc: 'type COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC' },
|
||||
category: { asc: 'category COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC', desc: 'category COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC' },
|
||||
calculation: { asc: 'calculation_type COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC', desc: 'calculation_type COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC' },
|
||||
state: { asc: 'active ASC, name COLLATE NOCASE ASC, id ASC', desc: 'active DESC, name COLLATE NOCASE ASC, id ASC', defaultDirection: 'desc' }
|
||||
}, 'name');
|
||||
const conditions = [
|
||||
...(filter === 'active' ? ['active=1'] : filter === 'offensive' ? ["type='Offensive'"] : filter === 'defensive' ? ["type='Defensive'"] : []),
|
||||
...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
|
||||
];
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const args = search ? [pattern] : [];
|
||||
const visiblePending = pending.filter((stat) =>
|
||||
(filter === 'offensive' ? String((stat as any).type) === 'Offensive' : filter === 'defensive' ? String((stat as any).type) === 'Defensive' : true)
|
||||
&& (!search || String(stat.name).toLocaleLowerCase().includes(search.toLocaleLowerCase()))
|
||||
).sort((left, right) => {
|
||||
const field = sort.key === 'calculation' ? 'calculation_type' : sort.key;
|
||||
const result = String((left as any)[field] ?? '').localeCompare(String((right as any)[field] ?? ''), undefined, { sensitivity: 'base', numeric: true });
|
||||
return (sort.direction === 'asc' ? result : -result) || String(left.name).localeCompare(String(right.name), undefined, { sensitivity: 'base' });
|
||||
});
|
||||
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM stat_attributes ${where}`)).get(...args);
|
||||
const pagination = getPagination(url, Number(filtered?.total ?? 0) + visiblePending.length);
|
||||
const pendingOnPage = pagination.page === 1 ? visiblePending.slice(0, pagination.perPage) : [];
|
||||
const databaseOffset = Math.max(0, pagination.offset - visiblePending.length);
|
||||
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
|
||||
const stats = databaseLimit
|
||||
? await (await database.prepare(`SELECT * FROM stat_attributes ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset)
|
||||
: [];
|
||||
return {
|
||||
stats: [...pendingOnPage, ...stats.map((stat) => ({ ...stat, unit: stat.unit === 'flat' ? '' : stat.unit }))],
|
||||
summary: { ...summary, total: Number(summary?.total ?? 0) + pending.length, pending: pending.length },
|
||||
pagination,
|
||||
filter,
|
||||
search,
|
||||
sort
|
||||
};
|
||||
SUM(CASE WHEN type='Defensive' THEN 1 ELSE 0 END) AS defensive FROM stat_attributes`)
|
||||
).get();
|
||||
const filter = ['active', 'offensive', 'defensive'].includes(
|
||||
url.searchParams.get('filter') ?? '',
|
||||
)
|
||||
? String(url.searchParams.get('filter'))
|
||||
: '';
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const sort = getRegistrySort(
|
||||
url,
|
||||
{
|
||||
name: {
|
||||
asc: 'name COLLATE NOCASE ASC, unit COLLATE NOCASE ASC, id ASC',
|
||||
desc: 'name COLLATE NOCASE DESC, unit COLLATE NOCASE DESC, id DESC',
|
||||
},
|
||||
type: {
|
||||
asc: 'type COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC',
|
||||
desc: 'type COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC',
|
||||
},
|
||||
category: {
|
||||
asc: 'category COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC',
|
||||
desc: 'category COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC',
|
||||
},
|
||||
calculation: {
|
||||
asc: 'calculation_type COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC',
|
||||
desc: 'calculation_type COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC',
|
||||
},
|
||||
state: {
|
||||
asc: 'active ASC, name COLLATE NOCASE ASC, id ASC',
|
||||
desc: 'active DESC, name COLLATE NOCASE ASC, id ASC',
|
||||
defaultDirection: 'desc',
|
||||
},
|
||||
},
|
||||
'name',
|
||||
);
|
||||
const conditions = [
|
||||
...(filter === 'active'
|
||||
? ['active=1']
|
||||
: filter === 'offensive'
|
||||
? ["type='Offensive'"]
|
||||
: filter === 'defensive'
|
||||
? ["type='Defensive'"]
|
||||
: []),
|
||||
...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : []),
|
||||
];
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const args = search ? [pattern] : [];
|
||||
const visiblePending = pending
|
||||
.filter(
|
||||
(stat) =>
|
||||
(filter === 'offensive'
|
||||
? String((stat as any).type) === 'Offensive'
|
||||
: filter === 'defensive'
|
||||
? String((stat as any).type) === 'Defensive'
|
||||
: true) &&
|
||||
(!search ||
|
||||
String(stat.name)
|
||||
.toLocaleLowerCase()
|
||||
.includes(search.toLocaleLowerCase())),
|
||||
)
|
||||
.sort((left, right) => {
|
||||
const field = sort.key === 'calculation' ? 'calculation_type' : sort.key;
|
||||
const result = String((left as any)[field] ?? '').localeCompare(
|
||||
String((right as any)[field] ?? ''),
|
||||
undefined,
|
||||
{ sensitivity: 'base', numeric: true },
|
||||
);
|
||||
return (
|
||||
(sort.direction === 'asc' ? result : -result) ||
|
||||
String(left.name).localeCompare(String(right.name), undefined, {
|
||||
sensitivity: 'base',
|
||||
})
|
||||
);
|
||||
});
|
||||
const filtered = await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS total FROM stat_attributes ${where}`,
|
||||
)
|
||||
).get(...args);
|
||||
const pagination = getPagination(
|
||||
url,
|
||||
Number(filtered?.total ?? 0) + visiblePending.length,
|
||||
);
|
||||
const pendingOnPage =
|
||||
pagination.page === 1 ? visiblePending.slice(0, pagination.perPage) : [];
|
||||
const databaseOffset = Math.max(0, pagination.offset - visiblePending.length);
|
||||
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
|
||||
const stats = databaseLimit
|
||||
? await (
|
||||
await database.prepare(
|
||||
`SELECT * FROM stat_attributes ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`,
|
||||
)
|
||||
).all(...args, databaseLimit, databaseOffset)
|
||||
: [];
|
||||
return {
|
||||
stats: [
|
||||
...pendingOnPage,
|
||||
...stats.map((stat) => ({
|
||||
...stat,
|
||||
unit: stat.unit === 'flat' ? '' : stat.unit,
|
||||
})),
|
||||
],
|
||||
summary: {
|
||||
...summary,
|
||||
total: Number(summary?.total ?? 0) + pending.length,
|
||||
pending: pending.length,
|
||||
},
|
||||
pagination,
|
||||
filter,
|
||||
search,
|
||||
sort,
|
||||
};
|
||||
};
|
||||
export const actions: Actions = {
|
||||
save: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? ''), name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
||||
const description = String(form.get('description') ?? '').normalize('NFKC').trim();
|
||||
const type = String(form.get('type') ?? ''), category = String(form.get('category') ?? ''), calculation = String(form.get('calculationType') ?? ''), unit = String(form.get('unit') ?? '');
|
||||
if (name.length < 2 || name.length > 80) return fail(400, { error: 'Attribute names must be 2–80 characters.' });
|
||||
if (description.length > 1200) return fail(400, { error: 'Attribute descriptions must be 1,200 characters or fewer.' });
|
||||
if (!['Offensive','Defensive'].includes(type) || !['Weapon','Skill','Character'].includes(category) || !['Additive','Multiplicative'].includes(calculation)) return fail(400, { error: 'Choose valid attribute options.' });
|
||||
if (!['','%','s','Shots','m'].includes(unit)) return fail(400, { error: 'Choose a valid unit.' });
|
||||
const number = (key: string, allowNegative = true) => {
|
||||
const raw = String(form.get(key) ?? '').trim();
|
||||
if (!raw) return null;
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || (!allowNegative && value < 0)) throw new Error();
|
||||
return value;
|
||||
};
|
||||
let values: Array<number|null>;
|
||||
try {
|
||||
values = [
|
||||
...['gearMin','gearMax','weaponMin','weaponMax','chipMin','chipMax'].map((key) => number(key)),
|
||||
number('capValue', false)
|
||||
];
|
||||
} catch { return fail(400, { error: 'Ranges must be numbers. Optional caps cannot be negative.' }); }
|
||||
for (let index = 0; index < 6; index += 2) if ((values[index] === null) !== (values[index+1] === null) || (values[index] !== null && Number(values[index]) > Number(values[index+1]))) return fail(400, { error: 'Each enabled source needs a valid minimum and maximum.' });
|
||||
const database = await getDatabase();
|
||||
if (await (await database.prepare('SELECT id FROM stat_attributes WHERE name = ? COLLATE NOCASE AND unit = ? AND id <> ?')).get(name, unit, id || 'new')) return fail(409, { error: 'That attribute and unit combination already exists.' });
|
||||
const now = new Date().toISOString(), statId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
||||
const before = id ? await auditRecord('stat_attributes', id) : null;
|
||||
if (id) 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=?,updated_at=? WHERE id=?`)).run(name,description || null,type,category,calculation,unit,...values,now,id);
|
||||
else await (await database.prepare(`INSERT INTO stat_attributes(id,name,description,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(statId,name,description || null,type,category,calculation,unit,...values,now,now);
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'attribute.updated' : 'attribute.created', entityId: statId, before, after: await auditRecord('stat_attributes', statId) });
|
||||
const savedItem = await auditRecord('stat_attributes', statId);
|
||||
return { saved: true, editorMode: id ? 'edit' : 'add', savedItem: { ...savedItem, unit: savedItem?.unit === 'flat' ? '' : savedItem?.unit }, 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 auditRecord('stat_attributes', id);
|
||||
await (await (await getDatabase()).prepare('UPDATE stat_attributes SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(),id);
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'attribute.archived', entityId: id, before, after: await auditRecord('stat_attributes', id) }); return { archived: true };
|
||||
}
|
||||
save: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.manage',
|
||||
);
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? ''),
|
||||
name = String(form.get('name') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
const description = String(form.get('description') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
const type = String(form.get('type') ?? ''),
|
||||
category = String(form.get('category') ?? ''),
|
||||
calculation = String(form.get('calculationType') ?? ''),
|
||||
unit = String(form.get('unit') ?? '');
|
||||
if (name.length < 2 || name.length > 80)
|
||||
return fail(400, { error: 'Attribute names must be 2–80 characters.' });
|
||||
if (description.length > 1200)
|
||||
return fail(400, {
|
||||
error: 'Attribute descriptions must be 1,200 characters or fewer.',
|
||||
});
|
||||
if (
|
||||
!['Offensive', 'Defensive'].includes(type) ||
|
||||
!['Weapon', 'Skill', 'Character'].includes(category) ||
|
||||
!['Additive', 'Multiplicative'].includes(calculation)
|
||||
)
|
||||
return fail(400, { error: 'Choose valid attribute options.' });
|
||||
if (!['', '%', 's', 'Shots', 'm'].includes(unit))
|
||||
return fail(400, { error: 'Choose a valid unit.' });
|
||||
const number = (key: string, allowNegative = true) => {
|
||||
const raw = String(form.get(key) ?? '').trim();
|
||||
if (!raw) return null;
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || (!allowNegative && value < 0))
|
||||
throw new Error();
|
||||
return value;
|
||||
};
|
||||
let values: Array<number | null>;
|
||||
try {
|
||||
values = [
|
||||
...[
|
||||
'gearMin',
|
||||
'gearMax',
|
||||
'weaponMin',
|
||||
'weaponMax',
|
||||
'chipMin',
|
||||
'chipMax',
|
||||
].map((key) => number(key)),
|
||||
number('capValue', false),
|
||||
];
|
||||
} catch {
|
||||
return fail(400, {
|
||||
error: 'Ranges must be numbers. Optional caps cannot be negative.',
|
||||
});
|
||||
}
|
||||
for (let index = 0; index < 6; index += 2)
|
||||
if (
|
||||
(values[index] === null) !== (values[index + 1] === null) ||
|
||||
(values[index] !== null &&
|
||||
Number(values[index]) > Number(values[index + 1]))
|
||||
)
|
||||
return fail(400, {
|
||||
error: 'Each enabled source needs a valid minimum and maximum.',
|
||||
});
|
||||
const database = await getDatabase();
|
||||
if (
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT id FROM stat_attributes WHERE name = ? COLLATE NOCASE AND unit = ? AND id <> ?',
|
||||
)
|
||||
).get(name, unit, id || 'new')
|
||||
)
|
||||
return fail(409, {
|
||||
error: 'That attribute and unit combination already exists.',
|
||||
});
|
||||
const now = new Date().toISOString(),
|
||||
statId =
|
||||
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
||||
const before = id ? await auditRecord('stat_attributes', id) : null;
|
||||
if (id)
|
||||
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=?,updated_at=? WHERE id=?`,
|
||||
)
|
||||
).run(
|
||||
name,
|
||||
description || null,
|
||||
type,
|
||||
category,
|
||||
calculation,
|
||||
unit,
|
||||
...values,
|
||||
now,
|
||||
id,
|
||||
);
|
||||
else
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO stat_attributes(id,name,description,type,category,calculation_type,unit,gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max,cap_value,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
)
|
||||
).run(
|
||||
statId,
|
||||
name,
|
||||
description || null,
|
||||
type,
|
||||
category,
|
||||
calculation,
|
||||
unit,
|
||||
...values,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: id ? 'attribute.updated' : 'attribute.created',
|
||||
entityId: statId,
|
||||
before,
|
||||
after: await auditRecord('stat_attributes', statId),
|
||||
});
|
||||
const savedItem = await auditRecord('stat_attributes', statId);
|
||||
return {
|
||||
saved: true,
|
||||
editorMode: id ? 'edit' : 'add',
|
||||
savedItem: {
|
||||
...savedItem,
|
||||
unit: savedItem?.unit === 'flat' ? '' : savedItem?.unit,
|
||||
},
|
||||
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 auditRecord('stat_attributes', id);
|
||||
await (
|
||||
await (
|
||||
await getDatabase()
|
||||
).prepare('UPDATE stat_attributes SET active=0,updated_at=? WHERE id=?')
|
||||
).run(new Date().toISOString(), id);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: 'attribute.archived',
|
||||
entityId: id,
|
||||
before,
|
||||
after: await auditRecord('stat_attributes', id),
|
||||
});
|
||||
return { archived: true };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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() };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,21 +12,34 @@ import { createNotification } from '$lib/server/notifications';
|
||||
const statuses = ['pending', 'approved', 'removed', 'withdrawn', 'superseded'];
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
await requirePermission(await getSessionUser(cookies), 'avatars.review');
|
||||
const database = await getDatabase();
|
||||
const status = statuses.includes(url.searchParams.get('status') ?? '') ? String(url.searchParams.get('status')) : 'pending';
|
||||
const search = (url.searchParams.get('search') ?? '').trim().slice(0, 100);
|
||||
const conditions = ['r.status=?'];
|
||||
const args: unknown[] = [status];
|
||||
if (search) {
|
||||
const value = `%${search.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||
conditions.push("(u.username LIKE ? ESCAPE '\\' COLLATE NOCASE OR p.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE)");
|
||||
args.push(value, value);
|
||||
}
|
||||
const where = `WHERE ${conditions.join(' AND ')}`;
|
||||
const total = Number((await (await database.prepare(`SELECT COUNT(*) AS total FROM avatar_reviews r JOIN users u ON u.id=r.user_id LEFT JOIN user_profiles p ON p.user_id=u.id ${where}`)).get(...args))?.total ?? 0);
|
||||
const pagination = getPagination(url, total);
|
||||
const reviews = await (await database.prepare(`SELECT r.*,COALESCE(p.display_name,u.username) AS display_name,u.login_username,
|
||||
await requirePermission(await getSessionUser(cookies), 'avatars.review');
|
||||
const database = await getDatabase();
|
||||
const status = statuses.includes(url.searchParams.get('status') ?? '')
|
||||
? String(url.searchParams.get('status'))
|
||||
: 'pending';
|
||||
const search = (url.searchParams.get('search') ?? '').trim().slice(0, 100);
|
||||
const conditions = ['r.status=?'];
|
||||
const args: unknown[] = [status];
|
||||
if (search) {
|
||||
const value = `%${search.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||
conditions.push(
|
||||
"(u.username LIKE ? ESCAPE '\\' COLLATE NOCASE OR p.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE)",
|
||||
);
|
||||
args.push(value, value);
|
||||
}
|
||||
const where = `WHERE ${conditions.join(' AND ')}`;
|
||||
const total = Number(
|
||||
(
|
||||
await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS total FROM avatar_reviews r JOIN users u ON u.id=r.user_id LEFT JOIN user_profiles p ON p.user_id=u.id ${where}`,
|
||||
)
|
||||
).get(...args)
|
||||
)?.total ?? 0,
|
||||
);
|
||||
const pagination = getPagination(url, total);
|
||||
const reviews = await (
|
||||
await database.prepare(`SELECT r.*,COALESCE(p.display_name,u.username) AS display_name,u.login_username,
|
||||
CASE WHEN a.source='upload' AND a.updated_at=r.avatar_updated_at THEN 1 ELSE 0 END AS is_current,
|
||||
COALESCE(rp.display_name,ru.username) AS reviewer_name
|
||||
FROM avatar_reviews r
|
||||
@@ -35,56 +48,145 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
LEFT JOIN user_avatars a ON a.user_id=r.user_id
|
||||
LEFT JOIN users ru ON ru.id=r.reviewed_by
|
||||
LEFT JOIN user_profiles rp ON rp.user_id=ru.id
|
||||
${where} ORDER BY r.submitted_at DESC LIMIT ? OFFSET ?`)).all(...args, pagination.perPage, pagination.offset);
|
||||
const summaryRows = await (await database.prepare('SELECT status,COUNT(*) AS count FROM avatar_reviews GROUP BY status')).all();
|
||||
return { reviews, pagination, status, search, summary: Object.fromEntries(summaryRows.map((row) => [String(row.status), Number(row.count)])) };
|
||||
${where} ORDER BY r.submitted_at DESC LIMIT ? OFFSET ?`)
|
||||
).all(...args, pagination.perPage, pagination.offset);
|
||||
const summaryRows = await (
|
||||
await database.prepare(
|
||||
'SELECT status,COUNT(*) AS count FROM avatar_reviews GROUP BY status',
|
||||
)
|
||||
).all();
|
||||
return {
|
||||
reviews,
|
||||
pagination,
|
||||
status,
|
||||
search,
|
||||
summary: Object.fromEntries(
|
||||
summaryRows.map((row) => [String(row.status), Number(row.count)]),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
approve: async ({ cookies, request }) => {
|
||||
const reviewer = await requirePermission(await getSessionUser(cookies), 'avatars.review');
|
||||
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 review = await (await database.prepare(`SELECT r.*,a.source,a.updated_at AS current_updated_at
|
||||
FROM avatar_reviews r LEFT JOIN user_avatars a ON a.user_id=r.user_id WHERE r.id=? AND r.status='pending'`)).get(id);
|
||||
if (!review) return fail(404, { avatarReviewError: 'That pending avatar review no longer exists.' });
|
||||
if (review.source !== 'upload' || review.current_updated_at !== review.avatar_updated_at) {
|
||||
return fail(409, { avatarReviewError: 'This image is no longer the agent’s current custom avatar.' });
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
await (await database.prepare("UPDATE avatar_reviews SET status='approved',reviewed_at=?,reviewed_by=?,review_note=? WHERE id=? AND status='pending'"))
|
||||
.run(now, reviewer.id, note || null, id);
|
||||
await (await database.prepare(`UPDATE user_content_reports SET status='dismissed',reviewed_at=?,
|
||||
reviewed_by=?,resolution_reason=? WHERE content_type='avatar' AND content_id=? AND status='pending'`))
|
||||
.run(now, reviewer.id, note || 'Oversight approved the current custom avatar.', review.user_id);
|
||||
await recordIsacAudit({ actorId: reviewer.id, category: 'user_moderation', action: 'avatar.approved', entityId: String(review.user_id), before: { reviewId: id, status: 'pending' }, after: { reviewId: id, status: 'approved', note: note || null } });
|
||||
return { avatarReviewSaved: true, avatarReviewToken: randomUUID() };
|
||||
},
|
||||
remove: async ({ cookies, request }) => {
|
||||
const reviewer = await requirePermission(await getSessionUser(cookies), 'avatars.review');
|
||||
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, { avatarReviewError: 'Add a brief reason for removing this avatar.' });
|
||||
const database = await getDatabase();
|
||||
const review = await (await database.prepare(`SELECT r.*,a.source,a.value AS current_storage_key,a.updated_at AS current_updated_at
|
||||
FROM avatar_reviews r LEFT JOIN user_avatars a ON a.user_id=r.user_id WHERE r.id=? AND r.status='pending'`)).get(id);
|
||||
if (!review) return fail(404, { avatarReviewError: 'That pending avatar review no longer exists.' });
|
||||
if (review.source !== 'upload' || review.current_updated_at !== review.avatar_updated_at || review.current_storage_key !== review.storage_key) {
|
||||
return fail(409, { avatarReviewError: 'This image is no longer the agent’s current custom avatar.' });
|
||||
}
|
||||
const before = { reviewId: id, status: 'pending', storageKey: review.storage_key, byteSize: review.byte_size };
|
||||
await deleteUploadedAvatar(database, String(review.user_id));
|
||||
const now = new Date().toISOString();
|
||||
await (await database.prepare("UPDATE avatar_reviews SET status='removed',reviewed_at=?,reviewed_by=?,review_note=? WHERE id=?"))
|
||||
.run(now, reviewer.id, note, id);
|
||||
await (await database.prepare(`UPDATE user_content_reports SET status='resolved',reviewed_at=?,
|
||||
reviewed_by=?,resolution_reason=? WHERE content_type='avatar' AND content_id=? AND status='pending'`))
|
||||
.run(now, reviewer.id, note, review.user_id);
|
||||
await recordIsacAudit({ actorId: reviewer.id, category: 'user_moderation', action: 'avatar.removed', entityId: String(review.user_id), before, after: { reviewId: id, status: 'removed', source: 'default', note } });
|
||||
await createNotification({ userId: String(review.user_id), category: 'isac', source: 'ISAC AVATAR OVERSIGHT', title: 'Custom avatar removed', body: `Your custom avatar did not meet SHD.build image requirements and was removed. Oversight reason: ${note}`, link: '/account#avatar', mandatory: true });
|
||||
return { avatarReviewSaved: true, avatarReviewToken: randomUUID() };
|
||||
}
|
||||
approve: async ({ cookies, request }) => {
|
||||
const reviewer = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'avatars.review',
|
||||
);
|
||||
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 review = await (
|
||||
await database.prepare(`SELECT r.*,a.source,a.updated_at AS current_updated_at
|
||||
FROM avatar_reviews r LEFT JOIN user_avatars a ON a.user_id=r.user_id WHERE r.id=? AND r.status='pending'`)
|
||||
).get(id);
|
||||
if (!review)
|
||||
return fail(404, {
|
||||
avatarReviewError: 'That pending avatar review no longer exists.',
|
||||
});
|
||||
if (
|
||||
review.source !== 'upload' ||
|
||||
review.current_updated_at !== review.avatar_updated_at
|
||||
) {
|
||||
return fail(409, {
|
||||
avatarReviewError:
|
||||
'This image is no longer the agent’s current custom avatar.',
|
||||
});
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
await (
|
||||
await database.prepare(
|
||||
"UPDATE avatar_reviews SET status='approved',reviewed_at=?,reviewed_by=?,review_note=? WHERE id=? AND status='pending'",
|
||||
)
|
||||
).run(now, reviewer.id, note || null, id);
|
||||
await (
|
||||
await database.prepare(`UPDATE user_content_reports SET status='dismissed',reviewed_at=?,
|
||||
reviewed_by=?,resolution_reason=? WHERE content_type='avatar' AND content_id=? AND status='pending'`)
|
||||
).run(
|
||||
now,
|
||||
reviewer.id,
|
||||
note || 'Oversight approved the current custom avatar.',
|
||||
review.user_id,
|
||||
);
|
||||
await recordIsacAudit({
|
||||
actorId: reviewer.id,
|
||||
category: 'user_moderation',
|
||||
action: 'avatar.approved',
|
||||
entityId: String(review.user_id),
|
||||
before: { reviewId: id, status: 'pending' },
|
||||
after: { reviewId: id, status: 'approved', note: note || null },
|
||||
});
|
||||
return { avatarReviewSaved: true, avatarReviewToken: randomUUID() };
|
||||
},
|
||||
remove: async ({ cookies, request }) => {
|
||||
const reviewer = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'avatars.review',
|
||||
);
|
||||
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, {
|
||||
avatarReviewError: 'Add a brief reason for removing this avatar.',
|
||||
});
|
||||
const database = await getDatabase();
|
||||
const review = await (
|
||||
await database.prepare(`SELECT r.*,a.source,a.value AS current_storage_key,a.updated_at AS current_updated_at
|
||||
FROM avatar_reviews r LEFT JOIN user_avatars a ON a.user_id=r.user_id WHERE r.id=? AND r.status='pending'`)
|
||||
).get(id);
|
||||
if (!review)
|
||||
return fail(404, {
|
||||
avatarReviewError: 'That pending avatar review no longer exists.',
|
||||
});
|
||||
if (
|
||||
review.source !== 'upload' ||
|
||||
review.current_updated_at !== review.avatar_updated_at ||
|
||||
review.current_storage_key !== review.storage_key
|
||||
) {
|
||||
return fail(409, {
|
||||
avatarReviewError:
|
||||
'This image is no longer the agent’s current custom avatar.',
|
||||
});
|
||||
}
|
||||
const before = {
|
||||
reviewId: id,
|
||||
status: 'pending',
|
||||
storageKey: review.storage_key,
|
||||
byteSize: review.byte_size,
|
||||
};
|
||||
await deleteUploadedAvatar(database, String(review.user_id));
|
||||
const now = new Date().toISOString();
|
||||
await (
|
||||
await database.prepare(
|
||||
"UPDATE avatar_reviews SET status='removed',reviewed_at=?,reviewed_by=?,review_note=? WHERE id=?",
|
||||
)
|
||||
).run(now, reviewer.id, note, id);
|
||||
await (
|
||||
await database.prepare(`UPDATE user_content_reports SET status='resolved',reviewed_at=?,
|
||||
reviewed_by=?,resolution_reason=? WHERE content_type='avatar' AND content_id=? AND status='pending'`)
|
||||
).run(now, reviewer.id, note, review.user_id);
|
||||
await recordIsacAudit({
|
||||
actorId: reviewer.id,
|
||||
category: 'user_moderation',
|
||||
action: 'avatar.removed',
|
||||
entityId: String(review.user_id),
|
||||
before,
|
||||
after: { reviewId: id, status: 'removed', source: 'default', note },
|
||||
});
|
||||
await createNotification({
|
||||
userId: String(review.user_id),
|
||||
category: 'isac',
|
||||
source: 'ISAC AVATAR OVERSIGHT',
|
||||
title: 'Custom avatar removed',
|
||||
body: `Your custom avatar did not meet SHD.build image requirements and was removed. Oversight reason: ${note}`,
|
||||
link: '/account#avatar',
|
||||
mandatory: true,
|
||||
});
|
||||
return { avatarReviewSaved: true, avatarReviewToken: randomUUID() };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,39 +1,99 @@
|
||||
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
|
||||
import {
|
||||
requireDataView,
|
||||
requirePermission,
|
||||
} from '$lib/server/auth/authorization';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination';
|
||||
import {
|
||||
getPagination,
|
||||
getRegistrySearch,
|
||||
getRegistrySort,
|
||||
} from '$lib/server/pagination';
|
||||
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||
import { applyIconMutation, gameDataIconMetadata, gameDataIconUrl, GameDataIconValidationError, readIconMutation } from '$lib/server/game-data-icons';
|
||||
import {
|
||||
applyIconMutation,
|
||||
gameDataIconMetadata,
|
||||
gameDataIconUrl,
|
||||
GameDataIconValidationError,
|
||||
readIconMutation,
|
||||
} from '$lib/server/game-data-icons';
|
||||
import {
|
||||
applyGameDataRender,
|
||||
ManagedImageValidationError,
|
||||
managedImageUrl,
|
||||
processManagedImage,
|
||||
readGameDataRenderMutation,
|
||||
} from '$lib/server/managed-images';
|
||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
||||
|
||||
const thresholds = [2, 3, 4] as const;
|
||||
|
||||
const renderAuditMetadata = (render: Record<string, any> | null | undefined) =>
|
||||
render
|
||||
? {
|
||||
id: render.id,
|
||||
name: render.name,
|
||||
category: render.category,
|
||||
tags_json: render.tags_json,
|
||||
original_filename: render.original_filename,
|
||||
byte_size: render.byte_size,
|
||||
width: render.width,
|
||||
height: render.height,
|
||||
source_entity_type: render.source_entity_type,
|
||||
source_entity_id: render.source_entity_id,
|
||||
created_at: render.created_at,
|
||||
updated_at: render.updated_at,
|
||||
}
|
||||
: null;
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const user = await requireDataView(await getSessionUser(cookies));
|
||||
const database = await getDatabase();
|
||||
const [pendingBrands, pendingStats] = await Promise.all([
|
||||
getPendingEntities(user.id, [{ module: 'brands', action: 'save', kind: 'brand', fields: ['stat2', 'value2', 'stat3', 'value3', 'stat4', 'value4'] }]),
|
||||
getPendingEntities(user.id, [{ module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] }])
|
||||
]);
|
||||
const mappedPendingBrands = pendingBrands.map((brand) => ({
|
||||
...brand,
|
||||
iconUrl: '',
|
||||
effects: thresholds.map((pieces) => ({
|
||||
pieces_required: pieces,
|
||||
stat_attribute_id: String(brand[`stat${pieces}`] ?? ''),
|
||||
value: Number(brand[`value${pieces}`] ?? 0)
|
||||
}))
|
||||
}));
|
||||
const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
|
||||
const user = await requireDataView(await getSessionUser(cookies));
|
||||
const database = await getDatabase();
|
||||
const [pendingBrands, pendingStats] = await Promise.all([
|
||||
getPendingEntities(user.id, [
|
||||
{
|
||||
module: 'brands',
|
||||
action: 'save',
|
||||
kind: 'brand',
|
||||
fields: ['stat2', 'value2', 'stat3', 'value3', 'stat4', 'value4'],
|
||||
},
|
||||
]),
|
||||
getPendingEntities(user.id, [
|
||||
{
|
||||
module: 'attributes',
|
||||
action: 'save',
|
||||
kind: 'attribute',
|
||||
fields: ['unit'],
|
||||
},
|
||||
]),
|
||||
]);
|
||||
const mappedPendingBrands = pendingBrands.map((brand) => ({
|
||||
...brand,
|
||||
iconUrl: '',
|
||||
renderUrl: '',
|
||||
effects: thresholds.map((pieces) => ({
|
||||
pieces_required: pieces,
|
||||
stat_attribute_id: String(brand[`stat${pieces}`] ?? ''),
|
||||
value: Number(brand[`value${pieces}`] ?? 0),
|
||||
})),
|
||||
}));
|
||||
const summary = await (
|
||||
await database.prepare(`SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
||||
SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived,
|
||||
SUM(CASE WHEN (SELECT COUNT(*) FROM brand_set_effects WHERE brand_set_id=brand_sets.id)=3 THEN 1 ELSE 0 END) AS complete FROM brand_sets`)).get();
|
||||
const filter = ['active', 'archived', 'complete'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const bonusOrder = (pieces: number, direction: 'ASC' | 'DESC') => `COALESCE((
|
||||
SUM(CASE WHEN (SELECT COUNT(*) FROM brand_set_effects WHERE brand_set_id=brand_sets.id)=3 THEN 1 ELSE 0 END) AS complete FROM brand_sets`)
|
||||
).get();
|
||||
const filter = ['active', 'archived', 'complete'].includes(
|
||||
url.searchParams.get('filter') ?? '',
|
||||
)
|
||||
? String(url.searchParams.get('filter'))
|
||||
: '';
|
||||
const requestedItemId = url.searchParams.get('item')?.trim() ?? '';
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const bonusOrder = (pieces: number, direction: 'ASC' | 'DESC') => `COALESCE((
|
||||
SELECT attributes.name FROM brand_set_effects effect
|
||||
JOIN stat_attributes attributes ON attributes.id=effect.stat_attribute_id
|
||||
WHERE effect.brand_set_id=brand_sets.id AND effect.pieces_required=${pieces}
|
||||
@@ -43,102 +103,356 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
WHERE effect.brand_set_id=brand_sets.id AND effect.pieces_required=${pieces}
|
||||
LIMIT 1
|
||||
), 0) ${direction}, name COLLATE NOCASE ASC, id ASC`;
|
||||
const sort = getRegistrySort(url, {
|
||||
name: { asc: 'name COLLATE NOCASE ASC, id ASC', desc: 'name COLLATE NOCASE DESC, id DESC' },
|
||||
bonus2: { asc: bonusOrder(2, 'ASC'), desc: bonusOrder(2, 'DESC') },
|
||||
bonus3: { asc: bonusOrder(3, 'ASC'), desc: bonusOrder(3, 'DESC') },
|
||||
bonus4: { asc: bonusOrder(4, 'ASC'), desc: bonusOrder(4, 'DESC') },
|
||||
state: { asc: 'active ASC, name COLLATE NOCASE ASC, id ASC', desc: 'active DESC, name COLLATE NOCASE ASC, id ASC', defaultDirection: 'desc' }
|
||||
}, 'name');
|
||||
const conditions = [
|
||||
...(filter === 'active' ? ['active=1'] : filter === 'archived' ? ['active=0'] : filter === 'complete' ? ['(SELECT COUNT(*) FROM brand_set_effects WHERE brand_set_id=brand_sets.id)=3'] : []),
|
||||
...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
|
||||
];
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const args = search ? [pattern] : [];
|
||||
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM brand_sets ${where}`)).get(...args);
|
||||
const visiblePending = mappedPendingBrands.filter((brand) => filter !== 'archived' && (!search || String(brand.name).toLocaleLowerCase().includes(search.toLocaleLowerCase()))).sort((left, right) => {
|
||||
const pieces = sort.key.startsWith('bonus') ? Number(sort.key.slice(5)) : 0;
|
||||
const valueFor = (brand: any) => pieces
|
||||
? `${brand.effects.find((effect: any) => Number(effect.pieces_required) === pieces)?.stat_attribute_id ?? ''}:${brand.effects.find((effect: any) => Number(effect.pieces_required) === pieces)?.value ?? 0}`
|
||||
: sort.key === 'state' ? String(brand.active ?? 1) : String(brand.name);
|
||||
const result = valueFor(left).localeCompare(valueFor(right), undefined, { sensitivity: 'base', numeric: true });
|
||||
return (sort.direction === 'asc' ? result : -result) || String(left.name).localeCompare(String(right.name), undefined, { sensitivity: 'base' });
|
||||
});
|
||||
const pagination = getPagination(url, Number(filtered?.total ?? 0) + visiblePending.length);
|
||||
const pendingOnPage = pagination.page === 1 ? visiblePending.slice(0, pagination.perPage) : [];
|
||||
const databaseOffset = Math.max(0, pagination.offset - visiblePending.length);
|
||||
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
|
||||
const [brands, effects, stats, effectSummary, icons] = await Promise.all([
|
||||
databaseLimit ? (await database.prepare(`SELECT * FROM brand_sets ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset) : [],
|
||||
(await database.prepare('SELECT brand_set_id,pieces_required,stat_attribute_id,value FROM brand_set_effects ORDER BY pieces_required')).all(),
|
||||
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare('SELECT COUNT(*) AS total FROM brand_set_effects')).get(),
|
||||
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'")).all()
|
||||
]);
|
||||
return {
|
||||
brands: [...pendingOnPage, ...brands.map((brand) => {
|
||||
const icon = icons.find((item) => item.entity_id === brand.id);
|
||||
return { ...brand, iconUrl: icon ? gameDataIconUrl('brand_set', String(brand.id), icon.updated_at) : '', effects: effects.filter((effect) => effect.brand_set_id === brand.id) };
|
||||
})],
|
||||
stats: [...pendingStats, ...stats],
|
||||
summary: { ...summary, total: Number(summary?.total ?? 0) + pendingBrands.length, pending: pendingBrands.length, effects: Number(effectSummary?.total ?? 0) },
|
||||
pagination,
|
||||
filter,
|
||||
search,
|
||||
sort
|
||||
};
|
||||
const sort = getRegistrySort(
|
||||
url,
|
||||
{
|
||||
name: {
|
||||
asc: 'name COLLATE NOCASE ASC, id ASC',
|
||||
desc: 'name COLLATE NOCASE DESC, id DESC',
|
||||
},
|
||||
bonus2: { asc: bonusOrder(2, 'ASC'), desc: bonusOrder(2, 'DESC') },
|
||||
bonus3: { asc: bonusOrder(3, 'ASC'), desc: bonusOrder(3, 'DESC') },
|
||||
bonus4: { asc: bonusOrder(4, 'ASC'), desc: bonusOrder(4, 'DESC') },
|
||||
state: {
|
||||
asc: 'active ASC, name COLLATE NOCASE ASC, id ASC',
|
||||
desc: 'active DESC, name COLLATE NOCASE ASC, id ASC',
|
||||
defaultDirection: 'desc',
|
||||
},
|
||||
},
|
||||
'name',
|
||||
);
|
||||
const conditions = [
|
||||
...(requestedItemId
|
||||
? ['id=?']
|
||||
: filter === 'active'
|
||||
? ['active=1']
|
||||
: filter === 'archived'
|
||||
? ['active=0']
|
||||
: filter === 'complete'
|
||||
? [
|
||||
'(SELECT COUNT(*) FROM brand_set_effects WHERE brand_set_id=brand_sets.id)=3',
|
||||
]
|
||||
: []),
|
||||
...(!requestedItemId && search
|
||||
? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"]
|
||||
: []),
|
||||
];
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const args = [
|
||||
...(requestedItemId ? [requestedItemId] : []),
|
||||
...(!requestedItemId && search ? [pattern] : []),
|
||||
];
|
||||
const filtered = await (
|
||||
await database.prepare(`SELECT COUNT(*) AS total FROM brand_sets ${where}`)
|
||||
).get(...args);
|
||||
const visiblePending = mappedPendingBrands
|
||||
.filter((brand) =>
|
||||
requestedItemId
|
||||
? brand.id === requestedItemId
|
||||
: filter !== 'archived' &&
|
||||
(!search ||
|
||||
String(brand.name)
|
||||
.toLocaleLowerCase()
|
||||
.includes(search.toLocaleLowerCase())),
|
||||
)
|
||||
.sort((left, right) => {
|
||||
const pieces = sort.key.startsWith('bonus')
|
||||
? Number(sort.key.slice(5))
|
||||
: 0;
|
||||
const valueFor = (brand: any) =>
|
||||
pieces
|
||||
? `${brand.effects.find((effect: any) => Number(effect.pieces_required) === pieces)?.stat_attribute_id ?? ''}:${brand.effects.find((effect: any) => Number(effect.pieces_required) === pieces)?.value ?? 0}`
|
||||
: sort.key === 'state'
|
||||
? String(brand.active ?? 1)
|
||||
: String(brand.name);
|
||||
const result = valueFor(left).localeCompare(valueFor(right), undefined, {
|
||||
sensitivity: 'base',
|
||||
numeric: true,
|
||||
});
|
||||
return (
|
||||
(sort.direction === 'asc' ? result : -result) ||
|
||||
String(left.name).localeCompare(String(right.name), undefined, {
|
||||
sensitivity: 'base',
|
||||
})
|
||||
);
|
||||
});
|
||||
const pagination = getPagination(
|
||||
url,
|
||||
Number(filtered?.total ?? 0) + visiblePending.length,
|
||||
);
|
||||
const pendingOnPage =
|
||||
pagination.page === 1 ? visiblePending.slice(0, pagination.perPage) : [];
|
||||
const databaseOffset = Math.max(0, pagination.offset - visiblePending.length);
|
||||
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
|
||||
const [brands, effects, stats, effectSummary, icons, renders] =
|
||||
await Promise.all([
|
||||
databaseLimit
|
||||
? (
|
||||
await database.prepare(
|
||||
`SELECT * FROM brand_sets ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`,
|
||||
)
|
||||
).all(...args, databaseLimit, databaseOffset)
|
||||
: [],
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT brand_set_id,pieces_required,stat_attribute_id,value FROM brand_set_effects ORDER BY pieces_required',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT COUNT(*) AS total FROM brand_set_effects',
|
||||
)
|
||||
).get(),
|
||||
(
|
||||
await database.prepare(
|
||||
"SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'",
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
"SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='brand_set_render'",
|
||||
)
|
||||
).all(),
|
||||
]);
|
||||
return {
|
||||
brands: [
|
||||
...pendingOnPage,
|
||||
...brands.map((brand) => {
|
||||
const icon = icons.find((item) => item.entity_id === brand.id);
|
||||
const render = renders.find(
|
||||
(item) => item.source_entity_id === brand.id,
|
||||
);
|
||||
return {
|
||||
...brand,
|
||||
iconUrl: icon
|
||||
? gameDataIconUrl('brand_set', String(brand.id), icon.updated_at)
|
||||
: '',
|
||||
renderUrl: render
|
||||
? managedImageUrl(String(render.id), render.updated_at)
|
||||
: '',
|
||||
effects: effects.filter((effect) => effect.brand_set_id === brand.id),
|
||||
};
|
||||
}),
|
||||
],
|
||||
stats: [...pendingStats, ...stats],
|
||||
summary: {
|
||||
...summary,
|
||||
total: Number(summary?.total ?? 0) + pendingBrands.length,
|
||||
pending: pendingBrands.length,
|
||||
effects: Number(effectSummary?.total ?? 0),
|
||||
},
|
||||
pagination,
|
||||
filter,
|
||||
requestedItemId,
|
||||
search,
|
||||
sort,
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
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, { brandError: error.message }); throw error; }
|
||||
const id = String(form.get('id') ?? '');
|
||||
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
||||
if (name.length < 2 || name.length > 80) return fail(400, { brandError: 'Brand set names must be 2–80 characters.' });
|
||||
const effects = thresholds.map((pieces) => ({ pieces, statId: String(form.get(`stat${pieces}`) ?? ''), value: Number(form.get(`value${pieces}`)) }));
|
||||
if (effects.some((effect) => !effect.statId || !Number.isFinite(effect.value) || effect.value < 0)) {
|
||||
return fail(400, { brandError: 'Every threshold needs an attribute and a non-negative value.' });
|
||||
}
|
||||
const database = await getDatabase();
|
||||
if (await (await database.prepare('SELECT id FROM brand_sets WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) {
|
||||
return fail(409, { brandError: 'That brand set already exists.' });
|
||||
}
|
||||
const uniqueStatIds = [...new Set(effects.map((effect) => effect.statId))];
|
||||
const validStats = await (await database.prepare(`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${uniqueStatIds.map(() => '?').join(',')})`)).get(...uniqueStatIds);
|
||||
if (Number(validStats?.count) !== uniqueStatIds.length) return fail(400, { brandError: 'One or more selected attributes are unavailable.' });
|
||||
const brandId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(), now = new Date().toISOString();
|
||||
const before = id ? { record: await auditRecord('brand_sets', id), icon: await gameDataIconMetadata(database, 'brand_set', id), effects: await (await database.prepare('SELECT * FROM brand_set_effects WHERE brand_set_id=? ORDER BY pieces_required')).all(id) } : null;
|
||||
if (id) await (await database.prepare('UPDATE brand_sets SET name=?,active=1,updated_at=? WHERE id=?')).run(name, now, id);
|
||||
else await (await database.prepare('INSERT INTO brand_sets(id,name,created_at,updated_at) VALUES(?,?,?,?)')).run(brandId, name, now, now);
|
||||
for (const effect of effects) {
|
||||
await (await database.prepare(`INSERT INTO brand_set_effects(id,brand_set_id,pieces_required,stat_attribute_id,value,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?) ON CONFLICT(brand_set_id,pieces_required) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,value=excluded.value,updated_at=excluded.updated_at`
|
||||
)).run(randomUUID(), brandId, effect.pieces, effect.statId, effect.value, now, now);
|
||||
}
|
||||
await applyIconMutation(database, 'brand_set', brandId, iconMutation, operator.id);
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'brand_set.updated' : 'brand_set.created', entityId: brandId, before, after: { record: await auditRecord('brand_sets', brandId), icon: await gameDataIconMetadata(database, 'brand_set', brandId), effects } });
|
||||
return { brandSaved: true, brandMode: 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 auditRecord('brand_sets', id);
|
||||
await (await (await getDatabase()).prepare('UPDATE brand_sets SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'brand_set.archived', entityId: id, before, after: await auditRecord('brand_sets', id) });
|
||||
return { brandArchived: true, resultToken: randomUUID() };
|
||||
},
|
||||
unarchive: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
const before = await auditRecord('brand_sets', id);
|
||||
if (!before) return fail(404, { brandError: 'Brand set not found.' });
|
||||
await (await (await getDatabase()).prepare('UPDATE brand_sets SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'brand_set.unarchived', entityId: id, before, after: await auditRecord('brand_sets', id) });
|
||||
return { brandUnarchived: 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, { brandError: error.message });
|
||||
throw error;
|
||||
}
|
||||
const renderMutation = await readGameDataRenderMutation(form);
|
||||
try {
|
||||
if (renderMutation.file)
|
||||
renderMutation.processed = await processManagedImage(
|
||||
renderMutation.file,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ManagedImageValidationError)
|
||||
return fail(400, { brandError: error.message });
|
||||
throw error;
|
||||
}
|
||||
const id = String(form.get('id') ?? '');
|
||||
const name = String(form.get('name') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
if (name.length < 2 || name.length > 80)
|
||||
return fail(400, {
|
||||
brandError: 'Brand set names must be 2–80 characters.',
|
||||
});
|
||||
const effects = thresholds.map((pieces) => ({
|
||||
pieces,
|
||||
statId: String(form.get(`stat${pieces}`) ?? ''),
|
||||
value: Number(form.get(`value${pieces}`)),
|
||||
}));
|
||||
if (
|
||||
effects.some(
|
||||
(effect) =>
|
||||
!effect.statId || !Number.isFinite(effect.value) || effect.value < 0,
|
||||
)
|
||||
) {
|
||||
return fail(400, {
|
||||
brandError:
|
||||
'Every threshold needs an attribute and a non-negative value.',
|
||||
});
|
||||
}
|
||||
const database = await getDatabase();
|
||||
if (
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT id FROM brand_sets WHERE name=? COLLATE NOCASE AND id<>?',
|
||||
)
|
||||
).get(name, id || 'new')
|
||||
) {
|
||||
return fail(409, { brandError: 'That brand set already exists.' });
|
||||
}
|
||||
const uniqueStatIds = [...new Set(effects.map((effect) => effect.statId))];
|
||||
const validStats = await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM stat_attributes WHERE active=1 AND id IN (${uniqueStatIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).get(...uniqueStatIds);
|
||||
if (Number(validStats?.count) !== uniqueStatIds.length)
|
||||
return fail(400, {
|
||||
brandError: 'One or more selected attributes are unavailable.',
|
||||
});
|
||||
const brandId =
|
||||
id || String(form.get('_proposedEntityId') ?? '') || randomUUID(),
|
||||
now = new Date().toISOString();
|
||||
const before = id
|
||||
? {
|
||||
record: await auditRecord('brand_sets', id),
|
||||
icon: await gameDataIconMetadata(database, 'brand_set', id),
|
||||
effects: await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM brand_set_effects WHERE brand_set_id=? ORDER BY pieces_required',
|
||||
)
|
||||
).all(id),
|
||||
render: renderAuditMetadata(
|
||||
await (
|
||||
await database.prepare(
|
||||
"SELECT * FROM managed_images WHERE source_entity_type='brand_set_render' AND source_entity_id=?",
|
||||
)
|
||||
).get(id),
|
||||
),
|
||||
}
|
||||
: null;
|
||||
if (id)
|
||||
await (
|
||||
await database.prepare(
|
||||
'UPDATE brand_sets SET name=?,active=1,updated_at=? WHERE id=?',
|
||||
)
|
||||
).run(name, now, id);
|
||||
else
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO brand_sets(id,name,created_at,updated_at) VALUES(?,?,?,?)',
|
||||
)
|
||||
).run(brandId, name, now, now);
|
||||
for (const effect of effects) {
|
||||
await (
|
||||
await database.prepare(`INSERT INTO brand_set_effects(id,brand_set_id,pieces_required,stat_attribute_id,value,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?) ON CONFLICT(brand_set_id,pieces_required) DO UPDATE SET stat_attribute_id=excluded.stat_attribute_id,value=excluded.value,updated_at=excluded.updated_at`)
|
||||
).run(
|
||||
randomUUID(),
|
||||
brandId,
|
||||
effect.pieces,
|
||||
effect.statId,
|
||||
effect.value,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
}
|
||||
await applyIconMutation(
|
||||
database,
|
||||
'brand_set',
|
||||
brandId,
|
||||
iconMutation,
|
||||
operator.id,
|
||||
);
|
||||
const render = renderAuditMetadata(
|
||||
await applyGameDataRender(
|
||||
database,
|
||||
'brand_set',
|
||||
brandId,
|
||||
name,
|
||||
renderMutation,
|
||||
operator.id,
|
||||
),
|
||||
);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: id ? 'brand_set.updated' : 'brand_set.created',
|
||||
entityId: brandId,
|
||||
before,
|
||||
after: {
|
||||
record: await auditRecord('brand_sets', brandId),
|
||||
icon: await gameDataIconMetadata(database, 'brand_set', brandId),
|
||||
effects,
|
||||
render,
|
||||
},
|
||||
});
|
||||
return {
|
||||
brandSaved: true,
|
||||
brandMode: 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 auditRecord('brand_sets', id);
|
||||
await (
|
||||
await (
|
||||
await getDatabase()
|
||||
).prepare('UPDATE brand_sets SET active=0,updated_at=? WHERE id=?')
|
||||
).run(new Date().toISOString(), id);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: 'brand_set.archived',
|
||||
entityId: id,
|
||||
before,
|
||||
after: await auditRecord('brand_sets', id),
|
||||
});
|
||||
return { brandArchived: true, resultToken: randomUUID() };
|
||||
},
|
||||
unarchive: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.manage',
|
||||
);
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
const before = await auditRecord('brand_sets', id);
|
||||
if (!before) return fail(404, { brandError: 'Brand set not found.' });
|
||||
await (
|
||||
await (
|
||||
await getDatabase()
|
||||
).prepare('UPDATE brand_sets SET active=1,updated_at=? WHERE id=?')
|
||||
).run(new Date().toISOString(), id);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: 'brand_set.unarchived',
|
||||
entityId: id,
|
||||
before,
|
||||
after: await auditRecord('brand_sets', id),
|
||||
});
|
||||
return { brandUnarchived: true, resultToken: randomUUID() };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,20 +5,28 @@
|
||||
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
|
||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||||
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
|
||||
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||
let { data, form } = $props();
|
||||
type Effect = { pieces_required: number; stat_attribute_id: string; value: number };
|
||||
type Editor = { id: string; name: string; iconUrl: string; active: number; effects: Effect[] };
|
||||
const blank = (): Editor => ({ id: '', name: '', iconUrl: '', active: 1, effects: [2, 3, 4].map((pieces) => ({ pieces_required: pieces, stat_attribute_id: '', value: 0 })) });
|
||||
type Editor = { id: string; name: string; iconUrl: string; renderUrl: string; active: number; effects: Effect[] };
|
||||
const blank = (): Editor => ({ id: '', name: '', iconUrl: '', renderUrl: '', active: 1, effects: [2, 3, 4].map((pieces) => ({ pieces_required: pieces, stat_attribute_id: '', value: 0 })) });
|
||||
let editing = $state<Editor | null>(null);
|
||||
let handledResultToken = $state('');
|
||||
let iconResetToken = $state(0);
|
||||
let renderResetToken = $state(0);
|
||||
let openedRequestedItem = $state(false);
|
||||
const effectFor = (brand: Editor, pieces: number) => brand.effects.find((effect) => Number(effect.pieces_required) === pieces) ?? { pieces_required: pieces, stat_attribute_id: '', value: 0 };
|
||||
const statFor = (id: string) => data.stats.find((stat) => stat.id === id);
|
||||
const formatEffect = (effect: Effect) => { const stat = statFor(effect.stat_attribute_id); return `${effect.value}${stat?.unit ?? ''} ${stat?.name ?? 'Unknown attribute'}`; };
|
||||
const edit = (brand: Editor) => { editing = { id: brand.id, name: brand.name, iconUrl: brand.iconUrl, active: Number(brand.active), effects: [2, 3, 4].map((pieces) => ({ ...effectFor(brand, pieces) })) }; };
|
||||
const edit = (brand: Editor) => { editing = { id: brand.id, name: brand.name, iconUrl: brand.iconUrl, renderUrl: brand.renderUrl, active: Number(brand.active), effects: [2, 3, 4].map((pieces) => ({ ...effectFor(brand, pieces) })) }; };
|
||||
$effect(() => {
|
||||
if (!openedRequestedItem && data.requestedItemId) {
|
||||
const requested = data.brands.find((brand) => brand.id === data.requestedItemId);
|
||||
if (requested) edit(requested);
|
||||
openedRequestedItem = true;
|
||||
}
|
||||
if ((form?.brandArchived || form?.brandUnarchived) && form.resultToken && form.resultToken !== handledResultToken) {
|
||||
handledResultToken = form.resultToken;
|
||||
editing = null;
|
||||
@@ -26,7 +34,10 @@
|
||||
}
|
||||
if (form?.brandSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
||||
handledResultToken = form.resultToken;
|
||||
if (form.brandMode === 'add') iconResetToken += 1;
|
||||
if (form.brandMode === 'add') {
|
||||
iconResetToken += 1;
|
||||
renderResetToken += 1;
|
||||
}
|
||||
editing = form.brandMode === 'add' ? blank() : null;
|
||||
}
|
||||
});
|
||||
@@ -40,6 +51,7 @@
|
||||
<input type="hidden" name="id" value={editing.id} />
|
||||
<div class="brand-editor-body">
|
||||
<IconUploadField currentUrl={editing.iconUrl} label="BRAND SET ICON" resetToken={iconResetToken}/>
|
||||
<GameDataRenderUploadField currentUrl={editing.renderUrl} label="BRAND SET RENDER" resetToken={renderResetToken}/>
|
||||
<div>
|
||||
<label class="field brand-name">BRAND NAME<input name="name" bind:value={editing.name} required maxlength="80" /></label>
|
||||
<div class="effect-editor">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,370 @@
|
||||
import { error, fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getPermissions } from '$lib/server/auth/authorization';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import { createNotification } from '$lib/server/notifications';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const allowed = (permissions: string[], permission: string) =>
|
||||
permissions.includes('*') || permissions.includes(permission);
|
||||
const reason = (value: unknown) =>
|
||||
String(value ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim()
|
||||
.slice(0, 500);
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) error(401, 'Sign in required');
|
||||
const permissions = await getPermissions(user);
|
||||
const canModerate = allowed(permissions, 'community.guides.moderate');
|
||||
const canCurate = allowed(permissions, 'guides.curate');
|
||||
if (!canModerate && !canCurate)
|
||||
error(403, 'Guide Oversight permission required');
|
||||
const database = await getDatabase();
|
||||
const [requests, reports, guides] = await Promise.all([
|
||||
canModerate
|
||||
? (
|
||||
await database.prepare(`SELECT request.*,revision.title,revision.deck,revision.plain_text,
|
||||
scan.status AS scan_status,scan.risk_level,scan.reason AS scan_reason,scan.provider,scan.model,
|
||||
COALESCE(profile.display_name,users.username,'Deleted Agent') AS author,articles.slug
|
||||
FROM article_publication_requests request JOIN articles ON articles.id=request.article_id
|
||||
JOIN article_revisions revision ON revision.id=request.revision_id
|
||||
LEFT JOIN content_moderation_scans scan ON scan.id=request.scan_id
|
||||
JOIN users ON users.id=articles.owner_id LEFT JOIN user_profiles profile ON profile.user_id=users.id
|
||||
WHERE request.status='pending_review' ORDER BY request.requested_at`)
|
||||
).all()
|
||||
: [],
|
||||
canModerate
|
||||
? (
|
||||
await database.prepare(`SELECT report.*,revision.title,revision.deck,articles.slug,
|
||||
COALESCE(profile.display_name,users.username,'Deleted Agent') AS author
|
||||
FROM user_content_reports report JOIN articles ON articles.id=report.content_id AND articles.kind='guide'
|
||||
JOIN article_revisions revision ON revision.id=report.article_revision_id
|
||||
JOIN users ON users.id=articles.owner_id LEFT JOIN user_profiles profile ON profile.user_id=users.id
|
||||
WHERE report.content_type='guide' AND report.status='pending' ORDER BY report.created_at`)
|
||||
).all()
|
||||
: [],
|
||||
canCurate
|
||||
? (
|
||||
await database.prepare(`SELECT articles.id,articles.slug,revision.id AS revision_id,revision.title,
|
||||
curation.pinned_order,COALESCE(curation.featured,0) AS featured,COALESCE(curation.recommended,0) AS recommended
|
||||
FROM articles JOIN article_revisions revision ON revision.id=articles.published_revision_id
|
||||
LEFT JOIN guide_curations curation ON curation.article_id=articles.id AND curation.curated_revision_id=revision.id
|
||||
WHERE articles.kind='guide' AND articles.visibility='public' AND articles.deleted_at IS NULL
|
||||
ORDER BY articles.published_at DESC`)
|
||||
).all()
|
||||
: [],
|
||||
]);
|
||||
return { canModerate, canCurate, requests, reports, guides };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
decide: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) return fail(401, { guideError: 'Sign in required.' });
|
||||
const permissions = await getPermissions(user);
|
||||
if (!allowed(permissions, 'community.guides.moderate'))
|
||||
return fail(403, { guideError: 'Guide moderation permission required.' });
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const decision = String(form.get('decision') ?? '');
|
||||
const reviewReason = reason(form.get('reason'));
|
||||
if (
|
||||
!id ||
|
||||
!['approve', 'reject'].includes(decision) ||
|
||||
reviewReason.length < 3
|
||||
)
|
||||
return fail(400, {
|
||||
guideError: 'Choose a decision and provide a reason.',
|
||||
});
|
||||
const database = await getDatabase();
|
||||
const row = await (
|
||||
await database.prepare(`SELECT request.*,articles.owner_id,articles.slug,articles.published_revision_id,
|
||||
revision.title,revision.deck,revision.plain_text,scan.id AS scan_id,scan.status AS scan_status
|
||||
FROM article_publication_requests request JOIN articles ON articles.id=request.article_id
|
||||
JOIN article_revisions revision ON revision.id=request.revision_id JOIN content_moderation_scans scan ON scan.id=request.scan_id
|
||||
WHERE request.id=? AND request.status='pending_review' AND scan.article_revision_id=request.revision_id`)
|
||||
).get(id);
|
||||
if (!row)
|
||||
return fail(404, {
|
||||
guideError: 'That Guide review is no longer pending.',
|
||||
});
|
||||
const now = new Date().toISOString();
|
||||
const reviewId = randomUUID();
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO content_moderation_reviews(id,scan_id,reviewer_id,decision,reason,created_at) VALUES(?,?,?,?,?,?)',
|
||||
)
|
||||
).run(
|
||||
reviewId,
|
||||
row.scan_id,
|
||||
user.id,
|
||||
decision === 'approve' ? 'approved' : 'rejected',
|
||||
reviewReason,
|
||||
now,
|
||||
);
|
||||
if (decision === 'approve') {
|
||||
const changed = await (
|
||||
await database.prepare(`UPDATE articles SET published_revision_id=?,pending_revision_id=NULL,visibility=?,moderation_hold=0,
|
||||
published_at=COALESCE(published_at,?),updated_at=? WHERE id=? AND pending_revision_id=?
|
||||
AND COALESCE(published_revision_id,'')=COALESCE(?,'')`)
|
||||
).run(
|
||||
row.revision_id,
|
||||
row.requested_visibility,
|
||||
now,
|
||||
now,
|
||||
row.article_id,
|
||||
row.revision_id,
|
||||
row.base_published_revision_id ?? null,
|
||||
);
|
||||
if (!changed.changes)
|
||||
throw new Error('The Guide changed before approval.');
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM article_search WHERE article_id=?',
|
||||
)
|
||||
).run(row.article_id);
|
||||
if (row.requested_visibility === 'public') {
|
||||
const searchTags = await (
|
||||
await database.prepare(
|
||||
`SELECT GROUP_CONCAT(tags.name,' ') AS names FROM article_revision_tags links JOIN article_tags tags ON tags.id=links.tag_id WHERE links.revision_id=?`,
|
||||
)
|
||||
).get(row.revision_id);
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO article_search(article_id,title,deck,body,tags) VALUES(?,?,?,?,?)',
|
||||
)
|
||||
).run(
|
||||
row.article_id,
|
||||
row.title,
|
||||
row.deck,
|
||||
row.plain_text,
|
||||
String(searchTags?.names ?? ''),
|
||||
);
|
||||
}
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM guide_curations WHERE article_id=?',
|
||||
)
|
||||
).run(row.article_id);
|
||||
} else
|
||||
await (
|
||||
await database.prepare(
|
||||
'UPDATE articles SET pending_revision_id=NULL,moderation_hold=0 WHERE id=? AND pending_revision_id=?',
|
||||
)
|
||||
).run(row.article_id, row.revision_id);
|
||||
await (
|
||||
await database.prepare(
|
||||
`UPDATE article_publication_requests SET status=?,decided_by=?,decided_at=?,decision_reason=? WHERE id=? AND status='pending_review'`,
|
||||
)
|
||||
).run(
|
||||
decision === 'approve' ? 'approved' : 'rejected',
|
||||
user.id,
|
||||
now,
|
||||
reviewReason,
|
||||
id,
|
||||
);
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'community_moderation',
|
||||
action: `guide.review_${decision === 'approve' ? 'approved' : 'rejected'}`,
|
||||
entityId: String(row.article_id),
|
||||
before: {
|
||||
requestId: id,
|
||||
revisionId: row.revision_id,
|
||||
scanId: row.scan_id,
|
||||
},
|
||||
after: {
|
||||
reviewId,
|
||||
reason: reviewReason,
|
||||
visibility:
|
||||
decision === 'approve' ? row.requested_visibility : 'unchanged',
|
||||
},
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
} catch (cause) {
|
||||
await database.exec('ROLLBACK');
|
||||
return fail(409, {
|
||||
guideError:
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: 'The review could not be saved.',
|
||||
});
|
||||
}
|
||||
await createNotification({
|
||||
userId: String(row.owner_id),
|
||||
category: 'isac',
|
||||
source: 'ISAC COMMUNITY OVERSIGHT',
|
||||
title: `Guide ${decision === 'approve' ? 'approved' : 'rejected'}`,
|
||||
body: `${row.title}: ${reviewReason}`,
|
||||
link: `/guides/${encodeURIComponent(String(row.slug))}`,
|
||||
actions: [
|
||||
{
|
||||
label: 'VIEW GUIDE',
|
||||
href: `/guides/${encodeURIComponent(String(row.slug))}`,
|
||||
},
|
||||
],
|
||||
mandatory: true,
|
||||
});
|
||||
return { guideSaved: true };
|
||||
},
|
||||
report: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) return fail(401, { guideError: 'Sign in required.' });
|
||||
const permissions = await getPermissions(user);
|
||||
if (!allowed(permissions, 'community.guides.moderate'))
|
||||
return fail(403, { guideError: 'Guide moderation permission required.' });
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const decision = String(form.get('decision') ?? '');
|
||||
const reviewReason = reason(form.get('reason'));
|
||||
if (
|
||||
!id ||
|
||||
!['dismiss', 'hide'].includes(decision) ||
|
||||
reviewReason.length < 3
|
||||
)
|
||||
return fail(400, {
|
||||
guideError: 'Choose a decision and provide a reason.',
|
||||
});
|
||||
const database = await getDatabase();
|
||||
const now = new Date().toISOString();
|
||||
let report: Record<string, unknown> | null = null;
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
report =
|
||||
(await (
|
||||
await database.prepare(`SELECT report.*,articles.owner_id,articles.slug,articles.visibility,articles.published_revision_id,revision.title
|
||||
FROM user_content_reports report JOIN articles ON articles.id=report.content_id JOIN article_revisions revision ON revision.id=report.article_revision_id
|
||||
WHERE report.id=? AND report.status='pending' AND report.content_type='guide'`)
|
||||
).get(id)) ?? null;
|
||||
if (!report) throw new Error('That report is no longer pending.');
|
||||
if (decision === 'hide') {
|
||||
const hidden = await (
|
||||
await database.prepare(
|
||||
"UPDATE articles SET visibility='private',moderation_hold=1,updated_at=? WHERE id=? AND published_revision_id=?",
|
||||
)
|
||||
).run(now, report.content_id, report.article_revision_id);
|
||||
if (!hidden.changes)
|
||||
throw new Error(
|
||||
'A newer Guide revision is now public; dismiss this stale report instead.',
|
||||
);
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM article_search WHERE article_id=?',
|
||||
)
|
||||
).run(report.content_id);
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM guide_curations WHERE article_id=?',
|
||||
)
|
||||
).run(report.content_id);
|
||||
}
|
||||
const reviewed = await (
|
||||
await database.prepare(
|
||||
"UPDATE user_content_reports SET status=?,reviewed_at=?,reviewed_by=?,resolution_reason=? WHERE id=? AND status='pending'",
|
||||
)
|
||||
).run(
|
||||
decision === 'hide' ? 'resolved' : 'dismissed',
|
||||
now,
|
||||
user.id,
|
||||
reviewReason,
|
||||
id,
|
||||
);
|
||||
if (!reviewed.changes)
|
||||
throw new Error('Another reviewer already handled this report.');
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'community_moderation',
|
||||
action:
|
||||
decision === 'hide'
|
||||
? 'guide.report_hidden'
|
||||
: 'guide.report_dismissed',
|
||||
entityId: String(report.content_id),
|
||||
before: {
|
||||
reportId: id,
|
||||
revisionId: report.article_revision_id,
|
||||
category: report.category,
|
||||
},
|
||||
after: { reason: reviewReason },
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
} catch (cause) {
|
||||
await database.exec('ROLLBACK');
|
||||
return fail(409, {
|
||||
guideError:
|
||||
cause instanceof Error
|
||||
? cause.message
|
||||
: 'The report could not be handled.',
|
||||
});
|
||||
}
|
||||
return { guideSaved: true };
|
||||
},
|
||||
curate: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) return fail(401, { guideError: 'Sign in required.' });
|
||||
const permissions = await getPermissions(user);
|
||||
if (!allowed(permissions, 'guides.curate'))
|
||||
return fail(403, { guideError: 'Guide curation permission required.' });
|
||||
const form = await request.formData();
|
||||
const articleId = String(form.get('articleId') ?? '');
|
||||
const placement = String(form.get('placement') ?? '');
|
||||
const enabled = form.get('enabled') === '1';
|
||||
if (!['pinned', 'featured', 'recommended'].includes(placement))
|
||||
return fail(400, { guideError: 'Choose a valid placement.' });
|
||||
const database = await getDatabase();
|
||||
const guide = await (
|
||||
await database.prepare(
|
||||
"SELECT id,published_revision_id FROM articles WHERE id=? AND kind='guide' AND visibility='public' AND deleted_at IS NULL",
|
||||
)
|
||||
).get(articleId);
|
||||
if (!guide) return fail(404, { guideError: 'Public Guide not found.' });
|
||||
const current = await (
|
||||
await database.prepare('SELECT * FROM guide_curations WHERE article_id=?')
|
||||
).get(articleId);
|
||||
const values = {
|
||||
pinned: current?.pinned_order ?? null,
|
||||
featured: Number(current?.featured ?? 0),
|
||||
recommended: Number(current?.recommended ?? 0),
|
||||
};
|
||||
if (placement === 'pinned') {
|
||||
const next = enabled
|
||||
? await (
|
||||
await database.prepare(
|
||||
'SELECT COALESCE(MAX(pinned_order),-1)+1 AS position FROM guide_curations',
|
||||
)
|
||||
).get()
|
||||
: null;
|
||||
values.pinned = enabled ? Number(next?.position ?? 0) : null;
|
||||
}
|
||||
if (placement === 'featured') values.featured = enabled ? 1 : 0;
|
||||
if (placement === 'recommended') values.recommended = enabled ? 1 : 0;
|
||||
const now = new Date().toISOString();
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO guide_curations(article_id,pinned_order,featured,recommended,curated_revision_id,curated_by,curated_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(article_id) DO UPDATE SET pinned_order=excluded.pinned_order,featured=excluded.featured,recommended=excluded.recommended,curated_revision_id=excluded.curated_revision_id,curated_by=excluded.curated_by,curated_at=excluded.curated_at`,
|
||||
)
|
||||
).run(
|
||||
articleId,
|
||||
values.pinned,
|
||||
values.featured,
|
||||
values.recommended,
|
||||
guide.published_revision_id,
|
||||
user.id,
|
||||
now,
|
||||
);
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'community_moderation',
|
||||
action: `guide.${placement}_${enabled ? 'enabled' : 'disabled'}`,
|
||||
entityId: articleId,
|
||||
before: current ?? null,
|
||||
after: values,
|
||||
});
|
||||
return { guideSaved: true };
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,7 @@
|
||||
<script lang="ts">import PendingForm from '$lib/components/PendingForm.svelte';let{data,form}=$props();</script>
|
||||
<svelte:head><title>Guide Oversight — ISAC</title></svelte:head>
|
||||
<section class="dashboard-section guide-oversight"><div class="section-title"><div><p class="eyebrow">COMMUNITY OVERSIGHT</p><h2>GUIDE REVIEW & CURATION</h2></div><span>{data.requests.length+data.reports.length} PENDING</span></div>{#if form?.guideError}<p class="error">{form.guideError}</p>{/if}
|
||||
{#if data.canModerate}<h3>PUBLICATION REVIEWS</h3><div class="guide-review-list">{#each data.requests as item}<article><header><div><span>{item.scan_status?.toUpperCase()} · {item.risk_level?.toUpperCase()}</span><h4>{item.title}</h4><small>BY {item.author} · {item.requested_visibility.toUpperCase()}</small></div><a href={`/isac/community/guides/${item.id}`}>REVIEW IMMUTABLE REVISION →</a></header><p>{item.deck}</p><details><summary>REVIEW TEXT</summary><p>{item.plain_text}</p></details><div class="review-actions">{#each [['approve','APPROVE'],['reject','REJECT']] as action}<PendingForm action="?/decide"><input type="hidden" name="id" value={item.id}/><input type="hidden" name="decision" value={action[0]}/><label>DECISION REASON<textarea name="reason" required maxlength="500"></textarea></label><button class={action[0]==='reject'?'danger':'primary'}>{action[1]}</button></PendingForm>{/each}</div></article>{:else}<p class="empty-state">No Guide revisions need review.</p>{/each}</div>
|
||||
<h3>REPORTED GUIDES</h3><div class="guide-review-list">{#each data.reports as item}<article><header><div><span>REPORTED · {item.category.toUpperCase()}</span><h4>{item.title}</h4><small>BY {item.author}</small></div></header><p>{item.details||'No additional details supplied.'}</p><div class="review-actions">{#each [['dismiss','DISMISS'],['hide','HIDE GUIDE']] as action}<PendingForm action="?/report"><input type="hidden" name="id" value={item.id}/><input type="hidden" name="decision" value={action[0]}/><label>DECISION REASON<textarea name="reason" required maxlength="500"></textarea></label><button class={action[0]==='hide'?'danger':'secondary'}>{action[1]}</button></PendingForm>{/each}</div></article>{:else}<p class="empty-state">No Guide reports are pending.</p>{/each}</div>{/if}
|
||||
{#if data.canCurate}<h3>PUBLIC GUIDE CURATION</h3><div class="guide-curation-list">{#each data.guides as guide}<article><div><strong>{guide.title}</strong><a href={`/guides/${guide.slug}`}>VIEW →</a></div>{#each [['pinned',guide.pinned_order!==null],['featured',Boolean(guide.featured)],['recommended',Boolean(guide.recommended)]] as placement}<PendingForm action="?/curate"><input type="hidden" name="articleId" value={guide.id}/><input type="hidden" name="placement" value={placement[0]}/><input type="hidden" name="enabled" value={placement[1]?'0':'1'}/><button class:active={placement[1]}>{placement[1]?'REMOVE ':''}{String(placement[0]).toUpperCase()}</button></PendingForm>{/each}</article>{:else}<p class="empty-state">No public Guides are available to curate.</p>{/each}</div>{/if}</section>
|
||||
<style>.guide-oversight{display:grid;gap:12px}.guide-oversight>h3{margin:18px 0 0;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.guide-review-list{display:grid;gap:8px}.guide-review-list>article{display:grid;gap:10px;padding:12px;border:1px solid var(--shd-border);background:var(--shd-surface)}.guide-review-list article>header{display:flex;justify-content:space-between;gap:12px}.guide-review-list h4,.guide-review-list p{margin:0}.guide-review-list h4{font-size:1.3rem}.guide-review-list header span,.guide-review-list header small,.guide-review-list header a{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.guide-review-list header span,.guide-review-list header a{color:var(--shd-orange)}.guide-review-list details>p{max-height:260px;overflow:auto;padding:10px;border:1px solid var(--shd-border);white-space:pre-wrap}.review-actions{display:grid;grid-template-columns:1fr 1fr;gap:8px}.review-actions :global(form){display:grid;gap:6px}.review-actions label{display:grid;gap:4px;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900}.guide-curation-list{display:grid}.guide-curation-list>article{display:grid;grid-template-columns:minmax(0,1fr) repeat(3,auto);align-items:center;gap:5px;padding:7px;border:1px solid var(--shd-border);border-top:0}.guide-curation-list>article:first-child{border-top:1px solid var(--shd-border)}.guide-curation-list>article>div{display:grid}.guide-curation-list a{color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900}.guide-curation-list button.active{color:var(--shd-orange);border-color:var(--shd-orange)}@media(max-width:800px){.review-actions{grid-template-columns:1fr}.guide-curation-list>article{grid-template-columns:1fr}.guide-curation-list :global(button){width:100%}}</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getPermissions } from '$lib/server/auth/authorization';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { parseArticleDocument } from '$lib/articles/document';
|
||||
import { managedImageUrl } from '$lib/server/managed-images';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, params }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) error(401, 'Sign in required');
|
||||
const permissions = await getPermissions(user);
|
||||
if (
|
||||
!permissions.includes('*') &&
|
||||
!permissions.includes('community.guides.moderate')
|
||||
)
|
||||
error(403, 'Guide moderation permission required');
|
||||
const database = await getDatabase();
|
||||
const row = await (
|
||||
await database.prepare(`SELECT request.id AS request_id,request.status,revision.*,COALESCE(profile.display_name,users.username,'Deleted Agent') AS author,
|
||||
scan.reason,scan.risk_level,cover.updated_at AS cover_updated_at FROM article_publication_requests request JOIN article_revisions revision ON revision.id=request.revision_id
|
||||
JOIN articles ON articles.id=request.article_id JOIN users ON users.id=articles.owner_id LEFT JOIN user_profiles profile ON profile.user_id=users.id
|
||||
LEFT JOIN content_moderation_scans scan ON scan.id=request.scan_id LEFT JOIN managed_images cover ON cover.id=revision.cover_image_id WHERE request.id=? AND articles.kind='guide'`)
|
||||
).get(params.requestId);
|
||||
if (!row) error(404, 'Guide review not found');
|
||||
const parsed = parseArticleDocument(String(row.document_json));
|
||||
if (!parsed) error(500, 'Guide revision is invalid');
|
||||
const [buildRows, imageRows] = await Promise.all([
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT embed_key,build_snapshot_json FROM article_revision_build_embeds WHERE revision_id=?',
|
||||
)
|
||||
).all(row.id),
|
||||
parsed.imageIds.length
|
||||
? (
|
||||
await database.prepare(
|
||||
`SELECT id,updated_at FROM managed_images WHERE id IN (${parsed.imageIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).all(...parsed.imageIds)
|
||||
: [],
|
||||
]);
|
||||
return {
|
||||
revision: {
|
||||
...row,
|
||||
document: parsed.document,
|
||||
coverUrl: row.cover_image_id
|
||||
? managedImageUrl(String(row.cover_image_id), row.cover_updated_at)
|
||||
: '',
|
||||
},
|
||||
builds: Object.fromEntries(
|
||||
buildRows.map((item) => [
|
||||
String(item.embed_key),
|
||||
{ ...JSON.parse(String(item.build_snapshot_json)), available: true },
|
||||
]),
|
||||
),
|
||||
images: Object.fromEntries(
|
||||
imageRows.map((item) => [
|
||||
String(item.id),
|
||||
{
|
||||
url: managedImageUrl(String(item.id), item.updated_at),
|
||||
alt: '',
|
||||
caption: '',
|
||||
},
|
||||
]),
|
||||
),
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
<script lang="ts">import ArticleContent from '$lib/components/ArticleContent.svelte';let { data }=$props();</script>
|
||||
<svelte:head><title>Guide Revision Review // ISAC</title></svelte:head>
|
||||
<main class="review-preview"><a href="/isac/community/guides">← GUIDE OVERSIGHT</a><header><span>IMMUTABLE REVISION // {data.revision.status}</span><h1>{data.revision.title}</h1><p>{data.revision.deck}</p><small>BY {data.revision.author} · RISK {String(data.revision.risk_level??'UNKNOWN').toUpperCase()}</small></header>{#if data.revision.coverUrl}<figure><img src={data.revision.coverUrl} alt={data.revision.cover_alt}/><figcaption>{data.revision.cover_caption}</figcaption></figure>{/if}<section class="evidence"><strong>AUTOMATED REVIEW</strong><p>{data.revision.reason??'No automated evidence recorded.'}</p></section><ArticleContent document={data.revision.document.document} builds={data.builds} images={data.images}/></main>
|
||||
<style>.review-preview{max-width:980px;margin:0 auto;padding:40px 24px 90px}.review-preview>a,.review-preview header>span,.review-preview small,.evidence strong{font:700 11px var(--shd-font-display);letter-spacing:.12em;color:var(--shd-orange)}header{padding:55px 0 35px;border-bottom:1px solid var(--shd-border)}h1{font:900 clamp(40px,8vw,78px)/.9 var(--shd-font-display);text-transform:uppercase;max-width:900px;margin:12px 0}header p{font:400 21px var(--shd-font-editorial);color:var(--shd-text-muted)}figure{margin:30px 0}figure img{width:100%;max-height:520px;object-fit:cover}.evidence{margin:30px 0;padding:18px;border:1px solid var(--shd-border);background:var(--shd-surface)}</style>
|
||||
@@ -1,39 +1,76 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
|
||||
import {
|
||||
requireDataView,
|
||||
requirePermission,
|
||||
} from '$lib/server/auth/authorization';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
await requireDataView(await getSessionUser(cookies));
|
||||
const ranges = await (await (await getDatabase()).prepare('SELECT * FROM core_attribute_tier_ranges ORDER BY tier')).all();
|
||||
return { ranges };
|
||||
await requireDataView(await getSessionUser(cookies));
|
||||
const ranges = await (
|
||||
await (
|
||||
await getDatabase()
|
||||
).prepare('SELECT * FROM core_attribute_tier_ranges ORDER BY tier')
|
||||
).all();
|
||||
return { ranges };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
saveCoreRanges: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const form = await request.formData();
|
||||
const ranges = [0, 1, 2].map((tier) => ({
|
||||
tier,
|
||||
min: Number(form.get(`tier${tier}Min`)),
|
||||
max: Number(form.get(`tier${tier}Max`))
|
||||
}));
|
||||
if (ranges.some((range) => !Number.isFinite(range.min) || !Number.isFinite(range.max) || range.min < 0 || range.max < range.min)) {
|
||||
return fail(400, { configurationError: 'Every tier requires a valid nominal minimum and maximum.' });
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const before = await (await database.prepare('SELECT * FROM core_attribute_tier_ranges ORDER BY tier')).all();
|
||||
const now = new Date().toISOString();
|
||||
for (const range of ranges) {
|
||||
await (await database.prepare(`INSERT INTO core_attribute_tier_ranges(tier,nominal_min,nominal_max,reference_level,updated_at)
|
||||
VALUES(?,?,?,50,?) ON CONFLICT(tier) DO UPDATE SET nominal_min=excluded.nominal_min,nominal_max=excluded.nominal_max,updated_at=excluded.updated_at`))
|
||||
.run(range.tier, range.min, range.max, now);
|
||||
}
|
||||
const after = await (await database.prepare('SELECT * FROM core_attribute_tier_ranges ORDER BY tier')).all();
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'configuration.core_ranges_updated', entityId: 'core-attribute-tier-ranges', before, after });
|
||||
return { configurationSaved: true, resultToken: randomUUID() };
|
||||
}
|
||||
saveCoreRanges: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.manage',
|
||||
);
|
||||
const form = await request.formData();
|
||||
const ranges = [0, 1, 2].map((tier) => ({
|
||||
tier,
|
||||
min: Number(form.get(`tier${tier}Min`)),
|
||||
max: Number(form.get(`tier${tier}Max`)),
|
||||
}));
|
||||
if (
|
||||
ranges.some(
|
||||
(range) =>
|
||||
!Number.isFinite(range.min) ||
|
||||
!Number.isFinite(range.max) ||
|
||||
range.min < 0 ||
|
||||
range.max < range.min,
|
||||
)
|
||||
) {
|
||||
return fail(400, {
|
||||
configurationError:
|
||||
'Every tier requires a valid nominal minimum and maximum.',
|
||||
});
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const before = await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM core_attribute_tier_ranges ORDER BY tier',
|
||||
)
|
||||
).all();
|
||||
const now = new Date().toISOString();
|
||||
for (const range of ranges) {
|
||||
await (
|
||||
await database.prepare(`INSERT INTO core_attribute_tier_ranges(tier,nominal_min,nominal_max,reference_level,updated_at)
|
||||
VALUES(?,?,?,50,?) ON CONFLICT(tier) DO UPDATE SET nominal_min=excluded.nominal_min,nominal_max=excluded.nominal_max,updated_at=excluded.updated_at`)
|
||||
).run(range.tier, range.min, range.max, now);
|
||||
}
|
||||
const after = await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM core_attribute_tier_ranges ORDER BY tier',
|
||||
)
|
||||
).all();
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: 'configuration.core_ranges_updated',
|
||||
entityId: 'core-attribute-tier-ranges',
|
||||
before,
|
||||
after,
|
||||
});
|
||||
return { configurationSaved: true, resultToken: randomUUID() };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,70 +3,91 @@ import { randomUUID } from 'node:crypto';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getPermissions } from '$lib/server/auth/authorization';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { CONTRIBUTOR_GUIDELINES_VERSION, getContributorMode } from '$lib/server/contributor-mode';
|
||||
import {
|
||||
CONTRIBUTOR_GUIDELINES_VERSION,
|
||||
getContributorMode,
|
||||
} from '$lib/server/contributor-mode';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const safeReturn = (value: FormDataEntryValue | null) => {
|
||||
const path = String(value ?? '/isac/attributes');
|
||||
return path.startsWith('/isac/') && !path.startsWith('//') ? path : '/isac/attributes';
|
||||
const path = String(value ?? '/isac/attributes');
|
||||
return path.startsWith('/isac/') && !path.startsWith('//')
|
||||
? path
|
||||
: '/isac/attributes';
|
||||
};
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const permissions = await getPermissions(user);
|
||||
const eligible = permissions.includes('*') || permissions.includes('data.propose');
|
||||
if (!eligible) redirect(303, '/isac/attributes');
|
||||
return {
|
||||
contributorMode: await getContributorMode(user.id),
|
||||
returnTo: safeReturn(url.searchParams.get('returnTo'))
|
||||
};
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const permissions = await getPermissions(user);
|
||||
const eligible =
|
||||
permissions.includes('*') || permissions.includes('data.propose');
|
||||
if (!eligible) redirect(303, '/isac/attributes');
|
||||
return {
|
||||
contributorMode: await getContributorMode(user.id),
|
||||
returnTo: safeReturn(url.searchParams.get('returnTo')),
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
enable: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const permissions = await getPermissions(user);
|
||||
if (!permissions.includes('*') && !permissions.includes('data.propose')) return fail(403, { contributorError: 'This account is not eligible to contribute data.' });
|
||||
const form = await request.formData();
|
||||
if (form.get('acceptGuidelines') !== 'on') return fail(400, { contributorError: 'Accept the contributor guidelines to enable contributor mode.' });
|
||||
const returnTo = safeReturn(form.get('returnTo'));
|
||||
const database = await getDatabase();
|
||||
const before = await getContributorMode(user.id);
|
||||
const now = new Date().toISOString();
|
||||
await (await database.prepare(`INSERT INTO contributor_agreements(user_id,guidelines_version,accepted_at,enabled,enabled_at,disabled_at)
|
||||
enable: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const permissions = await getPermissions(user);
|
||||
if (!permissions.includes('*') && !permissions.includes('data.propose'))
|
||||
return fail(403, {
|
||||
contributorError: 'This account is not eligible to contribute data.',
|
||||
});
|
||||
const form = await request.formData();
|
||||
if (form.get('acceptGuidelines') !== 'on')
|
||||
return fail(400, {
|
||||
contributorError:
|
||||
'Accept the contributor guidelines to enable contributor mode.',
|
||||
});
|
||||
const returnTo = safeReturn(form.get('returnTo'));
|
||||
const database = await getDatabase();
|
||||
const before = await getContributorMode(user.id);
|
||||
const now = new Date().toISOString();
|
||||
await (
|
||||
await database.prepare(`INSERT INTO contributor_agreements(user_id,guidelines_version,accepted_at,enabled,enabled_at,disabled_at)
|
||||
VALUES(?,?,?,1,?,NULL)
|
||||
ON CONFLICT(user_id) DO UPDATE SET guidelines_version=excluded.guidelines_version,accepted_at=excluded.accepted_at,
|
||||
enabled=1,enabled_at=excluded.enabled_at,disabled_at=NULL`)).run(user.id, CONTRIBUTOR_GUIDELINES_VERSION, now, now);
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'data_moderation',
|
||||
action: before.accepted ? 'contributor.enabled' : 'contributor.guidelines_accepted',
|
||||
entityId: user.id,
|
||||
before,
|
||||
after: await getContributorMode(user.id)
|
||||
});
|
||||
redirect(303, returnTo);
|
||||
},
|
||||
disable: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const returnTo = safeReturn((await request.formData()).get('returnTo'));
|
||||
const database = await getDatabase();
|
||||
const before = await getContributorMode(user.id);
|
||||
if (before.enabled) {
|
||||
await (await database.prepare('UPDATE contributor_agreements SET enabled=0,disabled_at=? WHERE user_id=?')).run(new Date().toISOString(), user.id);
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'data_moderation',
|
||||
action: 'contributor.disabled',
|
||||
entityId: user.id,
|
||||
before,
|
||||
after: await getContributorMode(user.id)
|
||||
});
|
||||
}
|
||||
redirect(303, returnTo);
|
||||
}
|
||||
enabled=1,enabled_at=excluded.enabled_at,disabled_at=NULL`)
|
||||
).run(user.id, CONTRIBUTOR_GUIDELINES_VERSION, now, now);
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'data_moderation',
|
||||
action: before.accepted
|
||||
? 'contributor.enabled'
|
||||
: 'contributor.guidelines_accepted',
|
||||
entityId: user.id,
|
||||
before,
|
||||
after: await getContributorMode(user.id),
|
||||
});
|
||||
redirect(303, returnTo);
|
||||
},
|
||||
disable: async ({ cookies, request }) => {
|
||||
const user = await getSessionUser(cookies);
|
||||
if (!user) redirect(303, '/sign-in');
|
||||
const returnTo = safeReturn((await request.formData()).get('returnTo'));
|
||||
const database = await getDatabase();
|
||||
const before = await getContributorMode(user.id);
|
||||
if (before.enabled) {
|
||||
await (
|
||||
await database.prepare(
|
||||
'UPDATE contributor_agreements SET enabled=0,disabled_at=? WHERE user_id=?',
|
||||
)
|
||||
).run(new Date().toISOString(), user.id);
|
||||
await recordIsacAudit({
|
||||
actorId: user.id,
|
||||
category: 'data_moderation',
|
||||
action: 'contributor.disabled',
|
||||
entityId: user.id,
|
||||
before,
|
||||
after: await getContributorMode(user.id),
|
||||
});
|
||||
}
|
||||
redirect(303, returnTo);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,281 +1,706 @@
|
||||
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
|
||||
import {
|
||||
requireDataView,
|
||||
requirePermission,
|
||||
} from '$lib/server/auth/authorization';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination';
|
||||
import {
|
||||
getPagination,
|
||||
getRegistrySearch,
|
||||
getRegistrySort,
|
||||
} from '$lib/server/pagination';
|
||||
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
||||
import { gameDataIconUrl } from '$lib/server/game-data-icons';
|
||||
import { applyGameDataRender, ManagedImageValidationError, managedImageUrl, processManagedImage, readGameDataRenderMutation } from '$lib/server/managed-images';
|
||||
import {
|
||||
applyGameDataRender,
|
||||
ManagedImageValidationError,
|
||||
managedImageUrl,
|
||||
processManagedImage,
|
||||
readGameDataRenderMutation,
|
||||
} from '$lib/server/managed-images';
|
||||
|
||||
const slots = ['Holster', 'Gloves', 'Backpack', 'Knee Pads', 'Body Armor', 'Mask'];
|
||||
const slots = [
|
||||
'Holster',
|
||||
'Gloves',
|
||||
'Backpack',
|
||||
'Knee Pads',
|
||||
'Body Armor',
|
||||
'Mask',
|
||||
];
|
||||
const armoredSlots = new Set(['Gloves', 'Knee Pads', 'Body Armor']);
|
||||
const augmentSlotCount = (slot: string) => slot === 'Backpack' || slot === 'Body Armor' ? 4 : 3;
|
||||
const augmentSlotCount = (slot: string) =>
|
||||
slot === 'Backpack' || slot === 'Body Armor' ? 4 : 3;
|
||||
const augmentPercentage = (slot: string, augmentSlot: number) =>
|
||||
slot === 'Backpack' || slot === 'Body Armor'
|
||||
? [0, 0, 40, 70, 100][augmentSlot]
|
||||
: [0, 0, 50, 100][augmentSlot];
|
||||
slot === 'Backpack' || slot === 'Body Armor'
|
||||
? [0, 0, 40, 70, 100][augmentSlot]
|
||||
: [0, 0, 50, 100][augmentSlot];
|
||||
|
||||
async function augmentProfileSnapshot(slot: string) {
|
||||
const database = await getDatabase();
|
||||
const augments = await (await database.prepare('SELECT * FROM gear_tactical_augments WHERE gear_slot=? ORDER BY augment_slot')).all(slot);
|
||||
const costs = await (await database.prepare(`SELECT cost.* FROM gear_tactical_augment_costs cost
|
||||
JOIN gear_tactical_augments augment ON augment.id=cost.augment_id WHERE augment.gear_slot=? ORDER BY augment.augment_slot,cost.gear_tier`)).all(slot);
|
||||
return augments.map((augment) => ({ ...augment, costs: costs.filter((cost) => cost.augment_id === augment.id) }));
|
||||
const database = await getDatabase();
|
||||
const augments = await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM gear_tactical_augments WHERE gear_slot=? ORDER BY augment_slot',
|
||||
)
|
||||
).all(slot);
|
||||
const costs = await (
|
||||
await database.prepare(`SELECT cost.* FROM gear_tactical_augment_costs cost
|
||||
JOIN gear_tactical_augments augment ON augment.id=cost.augment_id WHERE augment.gear_slot=? ORDER BY augment.augment_slot,cost.gear_tier`)
|
||||
).all(slot);
|
||||
return augments.map((augment) => ({
|
||||
...augment,
|
||||
costs: costs.filter((cost) => cost.augment_id === augment.id),
|
||||
}));
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const user = await requireDataView(await getSessionUser(cookies));
|
||||
const database = await getDatabase();
|
||||
const summary = await (await database.prepare(`SELECT COUNT(*) AS total,
|
||||
const user = await requireDataView(await getSessionUser(cookies));
|
||||
const database = await getDatabase();
|
||||
const summary = await (
|
||||
await database.prepare(`SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN active=1 THEN 1 ELSE 0 END) AS active,
|
||||
SUM(CASE WHEN slot IN ('Gloves','Knee Pads','Body Armor') THEN 1 ELSE 0 END) AS armored,
|
||||
SUM(CASE WHEN EXISTS (SELECT 1 FROM gear_talents WHERE gear_id=gear.id) THEN 1 ELSE 0 END) AS talented FROM gear`)).get();
|
||||
const filter = ['active', 'armored', 'talented'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const sort = getRegistrySort(url, {
|
||||
name: { asc: 'name COLLATE NOCASE ASC, id ASC', desc: 'name COLLATE NOCASE DESC, id DESC' },
|
||||
slot: { asc: 'slot COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC', desc: 'slot COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC' },
|
||||
tier: { asc: 'tier ASC, name COLLATE NOCASE ASC, id ASC', desc: 'tier DESC, name COLLATE NOCASE ASC, id ASC' },
|
||||
ranges: { asc: 'base_health_min ASC, COALESCE(base_armor_min, -1) ASC, name COLLATE NOCASE ASC, id ASC', desc: 'base_health_min DESC, COALESCE(base_armor_min, -1) DESC, name COLLATE NOCASE ASC, id ASC', defaultDirection: 'desc' },
|
||||
brands: {
|
||||
asc: "COALESCE((SELECT brand.name FROM gear_brand_sets link JOIN brand_sets brand ON brand.id=link.brand_set_id WHERE link.gear_id=gear.id ORDER BY brand.name COLLATE NOCASE LIMIT 1), '') COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC",
|
||||
desc: "COALESCE((SELECT brand.name FROM gear_brand_sets link JOIN brand_sets brand ON brand.id=link.brand_set_id WHERE link.gear_id=gear.id ORDER BY brand.name COLLATE NOCASE LIMIT 1), '') COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC"
|
||||
},
|
||||
state: { asc: 'active ASC, name COLLATE NOCASE ASC, id ASC', desc: 'active DESC, name COLLATE NOCASE ASC, id ASC', defaultDirection: 'desc' }
|
||||
}, 'name');
|
||||
const conditions = [
|
||||
...(filter === 'active' ? ['active=1'] : filter === 'armored' ? ["slot IN ('Gloves','Knee Pads','Body Armor')"] : filter === 'talented' ? ['EXISTS (SELECT 1 FROM gear_talents WHERE gear_id=gear.id)'] : []),
|
||||
...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
|
||||
];
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const args = search ? [pattern] : [];
|
||||
const filtered = await (await database.prepare(`SELECT COUNT(*) AS total FROM gear ${where}`)).get(...args);
|
||||
const pendingDefinitions = await getPendingEntities(user.id, [
|
||||
{ module: 'brands', action: 'save', kind: 'brand' },
|
||||
{ module: 'talents', action: 'saveTalent', kind: 'talent', fields: ['category'] },
|
||||
{ module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] },
|
||||
{ module: 'gear', action: 'save', kind: 'gear', fields: ['slot', 'tier', 'healthMin', 'healthMax', 'armorMin', 'armorMax', 'brandIds', 'talentIds'] }
|
||||
]);
|
||||
const pendingGear = pendingDefinitions.filter((item) => item.kind === 'gear').filter((item) =>
|
||||
(filter === 'armored' ? armoredSlots.has(String(item.slot)) : filter === 'talented' ? ['Backpack', 'Body Armor'].includes(String(item.slot)) : true)
|
||||
&& (!search || String(item.name).toLocaleLowerCase().includes(search.toLocaleLowerCase()))
|
||||
).sort((left, right) => {
|
||||
const valueFor = (item: any) => sort.key === 'ranges' ? Number(item.healthMin ?? 0)
|
||||
: sort.key === 'brands' ? (Array.isArray(item.brandIds) ? item.brandIds : [item.brandIds]).filter(Boolean).sort().join(':')
|
||||
: sort.key === 'state' ? Number(item.active ?? 1)
|
||||
: item[sort.key] ?? item.name;
|
||||
const leftValue = valueFor(left), rightValue = valueFor(right);
|
||||
const result = typeof leftValue === 'number' && typeof rightValue === 'number'
|
||||
? leftValue - rightValue
|
||||
: String(leftValue).localeCompare(String(rightValue), undefined, { sensitivity: 'base', numeric: true });
|
||||
return (sort.direction === 'asc' ? result : -result) || String(left.name).localeCompare(String(right.name), undefined, { sensitivity: 'base' });
|
||||
});
|
||||
const adjustedPagination = getPagination(url, Number(filtered?.total ?? 0) + pendingGear.length);
|
||||
const pendingGearOnPage = adjustedPagination.page === 1 ? pendingGear.slice(0, adjustedPagination.perPage) : [];
|
||||
const databaseOffset = Math.max(0, adjustedPagination.offset - pendingGear.length);
|
||||
const databaseLimit = Math.max(0, adjustedPagination.perPage - pendingGearOnPage.length);
|
||||
const [gear, brandLinks, talentLinks, brands, talents, augmentRows, augmentCosts, stats, brandIcons, talentIcons, renders] = await Promise.all([
|
||||
databaseLimit ? (await database.prepare(`SELECT * FROM gear ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset) : [],
|
||||
(await database.prepare('SELECT gear_id,brand_set_id FROM gear_brand_sets')).all(),
|
||||
(await database.prepare('SELECT gear_id,talent_id FROM gear_talents')).all(),
|
||||
(await database.prepare('SELECT id,name FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare(`SELECT id,name,category FROM talents WHERE active=1 AND category IN ('Backpack','Vest','Body Armor') ORDER BY name COLLATE NOCASE`)).all(),
|
||||
(await database.prepare('SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot')).all(),
|
||||
(await database.prepare('SELECT * FROM gear_tactical_augment_costs ORDER BY gear_tier')).all(),
|
||||
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all(),
|
||||
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'")).all(),
|
||||
(await database.prepare("SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'")).all(),
|
||||
(await database.prepare("SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='gear_render'")).all()
|
||||
]);
|
||||
const pendingBrands = pendingDefinitions.filter((item) => item.kind === 'brand');
|
||||
const pendingTalents = pendingDefinitions.filter((item) => item.kind === 'talent' && ['Backpack', 'Body Armor'].includes(String(item.category)));
|
||||
const pendingStats = pendingDefinitions.filter((item) => item.kind === 'attribute');
|
||||
const talentOptionWithIcon = (talent: Record<string, any>) => {
|
||||
const talentId = String(talent.id);
|
||||
const icon = talentIcons.find((item) => String(item.entity_id) === talentId);
|
||||
return {
|
||||
...talent,
|
||||
id: talentId,
|
||||
name: String(talent.name),
|
||||
category: String(talent.category),
|
||||
showIcon: true,
|
||||
iconUrl: icon ? gameDataIconUrl('talent', talentId, icon.updated_at) : ''
|
||||
};
|
||||
};
|
||||
return {
|
||||
gear: [...pendingGearOnPage.map((item) => ({
|
||||
...item,
|
||||
tier: Number(item.tier),
|
||||
base_health_min: item.healthMin,
|
||||
base_health_max: item.healthMax,
|
||||
base_armor_min: item.armorMin || null,
|
||||
base_armor_max: item.armorMax || null,
|
||||
brandIds: Array.isArray(item.brandIds) ? item.brandIds : [item.brandIds].filter(Boolean),
|
||||
talentIds: Array.isArray(item.talentIds) ? item.talentIds : [item.talentIds].filter(Boolean)
|
||||
})), ...gear.map((item) => ({
|
||||
...item,
|
||||
brandIds: brandLinks.filter((link) => link.gear_id === item.id).map((link) => String(link.brand_set_id)),
|
||||
talentIds: talentLinks.filter((link) => link.gear_id === item.id).map((link) => String(link.talent_id)),
|
||||
renderUrl: (() => {
|
||||
const render = renders.find((image) => image.source_entity_id === item.id);
|
||||
return render ? managedImageUrl(String(render.id), render.updated_at) : '';
|
||||
})()
|
||||
}))],
|
||||
brands: [...pendingBrands, ...brands.map((brand) => {
|
||||
const icon = brandIcons.find((item) => item.entity_id === brand.id);
|
||||
return { ...brand, iconUrl: icon ? gameDataIconUrl('brand_set', String(brand.id), icon.updated_at) : '' };
|
||||
})],
|
||||
talents: [...pendingTalents, ...talents.map((talent) => ({ ...talent, category: talent.category === 'Vest' ? 'Body Armor' : talent.category }))].map(talentOptionWithIcon),
|
||||
augmentProfiles: slots.map((slot) => ({
|
||||
slot,
|
||||
requiredSlots: augmentSlotCount(slot),
|
||||
augments: augmentRows.filter((augment) => augment.gear_slot === slot).map((augment) => ({
|
||||
...augment,
|
||||
costs: augmentCosts.filter((cost) => cost.augment_id === augment.id)
|
||||
}))
|
||||
})),
|
||||
stats: [...pendingStats, ...stats],
|
||||
summary: { ...summary, total: Number(summary?.total ?? 0) + pendingDefinitions.filter((item) => item.kind === 'gear').length, pending: pendingDefinitions.filter((item) => item.kind === 'gear').length },
|
||||
pagination: adjustedPagination,
|
||||
filter,
|
||||
search,
|
||||
sort
|
||||
};
|
||||
SUM(CASE WHEN EXISTS (SELECT 1 FROM gear_talents WHERE gear_id=gear.id) THEN 1 ELSE 0 END) AS talented FROM gear`)
|
||||
).get();
|
||||
const filter = ['active', 'armored', 'talented'].includes(
|
||||
url.searchParams.get('filter') ?? '',
|
||||
)
|
||||
? String(url.searchParams.get('filter'))
|
||||
: '';
|
||||
const requestedItemId = url.searchParams.get('item')?.trim() ?? '';
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const sort = getRegistrySort(
|
||||
url,
|
||||
{
|
||||
name: {
|
||||
asc: 'name COLLATE NOCASE ASC, id ASC',
|
||||
desc: 'name COLLATE NOCASE DESC, id DESC',
|
||||
},
|
||||
slot: {
|
||||
asc: 'slot COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC',
|
||||
desc: 'slot COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC',
|
||||
},
|
||||
tier: {
|
||||
asc: 'tier ASC, name COLLATE NOCASE ASC, id ASC',
|
||||
desc: 'tier DESC, name COLLATE NOCASE ASC, id ASC',
|
||||
},
|
||||
ranges: {
|
||||
asc: 'base_health_min ASC, COALESCE(base_armor_min, -1) ASC, name COLLATE NOCASE ASC, id ASC',
|
||||
desc: 'base_health_min DESC, COALESCE(base_armor_min, -1) DESC, name COLLATE NOCASE ASC, id ASC',
|
||||
defaultDirection: 'desc',
|
||||
},
|
||||
brands: {
|
||||
asc: "COALESCE((SELECT brand.name FROM gear_brand_sets link JOIN brand_sets brand ON brand.id=link.brand_set_id WHERE link.gear_id=gear.id ORDER BY brand.name COLLATE NOCASE LIMIT 1), '') COLLATE NOCASE ASC, name COLLATE NOCASE ASC, id ASC",
|
||||
desc: "COALESCE((SELECT brand.name FROM gear_brand_sets link JOIN brand_sets brand ON brand.id=link.brand_set_id WHERE link.gear_id=gear.id ORDER BY brand.name COLLATE NOCASE LIMIT 1), '') COLLATE NOCASE DESC, name COLLATE NOCASE ASC, id ASC",
|
||||
},
|
||||
state: {
|
||||
asc: 'active ASC, name COLLATE NOCASE ASC, id ASC',
|
||||
desc: 'active DESC, name COLLATE NOCASE ASC, id ASC',
|
||||
defaultDirection: 'desc',
|
||||
},
|
||||
},
|
||||
'name',
|
||||
);
|
||||
const conditions = [
|
||||
...(requestedItemId
|
||||
? ['id=?']
|
||||
: filter === 'active'
|
||||
? ['active=1']
|
||||
: filter === 'armored'
|
||||
? ["slot IN ('Gloves','Knee Pads','Body Armor')"]
|
||||
: filter === 'talented'
|
||||
? ['EXISTS (SELECT 1 FROM gear_talents WHERE gear_id=gear.id)']
|
||||
: []),
|
||||
...(!requestedItemId && search
|
||||
? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"]
|
||||
: []),
|
||||
];
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const args = [
|
||||
...(requestedItemId ? [requestedItemId] : []),
|
||||
...(!requestedItemId && search ? [pattern] : []),
|
||||
];
|
||||
const filtered = await (
|
||||
await database.prepare(`SELECT COUNT(*) AS total FROM gear ${where}`)
|
||||
).get(...args);
|
||||
const pendingDefinitions = await getPendingEntities(user.id, [
|
||||
{ module: 'brands', action: 'save', kind: 'brand' },
|
||||
{
|
||||
module: 'talents',
|
||||
action: 'saveTalent',
|
||||
kind: 'talent',
|
||||
fields: ['category'],
|
||||
},
|
||||
{
|
||||
module: 'attributes',
|
||||
action: 'save',
|
||||
kind: 'attribute',
|
||||
fields: ['unit'],
|
||||
},
|
||||
{
|
||||
module: 'gear',
|
||||
action: 'save',
|
||||
kind: 'gear',
|
||||
fields: [
|
||||
'slot',
|
||||
'tier',
|
||||
'healthMin',
|
||||
'healthMax',
|
||||
'armorMin',
|
||||
'armorMax',
|
||||
'brandIds',
|
||||
'talentIds',
|
||||
],
|
||||
},
|
||||
]);
|
||||
const pendingGear = pendingDefinitions
|
||||
.filter((item) => item.kind === 'gear')
|
||||
.filter(
|
||||
(item) =>
|
||||
(requestedItemId
|
||||
? item.id === requestedItemId
|
||||
: filter === 'armored'
|
||||
? armoredSlots.has(String(item.slot))
|
||||
: filter === 'talented'
|
||||
? ['Backpack', 'Body Armor'].includes(String(item.slot))
|
||||
: true) &&
|
||||
(requestedItemId ||
|
||||
!search ||
|
||||
String(item.name)
|
||||
.toLocaleLowerCase()
|
||||
.includes(search.toLocaleLowerCase())),
|
||||
)
|
||||
.sort((left, right) => {
|
||||
const valueFor = (item: any) =>
|
||||
sort.key === 'ranges'
|
||||
? Number(item.healthMin ?? 0)
|
||||
: sort.key === 'brands'
|
||||
? (Array.isArray(item.brandIds) ? item.brandIds : [item.brandIds])
|
||||
.filter(Boolean)
|
||||
.sort()
|
||||
.join(':')
|
||||
: sort.key === 'state'
|
||||
? Number(item.active ?? 1)
|
||||
: (item[sort.key] ?? item.name);
|
||||
const leftValue = valueFor(left),
|
||||
rightValue = valueFor(right);
|
||||
const result =
|
||||
typeof leftValue === 'number' && typeof rightValue === 'number'
|
||||
? leftValue - rightValue
|
||||
: String(leftValue).localeCompare(String(rightValue), undefined, {
|
||||
sensitivity: 'base',
|
||||
numeric: true,
|
||||
});
|
||||
return (
|
||||
(sort.direction === 'asc' ? result : -result) ||
|
||||
String(left.name).localeCompare(String(right.name), undefined, {
|
||||
sensitivity: 'base',
|
||||
})
|
||||
);
|
||||
});
|
||||
const adjustedPagination = getPagination(
|
||||
url,
|
||||
Number(filtered?.total ?? 0) + pendingGear.length,
|
||||
);
|
||||
const pendingGearOnPage =
|
||||
adjustedPagination.page === 1
|
||||
? pendingGear.slice(0, adjustedPagination.perPage)
|
||||
: [];
|
||||
const databaseOffset = Math.max(
|
||||
0,
|
||||
adjustedPagination.offset - pendingGear.length,
|
||||
);
|
||||
const databaseLimit = Math.max(
|
||||
0,
|
||||
adjustedPagination.perPage - pendingGearOnPage.length,
|
||||
);
|
||||
const [
|
||||
gear,
|
||||
brandLinks,
|
||||
talentLinks,
|
||||
brands,
|
||||
talents,
|
||||
augmentRows,
|
||||
augmentCosts,
|
||||
stats,
|
||||
brandIcons,
|
||||
talentIcons,
|
||||
renders,
|
||||
] = await Promise.all([
|
||||
databaseLimit
|
||||
? (
|
||||
await database.prepare(
|
||||
`SELECT * FROM gear ${where} ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`,
|
||||
)
|
||||
).all(...args, databaseLimit, databaseOffset)
|
||||
: [],
|
||||
(
|
||||
await database.prepare('SELECT gear_id,brand_set_id FROM gear_brand_sets')
|
||||
).all(),
|
||||
(
|
||||
await database.prepare('SELECT gear_id,talent_id FROM gear_talents')
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT id,name FROM brand_sets WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
`SELECT id,name,category FROM talents WHERE active=1 AND category IN ('Backpack','Vest','Body Armor') ORDER BY name COLLATE NOCASE`,
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM gear_tactical_augments ORDER BY gear_slot,augment_slot',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM gear_tactical_augment_costs ORDER BY gear_tier',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
"SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='brand_set'",
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
"SELECT entity_id,updated_at FROM game_data_icons WHERE entity_type='talent'",
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
"SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='gear_render'",
|
||||
)
|
||||
).all(),
|
||||
]);
|
||||
const pendingBrands = pendingDefinitions.filter(
|
||||
(item) => item.kind === 'brand',
|
||||
);
|
||||
const pendingTalents = pendingDefinitions.filter(
|
||||
(item) =>
|
||||
item.kind === 'talent' &&
|
||||
['Backpack', 'Body Armor'].includes(String(item.category)),
|
||||
);
|
||||
const pendingStats = pendingDefinitions.filter(
|
||||
(item) => item.kind === 'attribute',
|
||||
);
|
||||
const talentOptionWithIcon = (talent: Record<string, any>) => {
|
||||
const talentId = String(talent.id);
|
||||
const icon = talentIcons.find(
|
||||
(item) => String(item.entity_id) === talentId,
|
||||
);
|
||||
return {
|
||||
...talent,
|
||||
id: talentId,
|
||||
name: String(talent.name),
|
||||
category: String(talent.category),
|
||||
showIcon: true,
|
||||
iconUrl: icon ? gameDataIconUrl('talent', talentId, icon.updated_at) : '',
|
||||
};
|
||||
};
|
||||
return {
|
||||
gear: [
|
||||
...pendingGearOnPage.map((item) => ({
|
||||
...item,
|
||||
tier: Number(item.tier),
|
||||
base_health_min: item.healthMin,
|
||||
base_health_max: item.healthMax,
|
||||
base_armor_min: item.armorMin || null,
|
||||
base_armor_max: item.armorMax || null,
|
||||
brandIds: Array.isArray(item.brandIds)
|
||||
? item.brandIds
|
||||
: [item.brandIds].filter(Boolean),
|
||||
talentIds: Array.isArray(item.talentIds)
|
||||
? item.talentIds
|
||||
: [item.talentIds].filter(Boolean),
|
||||
})),
|
||||
...gear.map((item) => ({
|
||||
...item,
|
||||
brandIds: brandLinks
|
||||
.filter((link) => link.gear_id === item.id)
|
||||
.map((link) => String(link.brand_set_id)),
|
||||
talentIds: talentLinks
|
||||
.filter((link) => link.gear_id === item.id)
|
||||
.map((link) => String(link.talent_id)),
|
||||
renderUrl: (() => {
|
||||
const render = renders.find(
|
||||
(image) => image.source_entity_id === item.id,
|
||||
);
|
||||
return render
|
||||
? managedImageUrl(String(render.id), render.updated_at)
|
||||
: '';
|
||||
})(),
|
||||
})),
|
||||
],
|
||||
brands: [
|
||||
...pendingBrands,
|
||||
...brands.map((brand) => {
|
||||
const icon = brandIcons.find((item) => item.entity_id === brand.id);
|
||||
return {
|
||||
...brand,
|
||||
iconUrl: icon
|
||||
? gameDataIconUrl('brand_set', String(brand.id), icon.updated_at)
|
||||
: '',
|
||||
};
|
||||
}),
|
||||
],
|
||||
talents: [
|
||||
...pendingTalents,
|
||||
...talents.map((talent) => ({
|
||||
...talent,
|
||||
category: talent.category === 'Vest' ? 'Body Armor' : talent.category,
|
||||
})),
|
||||
].map(talentOptionWithIcon),
|
||||
augmentProfiles: slots.map((slot) => ({
|
||||
slot,
|
||||
requiredSlots: augmentSlotCount(slot),
|
||||
augments: augmentRows
|
||||
.filter((augment) => augment.gear_slot === slot)
|
||||
.map((augment) => ({
|
||||
...augment,
|
||||
costs: augmentCosts.filter((cost) => cost.augment_id === augment.id),
|
||||
})),
|
||||
})),
|
||||
stats: [...pendingStats, ...stats],
|
||||
summary: {
|
||||
...summary,
|
||||
total:
|
||||
Number(summary?.total ?? 0) +
|
||||
pendingDefinitions.filter((item) => item.kind === 'gear').length,
|
||||
pending: pendingDefinitions.filter((item) => item.kind === 'gear').length,
|
||||
},
|
||||
pagination: adjustedPagination,
|
||||
filter,
|
||||
requestedItemId,
|
||||
search,
|
||||
sort,
|
||||
};
|
||||
};
|
||||
|
||||
const readRange = (form: FormData, prefix: string) => {
|
||||
const min = Number(form.get(`${prefix}Min`));
|
||||
const max = Number(form.get(`${prefix}Max`));
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max) || min < 0 || max < min) throw new Error();
|
||||
return [min, max] as const;
|
||||
const min = Number(form.get(`${prefix}Min`));
|
||||
const max = Number(form.get(`${prefix}Max`));
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max) || min < 0 || max < min)
|
||||
throw new Error();
|
||||
return [min, max] as const;
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
saveAugmentProfile: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const form = await request.formData();
|
||||
const slot = String(form.get('slot') ?? '');
|
||||
if (!slots.includes(slot)) return fail(400, { augmentProfileError: 'Choose a valid gear slot.' });
|
||||
const requiredSlots = augmentSlotCount(slot);
|
||||
const augments = Array.from({ length: requiredSlots }, (_, index) => ({
|
||||
slot: index + 1,
|
||||
effectType: index === 0 ? 'Custom' : 'Base Percentage',
|
||||
statId: index === 0 ? String(form.get('statId1') ?? '') : '',
|
||||
rawValue: String(form.get(`value${index + 1}`) ?? '').trim(),
|
||||
value: index === 0 ? Number(form.get('value1')) : augmentPercentage(slot, index + 1),
|
||||
costs: [0, 1, 2].map((tier) => ({
|
||||
tier,
|
||||
raw: String(form.get(`unlockCost${index + 1}Tier${tier}`) ?? '').trim(),
|
||||
value: Number(form.get(`unlockCost${index + 1}Tier${tier}`))
|
||||
}))
|
||||
}));
|
||||
if (!augments[0].statId || !augments[0].rawValue || !Number.isFinite(augments[0].value)) {
|
||||
return fail(400, { augmentProfileError: 'Tactical Augment 01 requires one custom attribute and value.' });
|
||||
}
|
||||
if (augments.some((augment) => augment.costs.some((cost) => !cost.raw || !Number.isInteger(cost.value) || cost.value < 0))) {
|
||||
return fail(400, { augmentProfileError: 'Every tactical augment slot requires non-negative whole-number unlock costs for Tiers 0, 1, and 2.' });
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const statIds = [augments[0].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, { augmentProfileError: 'One or more selected attributes are unavailable.' });
|
||||
const before = await augmentProfileSnapshot(slot);
|
||||
const now = new Date().toISOString();
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
await (await database.prepare('DELETE FROM gear_tactical_augments WHERE gear_slot=?')).run(slot);
|
||||
for (const augment of augments) {
|
||||
const augmentId = randomUUID();
|
||||
await (await database.prepare(`INSERT INTO gear_tactical_augments
|
||||
saveAugmentProfile: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.manage',
|
||||
);
|
||||
const form = await request.formData();
|
||||
const slot = String(form.get('slot') ?? '');
|
||||
if (!slots.includes(slot))
|
||||
return fail(400, { augmentProfileError: 'Choose a valid gear slot.' });
|
||||
const requiredSlots = augmentSlotCount(slot);
|
||||
const augments = Array.from({ length: requiredSlots }, (_, index) => ({
|
||||
slot: index + 1,
|
||||
effectType: index === 0 ? 'Custom' : 'Base Percentage',
|
||||
statId: index === 0 ? String(form.get('statId1') ?? '') : '',
|
||||
rawValue: String(form.get(`value${index + 1}`) ?? '').trim(),
|
||||
value:
|
||||
index === 0
|
||||
? Number(form.get('value1'))
|
||||
: augmentPercentage(slot, index + 1),
|
||||
costs: [0, 1, 2].map((tier) => ({
|
||||
tier,
|
||||
raw: String(form.get(`unlockCost${index + 1}Tier${tier}`) ?? '').trim(),
|
||||
value: Number(form.get(`unlockCost${index + 1}Tier${tier}`)),
|
||||
})),
|
||||
}));
|
||||
if (
|
||||
!augments[0].statId ||
|
||||
!augments[0].rawValue ||
|
||||
!Number.isFinite(augments[0].value)
|
||||
) {
|
||||
return fail(400, {
|
||||
augmentProfileError:
|
||||
'Tactical Augment 01 requires one custom attribute and value.',
|
||||
});
|
||||
}
|
||||
if (
|
||||
augments.some((augment) =>
|
||||
augment.costs.some(
|
||||
(cost) =>
|
||||
!cost.raw || !Number.isInteger(cost.value) || cost.value < 0,
|
||||
),
|
||||
)
|
||||
) {
|
||||
return fail(400, {
|
||||
augmentProfileError:
|
||||
'Every tactical augment slot requires non-negative whole-number unlock costs for Tiers 0, 1, and 2.',
|
||||
});
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const statIds = [augments[0].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, {
|
||||
augmentProfileError: 'One or more selected attributes are unavailable.',
|
||||
});
|
||||
const before = await augmentProfileSnapshot(slot);
|
||||
const now = new Date().toISOString();
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM gear_tactical_augments WHERE gear_slot=?',
|
||||
)
|
||||
).run(slot);
|
||||
for (const augment of augments) {
|
||||
const augmentId = randomUUID();
|
||||
await (
|
||||
await database.prepare(`INSERT INTO gear_tactical_augments
|
||||
(id,gear_slot,augment_slot,effect_type,stat_attribute_id,value,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`)).run(augmentId, slot, augment.slot, augment.effectType, augment.statId || null, augment.value, now, now);
|
||||
for (const cost of augment.costs) {
|
||||
await (await database.prepare('INSERT INTO gear_tactical_augment_costs(augment_id,gear_tier,unlock_cost) VALUES(?,?,?)')).run(augmentId, cost.tier, cost.value);
|
||||
}
|
||||
}
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: before.length ? 'gear_augment_profile.updated' : 'gear_augment_profile.created',
|
||||
entityId: slot,
|
||||
before,
|
||||
after: await augmentProfileSnapshot(slot)
|
||||
});
|
||||
return { augmentProfileSaved: true, resultToken: randomUUID() };
|
||||
},
|
||||
save: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const renderMutation = await readGameDataRenderMutation(form);
|
||||
try {
|
||||
if (renderMutation.file) renderMutation.processed = await processManagedImage(renderMutation.file, false, false, true);
|
||||
} catch (error) {
|
||||
if (error instanceof ManagedImageValidationError) return fail(400, { gearError: error.message });
|
||||
throw error;
|
||||
}
|
||||
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
||||
const slot = String(form.get('slot') ?? '');
|
||||
const tier = Number(form.get('tier'));
|
||||
const brandIds = [...new Set(form.getAll('brandIds').map(String))];
|
||||
const talentIds = [...new Set(form.getAll('talentIds').map(String))];
|
||||
if (name.length < 2 || name.length > 80) return fail(400, { gearError: 'Gear names must be 2–80 characters.' });
|
||||
if (!slots.includes(slot) || !Number.isInteger(tier) || tier < 0 || tier > 2) {
|
||||
return fail(400, { gearError: 'Choose a valid slot and tier.' });
|
||||
}
|
||||
if (brandIds.length < 2) return fail(400, { gearError: 'Gear must be available from at least two brand sets.' });
|
||||
const talentCategory = slot === 'Backpack' ? 'Backpack' : slot === 'Body Armor' ? 'Vest' : null;
|
||||
if (talentCategory && talentIds.length < 1) return fail(400, { gearError: `${slot} gear requires at least one ${slot.toLowerCase()} talent.` });
|
||||
if (!talentCategory && talentIds.length) return fail(400, { gearError: `${slot} gear cannot have talents.` });
|
||||
let health: readonly [number, number], armor: readonly [number, number] | [null, null];
|
||||
try {
|
||||
health = readRange(form, 'health');
|
||||
armor = armoredSlots.has(slot) ? readRange(form, 'armor') : [null, null];
|
||||
} catch {
|
||||
return fail(400, { gearError: 'Base ranges require non-negative minimums and maximums greater than or equal to them.' });
|
||||
}
|
||||
const database = await getDatabase();
|
||||
if (await (await database.prepare('SELECT id FROM gear WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) {
|
||||
return fail(409, { gearError: 'That gear name already exists.' });
|
||||
}
|
||||
const validBrands = await (await database.prepare(`SELECT COUNT(*) AS count FROM brand_sets WHERE active=1 AND id IN (${brandIds.map(() => '?').join(',')})`)).get(...brandIds);
|
||||
if (Number(validBrands?.count) !== brandIds.length) return fail(400, { gearError: 'One or more selected brand sets are unavailable.' });
|
||||
if (talentCategory) {
|
||||
const allowedCategories = talentCategory === 'Vest' ? ['Vest', 'Body Armor'] : ['Backpack'];
|
||||
const validTalents = await (await database.prepare(`SELECT COUNT(*) AS count FROM talents WHERE active=1 AND category IN (${allowedCategories.map(() => '?').join(',')}) AND id IN (${talentIds.map(() => '?').join(',')})`)).get(...allowedCategories, ...talentIds);
|
||||
if (Number(validTalents?.count) !== talentIds.length) return fail(400, { gearError: `Every selected talent must be a ${slot} talent.` });
|
||||
}
|
||||
const gearId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID(), now = new Date().toISOString();
|
||||
const before = id ? { record: await auditRecord('gear', id), brandIds: await (await database.prepare('SELECT brand_set_id FROM gear_brand_sets WHERE gear_id=?')).all(id), talentIds: await (await database.prepare('SELECT talent_id FROM gear_talents WHERE gear_id=?')).all(id), render: await (await database.prepare("SELECT * FROM managed_images WHERE source_entity_type='gear_render' AND source_entity_id=?")).get(id) ?? null } : null;
|
||||
if (id) {
|
||||
await (await database.prepare(`UPDATE gear SET name=?,slot=?,tier=?,base_health_min=?,base_health_max=?,base_armor_min=?,base_armor_max=?,active=1,updated_at=? WHERE id=?`))
|
||||
.run(name, slot, tier, ...health, ...armor, now, id);
|
||||
} else {
|
||||
await (await database.prepare(`INSERT INTO gear(id,name,slot,tier,base_health_min,base_health_max,base_armor_min,base_armor_max,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`))
|
||||
.run(gearId, name, slot, tier, ...health, ...armor, now, now);
|
||||
}
|
||||
await (await database.prepare('DELETE FROM gear_brand_sets WHERE gear_id=?')).run(gearId);
|
||||
for (const brandId of brandIds) await (await database.prepare('INSERT INTO gear_brand_sets(gear_id,brand_set_id) VALUES(?,?)')).run(gearId, brandId);
|
||||
await (await database.prepare('DELETE FROM gear_talents WHERE gear_id=?')).run(gearId);
|
||||
for (const talentId of talentIds) await (await database.prepare('INSERT INTO gear_talents(gear_id,talent_id) VALUES(?,?)')).run(gearId, talentId);
|
||||
const render = await applyGameDataRender(database, 'gear', gearId, name, renderMutation, operator.id);
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: id ? 'gear.updated' : 'gear.created', entityId: gearId, before, after: { record: await auditRecord('gear', gearId), brandIds, talentIds, render } });
|
||||
return { gearSaved: true, gearMode: 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 auditRecord('gear', id);
|
||||
await (await (await getDatabase()).prepare('UPDATE gear SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'gear.archived', entityId: id, before, after: await auditRecord('gear', id) });
|
||||
return { gearArchived: true };
|
||||
}
|
||||
VALUES(?,?,?,?,?,?,?,?)`)
|
||||
).run(
|
||||
augmentId,
|
||||
slot,
|
||||
augment.slot,
|
||||
augment.effectType,
|
||||
augment.statId || null,
|
||||
augment.value,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
for (const cost of augment.costs) {
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO gear_tactical_augment_costs(augment_id,gear_tier,unlock_cost) VALUES(?,?,?)',
|
||||
)
|
||||
).run(augmentId, cost.tier, cost.value);
|
||||
}
|
||||
}
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: before.length
|
||||
? 'gear_augment_profile.updated'
|
||||
: 'gear_augment_profile.created',
|
||||
entityId: slot,
|
||||
before,
|
||||
after: await augmentProfileSnapshot(slot),
|
||||
});
|
||||
return { augmentProfileSaved: true, resultToken: randomUUID() };
|
||||
},
|
||||
save: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.manage',
|
||||
);
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const renderMutation = await readGameDataRenderMutation(form);
|
||||
try {
|
||||
if (renderMutation.file)
|
||||
renderMutation.processed = await processManagedImage(
|
||||
renderMutation.file,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ManagedImageValidationError)
|
||||
return fail(400, { gearError: error.message });
|
||||
throw error;
|
||||
}
|
||||
const name = String(form.get('name') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
const slot = String(form.get('slot') ?? '');
|
||||
const tier = Number(form.get('tier'));
|
||||
const brandIds = [...new Set(form.getAll('brandIds').map(String))];
|
||||
const talentIds = [...new Set(form.getAll('talentIds').map(String))];
|
||||
if (name.length < 2 || name.length > 80)
|
||||
return fail(400, { gearError: 'Gear names must be 2–80 characters.' });
|
||||
if (
|
||||
!slots.includes(slot) ||
|
||||
!Number.isInteger(tier) ||
|
||||
tier < 0 ||
|
||||
tier > 2
|
||||
) {
|
||||
return fail(400, { gearError: 'Choose a valid slot and tier.' });
|
||||
}
|
||||
if (brandIds.length < 2)
|
||||
return fail(400, {
|
||||
gearError: 'Gear must be available from at least two brand sets.',
|
||||
});
|
||||
const talentCategory =
|
||||
slot === 'Backpack' ? 'Backpack' : slot === 'Body Armor' ? 'Vest' : null;
|
||||
if (talentCategory && talentIds.length < 1)
|
||||
return fail(400, {
|
||||
gearError: `${slot} gear requires at least one ${slot.toLowerCase()} talent.`,
|
||||
});
|
||||
if (!talentCategory && talentIds.length)
|
||||
return fail(400, { gearError: `${slot} gear cannot have talents.` });
|
||||
let health: readonly [number, number],
|
||||
armor: readonly [number, number] | [null, null];
|
||||
try {
|
||||
health = readRange(form, 'health');
|
||||
armor = armoredSlots.has(slot) ? readRange(form, 'armor') : [null, null];
|
||||
} catch {
|
||||
return fail(400, {
|
||||
gearError:
|
||||
'Base ranges require non-negative minimums and maximums greater than or equal to them.',
|
||||
});
|
||||
}
|
||||
const database = await getDatabase();
|
||||
if (
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT id FROM gear WHERE name=? COLLATE NOCASE AND id<>?',
|
||||
)
|
||||
).get(name, id || 'new')
|
||||
) {
|
||||
return fail(409, { gearError: 'That gear name already exists.' });
|
||||
}
|
||||
const validBrands = await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM brand_sets WHERE active=1 AND id IN (${brandIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).get(...brandIds);
|
||||
if (Number(validBrands?.count) !== brandIds.length)
|
||||
return fail(400, {
|
||||
gearError: 'One or more selected brand sets are unavailable.',
|
||||
});
|
||||
if (talentCategory) {
|
||||
const allowedCategories =
|
||||
talentCategory === 'Vest' ? ['Vest', 'Body Armor'] : ['Backpack'];
|
||||
const validTalents = await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM talents WHERE active=1 AND category IN (${allowedCategories.map(() => '?').join(',')}) AND id IN (${talentIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).get(...allowedCategories, ...talentIds);
|
||||
if (Number(validTalents?.count) !== talentIds.length)
|
||||
return fail(400, {
|
||||
gearError: `Every selected talent must be a ${slot} talent.`,
|
||||
});
|
||||
}
|
||||
const gearId =
|
||||
id || String(form.get('_proposedEntityId') ?? '') || randomUUID(),
|
||||
now = new Date().toISOString();
|
||||
const before = id
|
||||
? {
|
||||
record: await auditRecord('gear', id),
|
||||
brandIds: await (
|
||||
await database.prepare(
|
||||
'SELECT brand_set_id FROM gear_brand_sets WHERE gear_id=?',
|
||||
)
|
||||
).all(id),
|
||||
talentIds: await (
|
||||
await database.prepare(
|
||||
'SELECT talent_id FROM gear_talents WHERE gear_id=?',
|
||||
)
|
||||
).all(id),
|
||||
render:
|
||||
(await (
|
||||
await database.prepare(
|
||||
"SELECT * FROM managed_images WHERE source_entity_type='gear_render' AND source_entity_id=?",
|
||||
)
|
||||
).get(id)) ?? null,
|
||||
}
|
||||
: null;
|
||||
if (id) {
|
||||
await (
|
||||
await database.prepare(
|
||||
`UPDATE gear SET name=?,slot=?,tier=?,base_health_min=?,base_health_max=?,base_armor_min=?,base_armor_max=?,active=1,updated_at=? WHERE id=?`,
|
||||
)
|
||||
).run(name, slot, tier, ...health, ...armor, now, id);
|
||||
} else {
|
||||
await (
|
||||
await database.prepare(
|
||||
`INSERT INTO gear(id,name,slot,tier,base_health_min,base_health_max,base_armor_min,base_armor_max,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)`,
|
||||
)
|
||||
).run(gearId, name, slot, tier, ...health, ...armor, now, now);
|
||||
}
|
||||
await (
|
||||
await database.prepare('DELETE FROM gear_brand_sets WHERE gear_id=?')
|
||||
).run(gearId);
|
||||
for (const brandId of brandIds)
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO gear_brand_sets(gear_id,brand_set_id) VALUES(?,?)',
|
||||
)
|
||||
).run(gearId, brandId);
|
||||
await (
|
||||
await database.prepare('DELETE FROM gear_talents WHERE gear_id=?')
|
||||
).run(gearId);
|
||||
for (const talentId of talentIds)
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO gear_talents(gear_id,talent_id) VALUES(?,?)',
|
||||
)
|
||||
).run(gearId, talentId);
|
||||
const render = await applyGameDataRender(
|
||||
database,
|
||||
'gear',
|
||||
gearId,
|
||||
name,
|
||||
renderMutation,
|
||||
operator.id,
|
||||
);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: id ? 'gear.updated' : 'gear.created',
|
||||
entityId: gearId,
|
||||
before,
|
||||
after: {
|
||||
record: await auditRecord('gear', gearId),
|
||||
brandIds,
|
||||
talentIds,
|
||||
render,
|
||||
},
|
||||
});
|
||||
return {
|
||||
gearSaved: true,
|
||||
gearMode: 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 auditRecord('gear', id);
|
||||
await (
|
||||
await (
|
||||
await getDatabase()
|
||||
).prepare('UPDATE gear SET active=0,updated_at=? WHERE id=?')
|
||||
).run(new Date().toISOString(), id);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: 'gear.archived',
|
||||
entityId: id,
|
||||
before,
|
||||
after: await auditRecord('gear', id),
|
||||
});
|
||||
return { gearArchived: true };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
let augmentEditing = $state<any | null>(null);
|
||||
let handledResultToken = $state('');
|
||||
let renderResetToken = $state(0);
|
||||
let openedRequestedItem = $state(false);
|
||||
const compatibleBrands = (ids: string[]) => ids.map((id) => data.brands.find((brand) => brand.id === id)).filter(Boolean);
|
||||
const stat = (id: string) => data.stats.find((item) => item.id === id);
|
||||
const format = (value: number | string, statId: string) => {
|
||||
@@ -58,6 +59,11 @@
|
||||
editing = null;
|
||||
};
|
||||
$effect(() => {
|
||||
if (!openedRequestedItem && data.requestedItemId) {
|
||||
const requested = data.gear.find((item) => item.id === data.requestedItemId);
|
||||
if (requested) edit(requested);
|
||||
openedRequestedItem = true;
|
||||
}
|
||||
if (form?.augmentProfileSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
||||
handledResultToken = form.resultToken;
|
||||
augmentEditing = null;
|
||||
@@ -95,7 +101,7 @@
|
||||
{/if}
|
||||
{#if data.brands.length < 2}<p class="error">Create at least two active brand sets before adding gear.</p>{/if}
|
||||
{#if form?.gearError}<p class="error">{form.gearError}</p>{/if}
|
||||
<div class="form-actions split-actions"><div>{#if editing.id}<button class="danger" formaction="?/archive" name="id" value={editing.id}>ARCHIVE GEAR</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={data.brands.length < 2 || ((editing.slot === 'Backpack' || editing.slot === 'Body Armor') && gearTalents(editing.slot).length === 0)}>SAVE GEAR →</button></div></div>
|
||||
<div class="form-actions split-actions"><div>{#if editing.id}<button class="moderation-action danger" formaction="?/archive" name="id" value={editing.id}>ARCHIVE GEAR</button>{/if}</div><div><button type="button" class="secondary" onclick={() => editing = null}>CANCEL</button><button class="primary" disabled={data.brands.length < 2 || ((editing.slot === 'Backpack' || editing.slot === 'Body Armor') && gearTalents(editing.slot).length === 0)}>SAVE GEAR →</button></div></div>
|
||||
</PendingForm>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1,198 +1,495 @@
|
||||
import { auditRecord, recordIsacAudit } from '$lib/server/audit';
|
||||
import { requireDataView, requirePermission } from '$lib/server/auth/authorization';
|
||||
import {
|
||||
requireDataView,
|
||||
requirePermission,
|
||||
} from '$lib/server/auth/authorization';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { getPendingEntities } from '$lib/server/data-moderation';
|
||||
import { getPagination, getRegistrySearch, getRegistrySort } from '$lib/server/pagination';
|
||||
import {
|
||||
getPagination,
|
||||
getRegistrySearch,
|
||||
getRegistrySort,
|
||||
} from '$lib/server/pagination';
|
||||
import {
|
||||
applyGameDataRender,
|
||||
ManagedImageValidationError,
|
||||
managedImageUrl,
|
||||
processManagedImage,
|
||||
readGameDataRenderMutation,
|
||||
} from '$lib/server/managed-images';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const qualities = ['Standard', 'Specialized', 'Superior', 'High-end'] as const;
|
||||
type QualityValue = {
|
||||
quality: string;
|
||||
primaryLevel1Min: number;
|
||||
primaryLevel1Max: number;
|
||||
primaryLevel20Min: number;
|
||||
primaryLevel20Max: number;
|
||||
quality: string;
|
||||
primaryLevel1Min: number;
|
||||
primaryLevel1Max: number;
|
||||
primaryLevel20Min: number;
|
||||
primaryLevel20Max: number;
|
||||
};
|
||||
type SecondaryRange = {
|
||||
statId: string;
|
||||
values: Record<string, { min: number; max: number }>;
|
||||
};
|
||||
type SecondaryRange = { statId: string; values: Record<string, { min: number; max: number }> };
|
||||
|
||||
function parseJson<T>(value: FormDataEntryValue | null, fallback: T): T {
|
||||
try { return JSON.parse(String(value ?? '')) as T; } catch { return fallback; }
|
||||
try {
|
||||
return JSON.parse(String(value ?? '')) as T;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
async function snapshot(id: string) {
|
||||
const database = await getDatabase();
|
||||
return {
|
||||
record: await auditRecord('gear_mods', id),
|
||||
qualityValues: await (await database.prepare('SELECT * FROM gear_mod_quality_values WHERE gear_mod_id=? ORDER BY quality')).all(id),
|
||||
secondaryOptions: await (await database.prepare('SELECT stat_attribute_id FROM gear_mod_secondary_options WHERE gear_mod_id=? ORDER BY stat_attribute_id')).all(id),
|
||||
secondaryQualityValues: await (await database.prepare('SELECT * FROM gear_mod_secondary_quality_values WHERE gear_mod_id=? ORDER BY stat_attribute_id,quality')).all(id)
|
||||
};
|
||||
const database = await getDatabase();
|
||||
return {
|
||||
record: await auditRecord('gear_mods', id),
|
||||
qualityValues: await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM gear_mod_quality_values WHERE gear_mod_id=? ORDER BY quality',
|
||||
)
|
||||
).all(id),
|
||||
secondaryOptions: await (
|
||||
await database.prepare(
|
||||
'SELECT stat_attribute_id FROM gear_mod_secondary_options WHERE gear_mod_id=? ORDER BY stat_attribute_id',
|
||||
)
|
||||
).all(id),
|
||||
secondaryQualityValues: await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM gear_mod_secondary_quality_values WHERE gear_mod_id=? ORDER BY stat_attribute_id,quality',
|
||||
)
|
||||
).all(id),
|
||||
render:
|
||||
(await (
|
||||
await database.prepare(
|
||||
"SELECT * FROM managed_images WHERE source_entity_type='gear_mod_render' AND source_entity_id=?",
|
||||
)
|
||||
).get(id)) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const user = await requireDataView(await getSessionUser(cookies));
|
||||
const database = await getDatabase();
|
||||
const requestedFilter = url.searchParams.get('filter') ?? '';
|
||||
const filter = ['active', 'pending', 'archived'].includes(requestedFilter) ? requestedFilter : '';
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const sort = getRegistrySort(url, {
|
||||
name: { asc: 'name COLLATE NOCASE ASC,id ASC', desc: 'name COLLATE NOCASE DESC,id DESC' },
|
||||
secondaries: { asc: '(SELECT COUNT(*) FROM gear_mod_secondary_options option WHERE option.gear_mod_id=gear_mods.id) ASC,name COLLATE NOCASE ASC', desc: '(SELECT COUNT(*) FROM gear_mod_secondary_options option WHERE option.gear_mod_id=gear_mods.id) DESC,name COLLATE NOCASE ASC' },
|
||||
state: { asc: 'active ASC,name COLLATE NOCASE ASC', desc: 'active DESC,name COLLATE NOCASE ASC', defaultDirection: 'desc' }
|
||||
}, 'name');
|
||||
const conditions = [
|
||||
...(filter === 'active' ? ['active=1'] : filter === 'archived' ? ['active=0'] : filter === 'pending' ? ['1=0'] : []),
|
||||
...(search ? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
|
||||
];
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const args = search ? [pattern] : [];
|
||||
const [summary, filtered, pendingStats, pendingMods] = await Promise.all([
|
||||
(await database.prepare(`SELECT COUNT(*) AS total,
|
||||
const user = await requireDataView(await getSessionUser(cookies));
|
||||
const database = await getDatabase();
|
||||
const requestedFilter = url.searchParams.get('filter') ?? '';
|
||||
const filter = ['active', 'pending', 'archived'].includes(requestedFilter)
|
||||
? requestedFilter
|
||||
: '';
|
||||
const requestedItemId = url.searchParams.get('item')?.trim() ?? '';
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const sort = getRegistrySort(
|
||||
url,
|
||||
{
|
||||
name: {
|
||||
asc: 'name COLLATE NOCASE ASC,id ASC',
|
||||
desc: 'name COLLATE NOCASE DESC,id DESC',
|
||||
},
|
||||
secondaries: {
|
||||
asc: '(SELECT COUNT(*) FROM gear_mod_secondary_options option WHERE option.gear_mod_id=gear_mods.id) ASC,name COLLATE NOCASE ASC',
|
||||
desc: '(SELECT COUNT(*) FROM gear_mod_secondary_options option WHERE option.gear_mod_id=gear_mods.id) DESC,name COLLATE NOCASE ASC',
|
||||
},
|
||||
state: {
|
||||
asc: 'active ASC,name COLLATE NOCASE ASC',
|
||||
desc: 'active DESC,name COLLATE NOCASE ASC',
|
||||
defaultDirection: 'desc',
|
||||
},
|
||||
},
|
||||
'name',
|
||||
);
|
||||
const conditions = [
|
||||
...(requestedItemId
|
||||
? ['id=?']
|
||||
: filter === 'active'
|
||||
? ['active=1']
|
||||
: filter === 'archived'
|
||||
? ['active=0']
|
||||
: filter === 'pending'
|
||||
? ['1=0']
|
||||
: []),
|
||||
...(!requestedItemId && search
|
||||
? ["name LIKE ? ESCAPE '\\' COLLATE NOCASE"]
|
||||
: []),
|
||||
];
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const args = [
|
||||
...(requestedItemId ? [requestedItemId] : []),
|
||||
...(!requestedItemId && search ? [pattern] : []),
|
||||
];
|
||||
const [summary, filtered, pendingStats, pendingMods] = 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 active=0 THEN 1 ELSE 0 END) AS archived FROM gear_mods`)).get(),
|
||||
(await database.prepare(`SELECT COUNT(*) AS total FROM gear_mods ${where}`)).get(...args),
|
||||
getPendingEntities(user.id, [{ module: 'attributes', action: 'save', kind: 'attribute', fields: ['unit'] }]),
|
||||
getPendingEntities(user.id, [{ module: 'gearmods', action: 'save', kind: 'gearmod', fields: ['qualityValues', 'secondaryRanges'] }])
|
||||
]);
|
||||
const pendingRows = filter === 'archived' || filter === 'active' ? [] : pendingMods.filter((item) =>
|
||||
!search || item.name.toLocaleLowerCase().includes(search.toLocaleLowerCase())
|
||||
);
|
||||
const pagination = getPagination(url, Number(filtered?.total ?? 0) + pendingRows.length);
|
||||
const pendingOnPage = pagination.page === 1 ? pendingRows.slice(0, pagination.perPage) : [];
|
||||
const databaseOffset = Math.max(0, pagination.offset - pendingRows.length);
|
||||
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
|
||||
const [mods, qualityRows, secondaryOptions, secondaryValues, stats] = await Promise.all([
|
||||
databaseLimit ? (await database.prepare(`SELECT * FROM gear_mods ${where}
|
||||
ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)).all(...args, databaseLimit, databaseOffset) : [],
|
||||
(await database.prepare('SELECT * FROM gear_mod_quality_values')).all(),
|
||||
(await database.prepare('SELECT * FROM gear_mod_secondary_options')).all(),
|
||||
(await database.prepare('SELECT * FROM gear_mod_secondary_quality_values')).all(),
|
||||
(await database.prepare('SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE')).all()
|
||||
]);
|
||||
const parsedPending = pendingOnPage.map((item) => ({
|
||||
...item,
|
||||
qualityValues: parseJson(String(item.qualityValues), []),
|
||||
secondaryRanges: parseJson(String(item.secondaryRanges), [])
|
||||
}));
|
||||
return {
|
||||
mods: [...parsedPending, ...mods.map((mod) => ({
|
||||
...mod,
|
||||
qualityValues: qualityRows.filter((row) => row.gear_mod_id === mod.id).map((row) => ({
|
||||
quality: row.quality,
|
||||
primaryLevel1Min: Number(row.primary_level_1_min),
|
||||
primaryLevel1Max: Number(row.primary_level_1_max),
|
||||
primaryLevel20Min: Number(row.primary_level_20_min),
|
||||
primaryLevel20Max: Number(row.primary_level_20_max)
|
||||
})),
|
||||
secondaryRanges: secondaryOptions.filter((option) => option.gear_mod_id === mod.id).map((option) => ({
|
||||
statId: String(option.stat_attribute_id),
|
||||
values: Object.fromEntries(secondaryValues
|
||||
.filter((row) => row.gear_mod_id === mod.id && row.stat_attribute_id === option.stat_attribute_id)
|
||||
.map((row) => [String(row.quality), { min: Number(row.min_value), max: Number(row.max_value) }]))
|
||||
}))
|
||||
}))],
|
||||
stats: [...pendingStats, ...stats],
|
||||
qualities,
|
||||
summary: {
|
||||
...summary,
|
||||
total: Number(summary?.total ?? 0) + pendingMods.length,
|
||||
pending: pendingMods.length
|
||||
},
|
||||
filter,
|
||||
pagination,
|
||||
sort,
|
||||
search
|
||||
};
|
||||
SUM(CASE WHEN active=0 THEN 1 ELSE 0 END) AS archived FROM gear_mods`)
|
||||
).get(),
|
||||
(
|
||||
await database.prepare(`SELECT COUNT(*) AS total FROM gear_mods ${where}`)
|
||||
).get(...args),
|
||||
getPendingEntities(user.id, [
|
||||
{
|
||||
module: 'attributes',
|
||||
action: 'save',
|
||||
kind: 'attribute',
|
||||
fields: ['unit'],
|
||||
},
|
||||
]),
|
||||
getPendingEntities(user.id, [
|
||||
{
|
||||
module: 'gearmods',
|
||||
action: 'save',
|
||||
kind: 'gearmod',
|
||||
fields: ['qualityValues', 'secondaryRanges'],
|
||||
},
|
||||
]),
|
||||
]);
|
||||
const pendingRows = requestedItemId
|
||||
? pendingMods.filter((item) => item.id === requestedItemId)
|
||||
: filter === 'archived' || filter === 'active'
|
||||
? []
|
||||
: pendingMods.filter(
|
||||
(item) =>
|
||||
!search ||
|
||||
item.name.toLocaleLowerCase().includes(search.toLocaleLowerCase()),
|
||||
);
|
||||
const pagination = getPagination(
|
||||
url,
|
||||
Number(filtered?.total ?? 0) + pendingRows.length,
|
||||
);
|
||||
const pendingOnPage =
|
||||
pagination.page === 1 ? pendingRows.slice(0, pagination.perPage) : [];
|
||||
const databaseOffset = Math.max(0, pagination.offset - pendingRows.length);
|
||||
const databaseLimit = Math.max(0, pagination.perPage - pendingOnPage.length);
|
||||
const [mods, qualityRows, secondaryOptions, secondaryValues, stats, renders] =
|
||||
await Promise.all([
|
||||
databaseLimit
|
||||
? (
|
||||
await database.prepare(`SELECT * FROM gear_mods ${where}
|
||||
ORDER BY ${sort.orderBy} LIMIT ? OFFSET ?`)
|
||||
).all(...args, databaseLimit, databaseOffset)
|
||||
: [],
|
||||
(await database.prepare('SELECT * FROM gear_mod_quality_values')).all(),
|
||||
(
|
||||
await database.prepare('SELECT * FROM gear_mod_secondary_options')
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT * FROM gear_mod_secondary_quality_values',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT id,name,unit FROM stat_attributes WHERE active=1 ORDER BY name COLLATE NOCASE',
|
||||
)
|
||||
).all(),
|
||||
(
|
||||
await database.prepare(
|
||||
"SELECT source_entity_id,id,updated_at FROM managed_images WHERE source_entity_type='gear_mod_render'",
|
||||
)
|
||||
).all(),
|
||||
]);
|
||||
const parsedPending = pendingOnPage.map((item) => ({
|
||||
...item,
|
||||
qualityValues: parseJson(String(item.qualityValues), []),
|
||||
secondaryRanges: parseJson(String(item.secondaryRanges), []),
|
||||
}));
|
||||
return {
|
||||
mods: [
|
||||
...parsedPending,
|
||||
...mods.map((mod) => ({
|
||||
...mod,
|
||||
qualityValues: qualityRows
|
||||
.filter((row) => row.gear_mod_id === mod.id)
|
||||
.map((row) => ({
|
||||
quality: row.quality,
|
||||
primaryLevel1Min: Number(row.primary_level_1_min),
|
||||
primaryLevel1Max: Number(row.primary_level_1_max),
|
||||
primaryLevel20Min: Number(row.primary_level_20_min),
|
||||
primaryLevel20Max: Number(row.primary_level_20_max),
|
||||
})),
|
||||
secondaryRanges: secondaryOptions
|
||||
.filter((option) => option.gear_mod_id === mod.id)
|
||||
.map((option) => ({
|
||||
statId: String(option.stat_attribute_id),
|
||||
values: Object.fromEntries(
|
||||
secondaryValues
|
||||
.filter(
|
||||
(row) =>
|
||||
row.gear_mod_id === mod.id &&
|
||||
row.stat_attribute_id === option.stat_attribute_id,
|
||||
)
|
||||
.map((row) => [
|
||||
String(row.quality),
|
||||
{ min: Number(row.min_value), max: Number(row.max_value) },
|
||||
]),
|
||||
),
|
||||
})),
|
||||
renderUrl: (() => {
|
||||
const render = renders.find(
|
||||
(item) => item.source_entity_id === mod.id,
|
||||
);
|
||||
return render
|
||||
? managedImageUrl(String(render.id), render.updated_at)
|
||||
: '';
|
||||
})(),
|
||||
})),
|
||||
],
|
||||
stats: [...pendingStats, ...stats],
|
||||
qualities,
|
||||
summary: {
|
||||
...summary,
|
||||
total: Number(summary?.total ?? 0) + pendingMods.length,
|
||||
pending: pendingMods.length,
|
||||
},
|
||||
filter,
|
||||
requestedItemId,
|
||||
pagination,
|
||||
sort,
|
||||
search,
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
save: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
||||
const qualityValues = parseJson<QualityValue[]>(form.get('qualityValues'), []);
|
||||
const secondaryRanges = parseJson<SecondaryRange[]>(form.get('secondaryRanges'), []);
|
||||
if (name.length < 2 || name.length > 100) return fail(400, { gearModError: 'Gear Mod names must be 2–100 characters.' });
|
||||
if (qualityValues.length !== qualities.length || new Set(qualityValues.map((row) => row.quality)).size !== qualities.length ||
|
||||
qualityValues.some((row) => !qualities.includes(row.quality as typeof qualities[number]) ||
|
||||
[row.primaryLevel1Min,row.primaryLevel1Max,row.primaryLevel20Min,row.primaryLevel20Max].some((value) => !Number.isFinite(Number(value))) ||
|
||||
Number(row.primaryLevel1Max) < Number(row.primaryLevel1Min) ||
|
||||
Number(row.primaryLevel20Max) < Number(row.primaryLevel20Min) ||
|
||||
Number(row.primaryLevel20Min) < Number(row.primaryLevel1Min) ||
|
||||
Number(row.primaryLevel20Max) < Number(row.primaryLevel1Max))) {
|
||||
return fail(400, { gearModError: 'Configure valid primary Level 1 and Level 20 ranges for all four qualities.' });
|
||||
}
|
||||
if (secondaryRanges.length < 2 || secondaryRanges.length > 50 ||
|
||||
new Set(secondaryRanges.map((range) => range.statId)).size !== secondaryRanges.length ||
|
||||
secondaryRanges.some((range) => !range.statId || qualities.some((quality) => {
|
||||
const values = range.values?.[quality];
|
||||
return !values || !Number.isFinite(Number(values.min)) || !Number.isFinite(Number(values.max)) ||
|
||||
Number(values.max) < Number(values.min);
|
||||
}))) {
|
||||
return fail(400, { gearModError: 'Configure at least two unique possible secondary attributes with valid ranges for every quality.' });
|
||||
}
|
||||
const database = await getDatabase();
|
||||
if (await (await database.prepare('SELECT id FROM gear_mods WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) {
|
||||
return fail(409, { gearModError: 'That Gear Mod name is already registered.' });
|
||||
}
|
||||
const statIds = secondaryRanges.map((range) => range.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, { gearModError: 'One or more selected attributes are unavailable.' });
|
||||
const modId = id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
||||
const before = id ? await snapshot(id) : null;
|
||||
if (id && !before?.record) return fail(404, { gearModError: 'Gear Mod not found.' });
|
||||
const now = new Date().toISOString();
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
if (id) await (await database.prepare('UPDATE gear_mods SET name=?,active=1,updated_at=? WHERE id=?')).run(name, now, id);
|
||||
else await (await database.prepare('INSERT INTO gear_mods(id,name,created_at,updated_at) VALUES(?,?,?,?)')).run(modId, name, now, now);
|
||||
await (await database.prepare('DELETE FROM gear_mod_quality_values WHERE gear_mod_id=?')).run(modId);
|
||||
for (const row of qualityValues) await (await database.prepare(`INSERT INTO gear_mod_quality_values
|
||||
save: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.manage',
|
||||
);
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const renderMutation = await readGameDataRenderMutation(form);
|
||||
try {
|
||||
if (renderMutation.file)
|
||||
renderMutation.processed = await processManagedImage(
|
||||
renderMutation.file,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ManagedImageValidationError)
|
||||
return fail(400, { gearModError: error.message });
|
||||
throw error;
|
||||
}
|
||||
const name = String(form.get('name') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
const qualityValues = parseJson<QualityValue[]>(
|
||||
form.get('qualityValues'),
|
||||
[],
|
||||
);
|
||||
const secondaryRanges = parseJson<SecondaryRange[]>(
|
||||
form.get('secondaryRanges'),
|
||||
[],
|
||||
);
|
||||
if (name.length < 2 || name.length > 100)
|
||||
return fail(400, {
|
||||
gearModError: 'Gear Mod names must be 2–100 characters.',
|
||||
});
|
||||
if (
|
||||
qualityValues.length !== qualities.length ||
|
||||
new Set(qualityValues.map((row) => row.quality)).size !==
|
||||
qualities.length ||
|
||||
qualityValues.some(
|
||||
(row) =>
|
||||
!qualities.includes(row.quality as (typeof qualities)[number]) ||
|
||||
[
|
||||
row.primaryLevel1Min,
|
||||
row.primaryLevel1Max,
|
||||
row.primaryLevel20Min,
|
||||
row.primaryLevel20Max,
|
||||
].some((value) => !Number.isFinite(Number(value))) ||
|
||||
Number(row.primaryLevel1Max) < Number(row.primaryLevel1Min) ||
|
||||
Number(row.primaryLevel20Max) < Number(row.primaryLevel20Min) ||
|
||||
Number(row.primaryLevel20Min) < Number(row.primaryLevel1Min) ||
|
||||
Number(row.primaryLevel20Max) < Number(row.primaryLevel1Max),
|
||||
)
|
||||
) {
|
||||
return fail(400, {
|
||||
gearModError:
|
||||
'Configure valid primary Level 1 and Level 20 ranges for all four qualities.',
|
||||
});
|
||||
}
|
||||
if (
|
||||
secondaryRanges.length < 2 ||
|
||||
secondaryRanges.length > 50 ||
|
||||
new Set(secondaryRanges.map((range) => range.statId)).size !==
|
||||
secondaryRanges.length ||
|
||||
secondaryRanges.some(
|
||||
(range) =>
|
||||
!range.statId ||
|
||||
qualities.some((quality) => {
|
||||
const values = range.values?.[quality];
|
||||
return (
|
||||
!values ||
|
||||
!Number.isFinite(Number(values.min)) ||
|
||||
!Number.isFinite(Number(values.max)) ||
|
||||
Number(values.max) < Number(values.min)
|
||||
);
|
||||
}),
|
||||
)
|
||||
) {
|
||||
return fail(400, {
|
||||
gearModError:
|
||||
'Configure at least two unique possible secondary attributes with valid ranges for every quality.',
|
||||
});
|
||||
}
|
||||
const database = await getDatabase();
|
||||
if (
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT id FROM gear_mods WHERE name=? COLLATE NOCASE AND id<>?',
|
||||
)
|
||||
).get(name, id || 'new')
|
||||
) {
|
||||
return fail(409, {
|
||||
gearModError: 'That Gear Mod name is already registered.',
|
||||
});
|
||||
}
|
||||
const statIds = secondaryRanges.map((range) => range.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, {
|
||||
gearModError: 'One or more selected attributes are unavailable.',
|
||||
});
|
||||
const modId =
|
||||
id || String(form.get('_proposedEntityId') ?? '') || randomUUID();
|
||||
const before = id ? await snapshot(id) : null;
|
||||
if (id && !before?.record)
|
||||
return fail(404, { gearModError: 'Gear Mod not found.' });
|
||||
const now = new Date().toISOString();
|
||||
await database.exec('BEGIN');
|
||||
try {
|
||||
if (id)
|
||||
await (
|
||||
await database.prepare(
|
||||
'UPDATE gear_mods SET name=?,active=1,updated_at=? WHERE id=?',
|
||||
)
|
||||
).run(name, now, id);
|
||||
else
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO gear_mods(id,name,created_at,updated_at) VALUES(?,?,?,?)',
|
||||
)
|
||||
).run(modId, name, now, now);
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM gear_mod_quality_values WHERE gear_mod_id=?',
|
||||
)
|
||||
).run(modId);
|
||||
for (const row of qualityValues)
|
||||
await (
|
||||
await database.prepare(`INSERT INTO gear_mod_quality_values
|
||||
(gear_mod_id,quality,primary_level_1_min,primary_level_1_max,primary_level_20_min,primary_level_20_max)
|
||||
VALUES(?,?,?,?,?,?)`)).run(modId,row.quality,row.primaryLevel1Min,row.primaryLevel1Max,row.primaryLevel20Min,row.primaryLevel20Max);
|
||||
await (await database.prepare('DELETE FROM gear_mod_secondary_options WHERE gear_mod_id=?')).run(modId);
|
||||
for (const range of secondaryRanges) {
|
||||
await (await database.prepare('INSERT INTO gear_mod_secondary_options(gear_mod_id,stat_attribute_id) VALUES(?,?)')).run(modId, range.statId);
|
||||
for (const quality of qualities) await (await database.prepare(`INSERT INTO gear_mod_secondary_quality_values
|
||||
(gear_mod_id,stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?,?)`))
|
||||
.run(modId, range.statId, quality, range.values[quality].min, range.values[quality].max);
|
||||
}
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: id ? 'gear_mod.updated' : 'gear_mod.created',
|
||||
entityId: modId,
|
||||
before,
|
||||
after: await snapshot(modId)
|
||||
});
|
||||
return { gearModSaved: true, gearModMode: 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 snapshot(id);
|
||||
if (!before.record) return fail(404, { gearModError: 'Gear Mod not found.' });
|
||||
await (await (await getDatabase()).prepare('UPDATE gear_mods SET active=0,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'gear_mod.archived', entityId: id, before, after: await snapshot(id) });
|
||||
return { gearModArchived: true };
|
||||
},
|
||||
unarchive: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'data.manage');
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
const before = await snapshot(id);
|
||||
if (!before.record) return fail(404, { gearModError: 'Gear Mod not found.' });
|
||||
await (await (await getDatabase()).prepare('UPDATE gear_mods SET active=1,updated_at=? WHERE id=?')).run(new Date().toISOString(), id);
|
||||
await recordIsacAudit({ actorId: operator.id, category: 'game_data', action: 'gear_mod.unarchived', entityId: id, before, after: await snapshot(id) });
|
||||
return { gearModUnarchived: true };
|
||||
}
|
||||
VALUES(?,?,?,?,?,?)`)
|
||||
).run(
|
||||
modId,
|
||||
row.quality,
|
||||
row.primaryLevel1Min,
|
||||
row.primaryLevel1Max,
|
||||
row.primaryLevel20Min,
|
||||
row.primaryLevel20Max,
|
||||
);
|
||||
await (
|
||||
await database.prepare(
|
||||
'DELETE FROM gear_mod_secondary_options WHERE gear_mod_id=?',
|
||||
)
|
||||
).run(modId);
|
||||
for (const range of secondaryRanges) {
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO gear_mod_secondary_options(gear_mod_id,stat_attribute_id) VALUES(?,?)',
|
||||
)
|
||||
).run(modId, range.statId);
|
||||
for (const quality of qualities)
|
||||
await (
|
||||
await database.prepare(`INSERT INTO gear_mod_secondary_quality_values
|
||||
(gear_mod_id,stat_attribute_id,quality,min_value,max_value) VALUES(?,?,?,?,?)`)
|
||||
).run(
|
||||
modId,
|
||||
range.statId,
|
||||
quality,
|
||||
range.values[quality].min,
|
||||
range.values[quality].max,
|
||||
);
|
||||
}
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
await applyGameDataRender(
|
||||
database,
|
||||
'gear_mod',
|
||||
modId,
|
||||
name,
|
||||
renderMutation,
|
||||
operator.id,
|
||||
);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: id ? 'gear_mod.updated' : 'gear_mod.created',
|
||||
entityId: modId,
|
||||
before,
|
||||
after: await snapshot(modId),
|
||||
});
|
||||
return {
|
||||
gearModSaved: true,
|
||||
gearModMode: 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 snapshot(id);
|
||||
if (!before.record)
|
||||
return fail(404, { gearModError: 'Gear Mod not found.' });
|
||||
await (
|
||||
await (
|
||||
await getDatabase()
|
||||
).prepare('UPDATE gear_mods SET active=0,updated_at=? WHERE id=?')
|
||||
).run(new Date().toISOString(), id);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: 'gear_mod.archived',
|
||||
entityId: id,
|
||||
before,
|
||||
after: await snapshot(id),
|
||||
});
|
||||
return { gearModArchived: true };
|
||||
},
|
||||
unarchive: async ({ request, cookies }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'data.manage',
|
||||
);
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
const before = await snapshot(id);
|
||||
if (!before.record)
|
||||
return fail(404, { gearModError: 'Gear Mod not found.' });
|
||||
await (
|
||||
await (
|
||||
await getDatabase()
|
||||
).prepare('UPDATE gear_mods SET active=1,updated_at=? WHERE id=?')
|
||||
).run(new Date().toISOString(), id);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'game_data',
|
||||
action: 'gear_mod.unarchived',
|
||||
entityId: id,
|
||||
before,
|
||||
after: await snapshot(id),
|
||||
});
|
||||
return { gearModUnarchived: true };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
|
||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
|
||||
|
||||
let { data, form } = $props();
|
||||
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
|
||||
@@ -21,6 +22,7 @@
|
||||
id: '',
|
||||
name: '',
|
||||
active: 1,
|
||||
renderUrl: '',
|
||||
qualityValues: data.qualities.map(qualityRow),
|
||||
secondaryRanges: [blankSecondary(), blankSecondary()]
|
||||
});
|
||||
@@ -28,6 +30,8 @@
|
||||
let levelPreview = $state(1);
|
||||
let qualityPreview = $state('High-end');
|
||||
let handledResultToken = $state('');
|
||||
let renderResetToken = $state(0);
|
||||
let openedRequestedItem = $state(false);
|
||||
const stat = (id: string) => data.stats.find((item: any) => item.id === id);
|
||||
const previewRow = $derived(editing?.qualityValues.find((row: any) => row.quality === qualityPreview));
|
||||
const interpolate = (start: unknown, end: unknown) => Number(start) + (Number(end) - Number(start)) * (levelPreview - 1) / 19;
|
||||
@@ -57,8 +61,14 @@
|
||||
}
|
||||
};
|
||||
$effect(() => {
|
||||
if (!openedRequestedItem && data.requestedItemId) {
|
||||
const requested = data.mods.find((item: any) => item.id === data.requestedItemId);
|
||||
if (requested) edit(requested);
|
||||
openedRequestedItem = true;
|
||||
}
|
||||
if (form?.gearModSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
||||
handledResultToken = form.resultToken;
|
||||
if (form.gearModMode === 'add') renderResetToken += 1;
|
||||
editing = form.gearModMode === 'add' ? blank() : null;
|
||||
levelPreview = 1;
|
||||
qualityPreview = 'High-end';
|
||||
@@ -90,7 +100,7 @@
|
||||
<div><p class="eyebrow">{editing.id ? 'MODIFY GEAR MOD' : 'NEW GEAR MOD'}</p><h3>{editing.id ? editing.name : 'GEAR MOD DEFINITION'}</h3></div>
|
||||
<button aria-label="Close editor" onclick={() => editing = null}>×</button>
|
||||
</div>
|
||||
<PendingForm action="?/save">
|
||||
<PendingForm action="?/save" enctype="multipart/form-data">
|
||||
<input type="hidden" name="id" value={editing.id}/>
|
||||
<input type="hidden" name="qualityValues" value={JSON.stringify(editing.qualityValues)}/>
|
||||
<input type="hidden" name="secondaryRanges" value={JSON.stringify(editing.secondaryRanges)}/>
|
||||
@@ -108,6 +118,7 @@
|
||||
<small>One primary attribute is selected while configuring the mod in a build.</small>
|
||||
</div>
|
||||
</div>
|
||||
<GameDataRenderUploadField currentUrl={editing.renderUrl} label="GEAR MOD RENDER" resetToken={renderResetToken}/>
|
||||
|
||||
<section class="weapon-mod-quality-section">
|
||||
<div class="selection-head">
|
||||
@@ -178,7 +189,7 @@
|
||||
<div class="dashboard-row gear-mod-row table-heading"><SortableColumnHeader label="GEAR MOD" column="name" sort={data.sort}/><span>PRIMARY</span><SortableColumnHeader label="SECONDARIES" column="secondaries" sort={data.sort}/><span>QUALITY</span><span>LEVELS</span><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
|
||||
{#each data.mods as item}
|
||||
<div class="dashboard-row gear-mod-row" class:archived={Number(item.active) !== 1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}>
|
||||
<div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.id.slice(0,8).toUpperCase()}</small></div>
|
||||
<div class="registry-render-identity">{#if item.renderUrl}<img class="registry-render" src={item.renderUrl} alt=""/>{/if}<div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.id.slice(0,8).toUpperCase()}</small></div></div>
|
||||
<span><span class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></span>5 OPTIONS</span>
|
||||
<span>{item.secondaryRanges.length} POSSIBLE<small> · SELECT 2</small></span>
|
||||
<span>STANDARD → HIGH-END</span>
|
||||
|
||||
@@ -5,146 +5,246 @@ import { requirePermission } from '$lib/server/auth/authorization';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import {
|
||||
MANAGED_IMAGE_CATEGORIES,
|
||||
ManagedImageDeletionError,
|
||||
ManagedImageValidationError,
|
||||
deleteManagedImage,
|
||||
managedImageUrl,
|
||||
processManagedImage,
|
||||
saveManagedImageFile
|
||||
MANAGED_IMAGE_CATEGORIES,
|
||||
ManagedImageDeletionError,
|
||||
ManagedImageValidationError,
|
||||
deleteManagedImage,
|
||||
managedImageUrl,
|
||||
processManagedImage,
|
||||
saveManagedImageFile,
|
||||
} from '$lib/server/managed-images';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const normalizeTags = (value: unknown) => [...new Set(String(value ?? '')
|
||||
.split(',').map((tag) => tag.normalize('NFKC').trim().toLocaleLowerCase()).filter(Boolean))]
|
||||
.slice(0, 20);
|
||||
const normalizeTags = (value: unknown) =>
|
||||
[
|
||||
...new Set(
|
||||
String(value ?? '')
|
||||
.split(',')
|
||||
.map((tag) => tag.normalize('NFKC').trim().toLocaleLowerCase())
|
||||
.filter(Boolean),
|
||||
),
|
||||
].slice(0, 20);
|
||||
|
||||
const readMetadata = (form: FormData) => {
|
||||
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
||||
const category = String(form.get('category') ?? '').trim();
|
||||
const tags = normalizeTags(form.get('tags'));
|
||||
if (name.length < 2 || name.length > 80) return { error: 'Name must be 2–80 characters.' } as const;
|
||||
if (!MANAGED_IMAGE_CATEGORIES.includes(category as (typeof MANAGED_IMAGE_CATEGORIES)[number])) return { error: 'Choose a valid assignment category.' } as const;
|
||||
if (tags.some((tag) => tag.length > 30)) return { error: 'Tags must be 30 characters or fewer.' } as const;
|
||||
return { name, category, tags } as const;
|
||||
const name = String(form.get('name') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
const category = String(form.get('category') ?? '').trim();
|
||||
const tags = normalizeTags(form.get('tags'));
|
||||
if (name.length < 2 || name.length > 80)
|
||||
return { error: 'Name must be 2–80 characters.' } as const;
|
||||
if (
|
||||
!MANAGED_IMAGE_CATEGORIES.includes(
|
||||
category as (typeof MANAGED_IMAGE_CATEGORIES)[number],
|
||||
)
|
||||
)
|
||||
return { error: 'Choose a valid assignment category.' } as const;
|
||||
if (tags.some((tag) => tag.length > 30))
|
||||
return { error: 'Tags must be 30 characters or fewer.' } as const;
|
||||
return { name, category, tags } as const;
|
||||
};
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
await requirePermission(await getSessionUser(cookies), 'images.manage');
|
||||
const database = await getDatabase();
|
||||
const query = url.searchParams.get('q')?.trim().slice(0, 80) ?? '';
|
||||
const requestedPerPage = Number(url.searchParams.get('perPage') ?? 25);
|
||||
const perPage = [10, 25, 50, 100].includes(requestedPerPage) ? requestedPerPage : 25;
|
||||
const requestedPage = Math.max(1, Number(url.searchParams.get('page') ?? 1) || 1);
|
||||
const where = query ? `WHERE name LIKE ? ESCAPE '\\' COLLATE NOCASE OR category LIKE ? ESCAPE '\\' COLLATE NOCASE OR tags_json LIKE ? ESCAPE '\\' COLLATE NOCASE` : '';
|
||||
const escaped = `%${query.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||
const args = query ? [escaped, escaped, escaped] : [];
|
||||
const total = Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM managed_images ${where}`)).get(...args))?.count ?? 0);
|
||||
const pageCount = Math.max(1, Math.ceil(total / perPage));
|
||||
const page = Math.min(requestedPage, pageCount);
|
||||
const images = await (await database.prepare(`SELECT managed_images.*, users.username AS creator_username
|
||||
await requirePermission(await getSessionUser(cookies), 'images.manage');
|
||||
const database = await getDatabase();
|
||||
const query = url.searchParams.get('q')?.trim().slice(0, 80) ?? '';
|
||||
const requestedPerPage = Number(url.searchParams.get('perPage') ?? 25);
|
||||
const perPage = [10, 25, 50, 100].includes(requestedPerPage)
|
||||
? requestedPerPage
|
||||
: 25;
|
||||
const requestedPage = Math.max(
|
||||
1,
|
||||
Number(url.searchParams.get('page') ?? 1) || 1,
|
||||
);
|
||||
const where = query
|
||||
? `WHERE name LIKE ? ESCAPE '\\' COLLATE NOCASE OR category LIKE ? ESCAPE '\\' COLLATE NOCASE OR tags_json LIKE ? ESCAPE '\\' COLLATE NOCASE`
|
||||
: '';
|
||||
const escaped = `%${query.replaceAll('\\', '\\\\').replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||
const args = query ? [escaped, escaped, escaped] : [];
|
||||
const total = Number(
|
||||
(
|
||||
await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM managed_images ${where}`,
|
||||
)
|
||||
).get(...args)
|
||||
)?.count ?? 0,
|
||||
);
|
||||
const pageCount = Math.max(1, Math.ceil(total / perPage));
|
||||
const page = Math.min(requestedPage, pageCount);
|
||||
const images = await (
|
||||
await database.prepare(`SELECT managed_images.*, users.username AS creator_username
|
||||
FROM managed_images LEFT JOIN users ON users.id=managed_images.created_by ${where}
|
||||
ORDER BY managed_images.updated_at DESC LIMIT ? OFFSET ?`)).all(...args, perPage, (page - 1) * perPage);
|
||||
const stats = await (await database.prepare(`SELECT COUNT(*) AS total,
|
||||
ORDER BY managed_images.updated_at DESC LIMIT ? OFFSET ?`)
|
||||
).all(...args, perPage, (page - 1) * perPage);
|
||||
const stats = await (
|
||||
await database.prepare(`SELECT COUNT(*) AS total,
|
||||
COALESCE(SUM(is_icon),0) AS icons, COALESCE(SUM(byte_size),0) AS bytes,
|
||||
COUNT(DISTINCT category) AS categories,
|
||||
COALESCE(SUM(CASE WHEN source_entity_type IS NOT NULL THEN 1 ELSE 0 END),0) AS linked
|
||||
FROM managed_images`)).get();
|
||||
return {
|
||||
categories: MANAGED_IMAGE_CATEGORIES,
|
||||
images: images.map((image) => ({
|
||||
...image,
|
||||
tags: JSON.parse(String(image.tags_json)),
|
||||
url: managedImageUrl(String(image.id), image.updated_at)
|
||||
})),
|
||||
stats,
|
||||
search: query,
|
||||
pagination: { page, perPage, total, pageCount }
|
||||
};
|
||||
FROM managed_images`)
|
||||
).get();
|
||||
return {
|
||||
categories: MANAGED_IMAGE_CATEGORIES,
|
||||
images: images.map((image) => ({
|
||||
...image,
|
||||
tags: JSON.parse(String(image.tags_json)),
|
||||
url: managedImageUrl(String(image.id), image.updated_at),
|
||||
})),
|
||||
stats,
|
||||
search: query,
|
||||
pagination: { page, perPage, total, pageCount },
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
upload: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'images.manage');
|
||||
const form = await request.formData();
|
||||
const metadata = readMetadata(form);
|
||||
if ('error' in metadata) return fail(400, { imageError: metadata.error });
|
||||
const file = form.get('image');
|
||||
if (!(file instanceof File) || !file.size) return fail(400, { imageError: 'Choose an image to upload.' });
|
||||
const isIcon = form.get('isIcon') === 'on';
|
||||
const forceWhite = isIcon && form.get('forceWhite') === 'on';
|
||||
try {
|
||||
const processed = await processManagedImage(file, isIcon, forceWhite);
|
||||
const id = randomUUID();
|
||||
const storageKey = await saveManagedImageFile(id, processed.output);
|
||||
const now = new Date().toISOString();
|
||||
const after = {
|
||||
id, ...metadata, is_icon: isIcon ? 1 : 0, force_white: forceWhite ? 1 : 0,
|
||||
storage_key: storageKey, original_filename: file.name, byte_size: processed.output.length,
|
||||
width: processed.width, height: processed.height, created_by: operator.id,
|
||||
created_at: now, updated_at: now
|
||||
};
|
||||
const database = await getDatabase();
|
||||
await (await database.prepare(`INSERT INTO managed_images
|
||||
upload: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'images.manage',
|
||||
);
|
||||
const form = await request.formData();
|
||||
const metadata = readMetadata(form);
|
||||
if ('error' in metadata) return fail(400, { imageError: metadata.error });
|
||||
const file = form.get('image');
|
||||
if (!(file instanceof File) || !file.size)
|
||||
return fail(400, { imageError: 'Choose an image to upload.' });
|
||||
const isIcon = form.get('isIcon') === 'on';
|
||||
const forceWhite = isIcon && form.get('forceWhite') === 'on';
|
||||
try {
|
||||
const processed = await processManagedImage(file, isIcon, forceWhite);
|
||||
const id = randomUUID();
|
||||
const storageKey = await saveManagedImageFile(id, processed.output);
|
||||
const now = new Date().toISOString();
|
||||
const after = {
|
||||
id,
|
||||
...metadata,
|
||||
is_icon: isIcon ? 1 : 0,
|
||||
force_white: forceWhite ? 1 : 0,
|
||||
storage_key: storageKey,
|
||||
original_filename: file.name,
|
||||
byte_size: processed.output.length,
|
||||
width: processed.width,
|
||||
height: processed.height,
|
||||
created_by: operator.id,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
const database = await getDatabase();
|
||||
await (
|
||||
await database.prepare(`INSERT INTO managed_images
|
||||
(id,name,category,tags_json,is_icon,force_white,storage_key,original_filename,byte_size,width,height,created_by,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
|
||||
id, metadata.name, metadata.category, JSON.stringify(metadata.tags), isIcon ? 1 : 0,
|
||||
forceWhite ? 1 : 0, storageKey, file.name, processed.output.length,
|
||||
processed.width, processed.height, operator.id, now, now
|
||||
);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id, category: 'system_configuration',
|
||||
action: 'image.created', entityId: id, before: null, after
|
||||
});
|
||||
return { imageSaved: true, editorMode: 'add', resultToken: randomUUID() };
|
||||
} catch (error) {
|
||||
if (error instanceof ManagedImageValidationError) return fail(400, { imageError: error.message });
|
||||
if (String(error).includes('UNIQUE')) return fail(409, { imageError: 'An image already uses that name.' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
update: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'images.manage');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const metadata = readMetadata(form);
|
||||
if ('error' in metadata) return fail(400, { imageError: metadata.error });
|
||||
const database = await getDatabase();
|
||||
const before = await (await database.prepare('SELECT * FROM managed_images WHERE id=?')).get(id);
|
||||
if (!before) return fail(404, { imageError: 'Image not found.' });
|
||||
const updatedAt = new Date().toISOString();
|
||||
try {
|
||||
await (await database.prepare('UPDATE managed_images SET name=?,category=?,tags_json=?,updated_at=? WHERE id=?'))
|
||||
.run(metadata.name, metadata.category, JSON.stringify(metadata.tags), updatedAt, id);
|
||||
const after = { ...before, name: metadata.name, category: metadata.category, tags_json: JSON.stringify(metadata.tags), updated_at: updatedAt };
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id, category: 'system_configuration',
|
||||
action: 'image.updated', entityId: id, before, after
|
||||
});
|
||||
return { imageSaved: true, editorMode: 'edit', resultToken: randomUUID() };
|
||||
} catch (error) {
|
||||
if (String(error).includes('UNIQUE')) return fail(409, { imageError: 'An image already uses that name.' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
delete: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'images.manage');
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
const database = await getDatabase();
|
||||
let deleted;
|
||||
try {
|
||||
deleted = await deleteManagedImage(database, id);
|
||||
} catch (error) {
|
||||
if (error instanceof ManagedImageDeletionError) return fail(409, { imageError: error.message });
|
||||
throw error;
|
||||
}
|
||||
if (!deleted) return fail(404, { imageError: 'Image not found.' });
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id, category: 'system_configuration',
|
||||
action: 'image.deleted', entityId: id,
|
||||
before: { libraryImage: deleted.image, sourceIcon: deleted.sourceIcon },
|
||||
after: { libraryImage: null, sourceIcon: null }
|
||||
});
|
||||
return { imageDeleted: true, resultToken: randomUUID() };
|
||||
}
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||
).run(
|
||||
id,
|
||||
metadata.name,
|
||||
metadata.category,
|
||||
JSON.stringify(metadata.tags),
|
||||
isIcon ? 1 : 0,
|
||||
forceWhite ? 1 : 0,
|
||||
storageKey,
|
||||
file.name,
|
||||
processed.output.length,
|
||||
processed.width,
|
||||
processed.height,
|
||||
operator.id,
|
||||
now,
|
||||
now,
|
||||
);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'system_configuration',
|
||||
action: 'image.created',
|
||||
entityId: id,
|
||||
before: null,
|
||||
after,
|
||||
});
|
||||
return { imageSaved: true, editorMode: 'add', resultToken: randomUUID() };
|
||||
} catch (error) {
|
||||
if (error instanceof ManagedImageValidationError)
|
||||
return fail(400, { imageError: error.message });
|
||||
if (String(error).includes('UNIQUE'))
|
||||
return fail(409, { imageError: 'An image already uses that name.' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
update: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'images.manage',
|
||||
);
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const metadata = readMetadata(form);
|
||||
if ('error' in metadata) return fail(400, { imageError: metadata.error });
|
||||
const database = await getDatabase();
|
||||
const before = await (
|
||||
await database.prepare('SELECT * FROM managed_images WHERE id=?')
|
||||
).get(id);
|
||||
if (!before) return fail(404, { imageError: 'Image not found.' });
|
||||
const updatedAt = new Date().toISOString();
|
||||
try {
|
||||
await (
|
||||
await database.prepare(
|
||||
'UPDATE managed_images SET name=?,category=?,tags_json=?,updated_at=? WHERE id=?',
|
||||
)
|
||||
).run(
|
||||
metadata.name,
|
||||
metadata.category,
|
||||
JSON.stringify(metadata.tags),
|
||||
updatedAt,
|
||||
id,
|
||||
);
|
||||
const after = {
|
||||
...before,
|
||||
name: metadata.name,
|
||||
category: metadata.category,
|
||||
tags_json: JSON.stringify(metadata.tags),
|
||||
updated_at: updatedAt,
|
||||
};
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'system_configuration',
|
||||
action: 'image.updated',
|
||||
entityId: id,
|
||||
before,
|
||||
after,
|
||||
});
|
||||
return {
|
||||
imageSaved: true,
|
||||
editorMode: 'edit',
|
||||
resultToken: randomUUID(),
|
||||
};
|
||||
} catch (error) {
|
||||
if (String(error).includes('UNIQUE'))
|
||||
return fail(409, { imageError: 'An image already uses that name.' });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
delete: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'images.manage',
|
||||
);
|
||||
const id = String((await request.formData()).get('id') ?? '');
|
||||
const database = await getDatabase();
|
||||
let deleted;
|
||||
try {
|
||||
deleted = await deleteManagedImage(database, id);
|
||||
} catch (error) {
|
||||
if (error instanceof ManagedImageDeletionError)
|
||||
return fail(409, { imageError: error.message });
|
||||
throw error;
|
||||
}
|
||||
if (!deleted) return fail(404, { imageError: 'Image not found.' });
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'system_configuration',
|
||||
action: 'image.deleted',
|
||||
entityId: id,
|
||||
before: { libraryImage: deleted.image, sourceIcon: deleted.sourceIcon },
|
||||
after: { libraryImage: null, sourceIcon: null },
|
||||
});
|
||||
return { imageDeleted: true, resultToken: randomUUID() };
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,21 +4,25 @@ import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
await requireDataView(await getSessionUser(cookies));
|
||||
const database = await getDatabase();
|
||||
const attributes = await (await database.prepare(`
|
||||
await requireDataView(await getSessionUser(cookies));
|
||||
const database = await getDatabase();
|
||||
const attributes = await (
|
||||
await database.prepare(`
|
||||
SELECT id,name,description,unit,type,category,calculation_type,cap_value,
|
||||
gear_min,gear_max,weapon_min,weapon_max,skill_chip_min,skill_chip_max
|
||||
FROM stat_attributes
|
||||
WHERE active=1
|
||||
ORDER BY category COLLATE NOCASE,type COLLATE NOCASE,name COLLATE NOCASE
|
||||
`)).all();
|
||||
`)
|
||||
).all();
|
||||
|
||||
return {
|
||||
attributes,
|
||||
attributeSummary: {
|
||||
total: attributes.length,
|
||||
documented: attributes.filter((attribute) => String(attribute.description ?? '').trim()).length
|
||||
}
|
||||
};
|
||||
return {
|
||||
attributes,
|
||||
attributeSummary: {
|
||||
total: attributes.length,
|
||||
documented: attributes.filter((attribute) =>
|
||||
String(attribute.description ?? '').trim(),
|
||||
).length,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -4,10 +4,23 @@ import { getDatabase } from '$lib/server/database';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import { stat } from 'node:fs/promises';
|
||||
import { basename, resolve } from 'node:path';
|
||||
import { arch, availableParallelism, cpus, freemem, loadavg, platform, release, totalmem, uptime } from 'node:os';
|
||||
import {
|
||||
arch,
|
||||
availableParallelism,
|
||||
cpus,
|
||||
freemem,
|
||||
loadavg,
|
||||
platform,
|
||||
release,
|
||||
totalmem,
|
||||
uptime,
|
||||
} from 'node:os';
|
||||
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getRemoveBgCreditStatus, refreshRemoveBgCredits } from '$lib/server/remove-bg-monitor';
|
||||
import {
|
||||
getRemoveBgCreditStatus,
|
||||
refreshRemoveBgCredits,
|
||||
} from '$lib/server/remove-bg-monitor';
|
||||
|
||||
const loopDelay = monitorEventLoopDelay({ resolution: 20 });
|
||||
loopDelay.enable();
|
||||
@@ -16,79 +29,110 @@ let previousCpuAt = performance.now();
|
||||
let previousElu = performance.eventLoopUtilization();
|
||||
|
||||
const numericPragma = (row: Record<string, unknown> | undefined) =>
|
||||
Number(Object.values(row ?? {}).find((value) => typeof value === 'number') ?? 0);
|
||||
Number(
|
||||
Object.values(row ?? {}).find((value) => typeof value === 'number') ?? 0,
|
||||
);
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
await requirePermission(await getSessionUser(cookies), 'metrics.view');
|
||||
const startedAt = performance.now();
|
||||
const database = await getDatabase();
|
||||
const databasePath = resolve(env.TDR_DATABASE_PATH || './data/shd-build-development.db');
|
||||
const now = performance.now();
|
||||
const cpu = process.cpuUsage(previousCpu);
|
||||
const elapsedMicros = Math.max(1, (now - previousCpuAt) * 1000);
|
||||
previousCpu = process.cpuUsage();
|
||||
previousCpuAt = now;
|
||||
const elu = performance.eventLoopUtilization(previousElu);
|
||||
previousElu = performance.eventLoopUtilization();
|
||||
const memory = process.memoryUsage();
|
||||
await requirePermission(await getSessionUser(cookies), 'metrics.view');
|
||||
const startedAt = performance.now();
|
||||
const database = await getDatabase();
|
||||
const databasePath = resolve(
|
||||
env.TDR_DATABASE_PATH || './data/shd-build-development.db',
|
||||
);
|
||||
const now = performance.now();
|
||||
const cpu = process.cpuUsage(previousCpu);
|
||||
const elapsedMicros = Math.max(1, (now - previousCpuAt) * 1000);
|
||||
previousCpu = process.cpuUsage();
|
||||
previousCpuAt = now;
|
||||
const elu = performance.eventLoopUtilization(previousElu);
|
||||
previousElu = performance.eventLoopUtilization();
|
||||
const memory = process.memoryUsage();
|
||||
|
||||
const [pageCount, pageSize, freePages, tableCount, counts, databaseFile, removeBackground] = await Promise.all([
|
||||
(await database.prepare('PRAGMA page_count')).get(),
|
||||
(await database.prepare('PRAGMA page_size')).get(),
|
||||
(await database.prepare('PRAGMA freelist_count')).get(),
|
||||
(await database.prepare("SELECT COUNT(*) AS count FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")).get(),
|
||||
(await database.prepare(`SELECT
|
||||
const [
|
||||
pageCount,
|
||||
pageSize,
|
||||
freePages,
|
||||
tableCount,
|
||||
counts,
|
||||
databaseFile,
|
||||
removeBackground,
|
||||
] = await Promise.all([
|
||||
(await database.prepare('PRAGMA page_count')).get(),
|
||||
(await database.prepare('PRAGMA page_size')).get(),
|
||||
(await database.prepare('PRAGMA freelist_count')).get(),
|
||||
(
|
||||
await database.prepare(
|
||||
"SELECT COUNT(*) AS count FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'",
|
||||
)
|
||||
).get(),
|
||||
(
|
||||
await database.prepare(`SELECT
|
||||
(SELECT COUNT(*) FROM users) AS users,
|
||||
(SELECT COUNT(*) FROM sessions WHERE expires_at>?) AS active_sessions,
|
||||
(SELECT COUNT(*) FROM builds) AS builds,
|
||||
(SELECT COUNT(*) FROM comments) AS comments,
|
||||
(SELECT COUNT(*) FROM audit_log) AS audit_events,
|
||||
(SELECT COUNT(*) FROM stat_attributes)+(SELECT COUNT(*) FROM talents)+(SELECT COUNT(*) FROM brand_sets)+
|
||||
(SELECT COUNT(*) FROM gear)+(SELECT COUNT(*) FROM weapons)+(SELECT COUNT(*) FROM os_protocols) AS game_records`)).get(new Date().toISOString()),
|
||||
stat(databasePath).catch(() => null),
|
||||
refreshRemoveBgCredits(false).catch(() => getRemoveBgCreditStatus())
|
||||
]);
|
||||
const pages = numericPragma(pageCount);
|
||||
const bytesPerPage = numericPragma(pageSize);
|
||||
const unusedPages = numericPragma(freePages);
|
||||
const hostTotalMemory = totalmem();
|
||||
const hostFreeMemory = freemem();
|
||||
const coreCount = availableParallelism?.() ?? cpus().length;
|
||||
(SELECT COUNT(*) FROM gear)+(SELECT COUNT(*) FROM weapons)+(SELECT COUNT(*) FROM os_protocols) AS game_records`)
|
||||
).get(new Date().toISOString()),
|
||||
stat(databasePath).catch(() => null),
|
||||
refreshRemoveBgCredits(false).catch(() => getRemoveBgCreditStatus()),
|
||||
]);
|
||||
const pages = numericPragma(pageCount);
|
||||
const bytesPerPage = numericPragma(pageSize);
|
||||
const unusedPages = numericPragma(freePages);
|
||||
const hostTotalMemory = totalmem();
|
||||
const hostFreeMemory = freemem();
|
||||
const coreCount = availableParallelism?.() ?? cpus().length;
|
||||
|
||||
return {
|
||||
capturedAt: new Date().toISOString(),
|
||||
collectionMs: Number((performance.now() - startedAt).toFixed(2)),
|
||||
process: {
|
||||
uptimeSeconds: process.uptime(),
|
||||
cpuPercent: Number((((cpu.user + cpu.system) / elapsedMicros) * 100).toFixed(2)),
|
||||
rss: memory.rss,
|
||||
heapUsed: memory.heapUsed,
|
||||
heapTotal: memory.heapTotal,
|
||||
external: memory.external,
|
||||
eventLoopUtilization: Number((elu.utilization * 100).toFixed(2)),
|
||||
eventLoopP95Ms: Number((loopDelay.percentile(95) / 1e6).toFixed(2))
|
||||
},
|
||||
host: {
|
||||
platform: platform(), release: release(), architecture: arch(), cores: coreCount,
|
||||
uptimeSeconds: uptime(), loadAverage: loadavg(), totalMemory: hostTotalMemory,
|
||||
freeMemory: hostFreeMemory, usedMemoryPercent: Number((((hostTotalMemory - hostFreeMemory) / hostTotalMemory) * 100).toFixed(2))
|
||||
},
|
||||
runtime: {
|
||||
node: process.version,
|
||||
environment: process.env.NODE_ENV === 'production' ? 'production' : 'development',
|
||||
pid: process.pid
|
||||
},
|
||||
database: {
|
||||
filename: basename(databasePath),
|
||||
fileSize: databaseFile?.size ?? pages * bytesPerPage,
|
||||
modifiedAt: databaseFile?.mtime.toISOString() ?? null,
|
||||
pageCount: pages,
|
||||
pageSize: bytesPerPage,
|
||||
freePages: unusedPages,
|
||||
freePercent: pages ? Number(((unusedPages / pages) * 100).toFixed(2)) : 0,
|
||||
tableCount: Number(tableCount?.count ?? 0)
|
||||
},
|
||||
counts,
|
||||
services: { removeBackground }
|
||||
};
|
||||
return {
|
||||
capturedAt: new Date().toISOString(),
|
||||
collectionMs: Number((performance.now() - startedAt).toFixed(2)),
|
||||
process: {
|
||||
uptimeSeconds: process.uptime(),
|
||||
cpuPercent: Number(
|
||||
(((cpu.user + cpu.system) / elapsedMicros) * 100).toFixed(2),
|
||||
),
|
||||
rss: memory.rss,
|
||||
heapUsed: memory.heapUsed,
|
||||
heapTotal: memory.heapTotal,
|
||||
external: memory.external,
|
||||
eventLoopUtilization: Number((elu.utilization * 100).toFixed(2)),
|
||||
eventLoopP95Ms: Number((loopDelay.percentile(95) / 1e6).toFixed(2)),
|
||||
},
|
||||
host: {
|
||||
platform: platform(),
|
||||
release: release(),
|
||||
architecture: arch(),
|
||||
cores: coreCount,
|
||||
uptimeSeconds: uptime(),
|
||||
loadAverage: loadavg(),
|
||||
totalMemory: hostTotalMemory,
|
||||
freeMemory: hostFreeMemory,
|
||||
usedMemoryPercent: Number(
|
||||
(((hostTotalMemory - hostFreeMemory) / hostTotalMemory) * 100).toFixed(
|
||||
2,
|
||||
),
|
||||
),
|
||||
},
|
||||
runtime: {
|
||||
node: process.version,
|
||||
environment:
|
||||
process.env.NODE_ENV === 'production' ? 'production' : 'development',
|
||||
pid: process.pid,
|
||||
},
|
||||
database: {
|
||||
filename: basename(databasePath),
|
||||
fileSize: databaseFile?.size ?? pages * bytesPerPage,
|
||||
modifiedAt: databaseFile?.mtime.toISOString() ?? null,
|
||||
pageCount: pages,
|
||||
pageSize: bytesPerPage,
|
||||
freePages: unusedPages,
|
||||
freePercent: pages ? Number(((unusedPages / pages) * 100).toFixed(2)) : 0,
|
||||
tableCount: Number(tableCount?.count ?? 0),
|
||||
},
|
||||
counts,
|
||||
services: { removeBackground },
|
||||
};
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@
|
||||
import GameIcon from '$lib/components/GameIcon.svelte';
|
||||
import type { GameIconName } from '$lib/game-icons';
|
||||
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||||
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
|
||||
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import MechanicProgramEditor from '$lib/components/isac/MechanicProgramEditor.svelte';
|
||||
@@ -31,13 +32,15 @@
|
||||
clauses: [{key:'primary',label:'Primary',event:'tick',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'self',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{base:{value:0}}}]}]
|
||||
});
|
||||
const blankTalent = () => ({ id: '', name: '', description_template: '', conditionalRule: blankRule() });
|
||||
const blank = () => ({ id: '', name: '', iconUrl: '', quality: 'Standard', primary_attribute_type: 'Firepower', active: 1, primaryEffect: { stat_attribute_id: '', base_value: '' }, talent: blankTalent() });
|
||||
const blank = () => ({ id: '', name: '', iconUrl: '', renderUrl: '', quality: 'Standard', primary_attribute_type: 'Firepower', active: 1, primaryEffect: { stat_attribute_id: '', base_value: '' }, talent: blankTalent() });
|
||||
let editing = $state<any | null>(null);
|
||||
let profileEditing = $state<any | null>(null);
|
||||
let talentUpgrade = $state(0);
|
||||
let protocolLevel = $state(1);
|
||||
let handledResultToken = $state('');
|
||||
let iconResetToken = $state(0);
|
||||
let renderResetToken = $state(0);
|
||||
let openedRequestedItem = $state(false);
|
||||
const qualityClass = (quality: string) => `quality-${quality.toLowerCase().replace('-', '')}`;
|
||||
const iconName = (type: string): GameIconName => type.toLowerCase() as GameIconName;
|
||||
const qualityOptions = qualities.map((quality) => ({ value: quality, label: quality, className: qualityClass(quality) }));
|
||||
@@ -93,13 +96,21 @@
|
||||
protocolLevel = 1;
|
||||
};
|
||||
$effect(() => {
|
||||
if (!openedRequestedItem && data.requestedItemId) {
|
||||
const requested = data.protocols.find((item: any) => item.id === data.requestedItemId);
|
||||
if (requested) edit(requested);
|
||||
openedRequestedItem = true;
|
||||
}
|
||||
if (form?.profileSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
||||
handledResultToken = form.resultToken;
|
||||
profileEditing = null;
|
||||
}
|
||||
if (form?.protocolSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
||||
handledResultToken = form.resultToken;
|
||||
if (form.protocolMode === 'add') iconResetToken += 1;
|
||||
if (form.protocolMode === 'add') {
|
||||
iconResetToken += 1;
|
||||
renderResetToken += 1;
|
||||
}
|
||||
editing = form.protocolMode === 'add' ? blank() : null;
|
||||
talentUpgrade = 0;
|
||||
protocolLevel = 1;
|
||||
@@ -153,7 +164,7 @@
|
||||
<input type="hidden" name="id" value={editing.id}/>
|
||||
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)} />
|
||||
<div class="protocol-identity-grid">
|
||||
<div class="icon-editor-identity"><IconUploadField currentUrl={editing.iconUrl} label="OS PROTOCOL ICON" resetToken={iconResetToken}/><label class="field">PROTOCOL NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label></div>
|
||||
<div class="icon-editor-identity"><IconUploadField currentUrl={editing.iconUrl} label="OS PROTOCOL ICON" resetToken={iconResetToken}/><GameDataRenderUploadField currentUrl={editing.renderUrl} label="OS PROTOCOL RENDER" resetToken={renderResetToken}/><label class="field">PROTOCOL NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label></div>
|
||||
<div class="protocol-classification-stack">
|
||||
<SegmentedChoice legend="QUALITY" name="quality" options={qualityOptions} bind:value={editing.quality}/>
|
||||
<SegmentedChoice legend="TYPE" name="attributeType" options={attributeTypeOptions} bind:value={editing.primary_attribute_type}/>
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
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, getRegistrySearch, getRegistrySort } from '$lib/server/pagination';
|
||||
import {
|
||||
getPagination,
|
||||
getRegistrySearch,
|
||||
getRegistrySort,
|
||||
} from '$lib/server/pagination';
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
@@ -9,206 +16,357 @@ import { recordIsacAudit } from '$lib/server/audit';
|
||||
|
||||
const filters = ['isac', 'data', 'users', 'audit', 'system'];
|
||||
const seededRoleIds = new Set([
|
||||
'role-data-editor',
|
||||
'role-user-operator',
|
||||
'role-access-manager',
|
||||
'role-admin',
|
||||
'role-moderator',
|
||||
'role-data-viewer',
|
||||
'role-data-contributor',
|
||||
'role-data-approver',
|
||||
'role-auditor'
|
||||
'role-data-editor',
|
||||
'role-user-operator',
|
||||
'role-access-manager',
|
||||
'role-admin',
|
||||
'role-moderator',
|
||||
'role-data-viewer',
|
||||
'role-data-contributor',
|
||||
'role-data-approver',
|
||||
'role-auditor',
|
||||
]);
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.roles');
|
||||
const operatorPermissions = await getPermissions(operator);
|
||||
const canManageSystemAdmin = operatorPermissions.includes('*');
|
||||
const database = await getDatabase();
|
||||
const filter = filters.includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const sort = getRegistrySort(url, {
|
||||
name: { asc: 'roles.name COLLATE NOCASE ASC', desc: 'roles.name COLLATE NOCASE DESC' },
|
||||
users: { asc: 'user_count ASC', desc: 'user_count DESC', defaultDirection: 'desc' },
|
||||
permissions: {
|
||||
asc: '(SELECT COUNT(*) FROM role_permissions WHERE role_id=roles.id) ASC',
|
||||
desc: '(SELECT COUNT(*) FROM role_permissions WHERE role_id=roles.id) DESC',
|
||||
defaultDirection: 'desc'
|
||||
},
|
||||
state: {
|
||||
asc: "CASE WHEN roles.id='role-admin' THEN 1 ELSE 0 END ASC",
|
||||
desc: "CASE WHEN roles.id='role-admin' THEN 1 ELSE 0 END DESC"
|
||||
}
|
||||
}, 'name');
|
||||
const permissionPrefix = filter === 'isac' ? 'isac.%' : filter === 'data' ? 'data.%' : filter === 'users' ? 'users.%' : filter === 'audit' ? 'audit.%' : filter === 'system' ? 'system.%' : '';
|
||||
const relatedPermissionFilter = filter === 'data'
|
||||
? " OR p.name LIKE 'calculation.%' OR p.name LIKE 'ai.%'"
|
||||
: filter === 'system'
|
||||
? " OR p.name LIKE 'site.%' OR p.name LIKE 'metrics.%' OR p.name LIKE 'images.%'"
|
||||
: '';
|
||||
const conditions = [
|
||||
...(permissionPrefix ? [`EXISTS (
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'users.roles',
|
||||
);
|
||||
const operatorPermissions = await getPermissions(operator);
|
||||
const canManageSystemAdmin = operatorPermissions.includes('*');
|
||||
const database = await getDatabase();
|
||||
const filter = filters.includes(url.searchParams.get('filter') ?? '')
|
||||
? String(url.searchParams.get('filter'))
|
||||
: '';
|
||||
const { search, pattern } = getRegistrySearch(url);
|
||||
const sort = getRegistrySort(
|
||||
url,
|
||||
{
|
||||
name: {
|
||||
asc: 'roles.name COLLATE NOCASE ASC',
|
||||
desc: 'roles.name COLLATE NOCASE DESC',
|
||||
},
|
||||
users: {
|
||||
asc: 'user_count ASC',
|
||||
desc: 'user_count DESC',
|
||||
defaultDirection: 'desc',
|
||||
},
|
||||
permissions: {
|
||||
asc: '(SELECT COUNT(*) FROM role_permissions WHERE role_id=roles.id) ASC',
|
||||
desc: '(SELECT COUNT(*) FROM role_permissions WHERE role_id=roles.id) DESC',
|
||||
defaultDirection: 'desc',
|
||||
},
|
||||
state: {
|
||||
asc: "CASE WHEN roles.id='role-admin' THEN 1 ELSE 0 END ASC",
|
||||
desc: "CASE WHEN roles.id='role-admin' THEN 1 ELSE 0 END DESC",
|
||||
},
|
||||
},
|
||||
'name',
|
||||
);
|
||||
const permissionPrefix =
|
||||
filter === 'isac'
|
||||
? 'isac.%'
|
||||
: filter === 'data'
|
||||
? 'data.%'
|
||||
: filter === 'users'
|
||||
? 'users.%'
|
||||
: filter === 'audit'
|
||||
? 'audit.%'
|
||||
: filter === 'system'
|
||||
? 'system.%'
|
||||
: '';
|
||||
const relatedPermissionFilter =
|
||||
filter === 'data'
|
||||
? " OR p.name LIKE 'calculation.%' OR p.name LIKE 'ai.%'"
|
||||
: filter === 'system'
|
||||
? " OR p.name LIKE 'site.%' OR p.name LIKE 'metrics.%' OR p.name LIKE 'images.%'"
|
||||
: '';
|
||||
const conditions = [
|
||||
...(permissionPrefix
|
||||
? [
|
||||
`EXISTS (
|
||||
SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id
|
||||
WHERE rp.role_id=roles.id AND (p.name LIKE ?${relatedPermissionFilter})
|
||||
)`] : []),
|
||||
...(search ? ["roles.name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : [])
|
||||
];
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const filterArgs = [...(permissionPrefix ? [permissionPrefix] : []), ...(search ? [pattern] : [])];
|
||||
const [summary, filtered, permissions] = await Promise.all([
|
||||
(await database.prepare(`SELECT COUNT(*) AS total,
|
||||
)`,
|
||||
]
|
||||
: []),
|
||||
...(search ? ["roles.name LIKE ? ESCAPE '\\' COLLATE NOCASE"] : []),
|
||||
];
|
||||
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
|
||||
const filterArgs = [
|
||||
...(permissionPrefix ? [permissionPrefix] : []),
|
||||
...(search ? [pattern] : []),
|
||||
];
|
||||
const [summary, filtered, permissions] = await Promise.all([
|
||||
(
|
||||
await database.prepare(`SELECT COUNT(*) AS total,
|
||||
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'isac.%') THEN 1 ELSE 0 END) AS isac,
|
||||
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND (p.name LIKE 'data.%' OR p.name LIKE 'calculation.%' OR p.name LIKE 'ai.%')) THEN 1 ELSE 0 END) AS data,
|
||||
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'users.%') THEN 1 ELSE 0 END) AS users,
|
||||
SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND p.name LIKE 'audit.%') THEN 1 ELSE 0 END) AS audit
|
||||
,SUM(CASE WHEN EXISTS (SELECT 1 FROM role_permissions rp JOIN permissions p ON p.id=rp.permission_id WHERE rp.role_id=roles.id AND (p.name LIKE 'system.%' OR p.name LIKE 'site.%' OR p.name LIKE 'metrics.%' OR p.name LIKE 'images.%')) THEN 1 ELSE 0 END) AS system
|
||||
FROM roles`)).get(),
|
||||
(await database.prepare(`SELECT COUNT(*) AS total FROM roles ${where}`)).get(...filterArgs),
|
||||
(await database.prepare(`SELECT id,name,description FROM permissions
|
||||
${canManageSystemAdmin ? '' : "WHERE name<>'system.admin'"} ORDER BY name COLLATE NOCASE`)).all()
|
||||
]);
|
||||
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
||||
const roles = await (await database.prepare(`SELECT roles.*,
|
||||
FROM roles`)
|
||||
).get(),
|
||||
(
|
||||
await database.prepare(`SELECT COUNT(*) AS total FROM roles ${where}`)
|
||||
).get(...filterArgs),
|
||||
(
|
||||
await database.prepare(`SELECT id,name,description FROM permissions
|
||||
${canManageSystemAdmin ? '' : "WHERE name<>'system.admin'"} ORDER BY name COLLATE NOCASE`)
|
||||
).all(),
|
||||
]);
|
||||
const pagination = getPagination(url, Number(filtered?.total ?? 0));
|
||||
const roles = await (
|
||||
await database.prepare(`SELECT roles.*,
|
||||
(SELECT COUNT(*) FROM user_roles WHERE role_id=roles.id) AS user_count
|
||||
FROM roles ${where} ORDER BY ${sort.orderBy},roles.name COLLATE NOCASE,roles.id
|
||||
LIMIT ? OFFSET ?`)).all(...filterArgs, pagination.perPage, pagination.offset);
|
||||
const roleIds = roles.map((role) => String(role.id));
|
||||
const links = roleIds.length ? await (await database.prepare(`SELECT role_id,permission_id FROM role_permissions
|
||||
WHERE role_id IN (${roleIds.map(() => '?').join(',')})`)).all(...roleIds) : [];
|
||||
return {
|
||||
roles: roles.map((role) => ({
|
||||
...role,
|
||||
permissionIds: links.filter((link) => link.role_id === role.id).map((link) => String(link.permission_id)),
|
||||
systemRole: seededRoleIds.has(String(role.id)),
|
||||
protected: role.id === 'role-admin',
|
||||
canEdit: role.id !== 'role-admin' || canManageSystemAdmin,
|
||||
canDelete: role.id !== 'role-admin'
|
||||
&& (canManageSystemAdmin || !seededRoleIds.has(String(role.id)))
|
||||
})),
|
||||
permissions,
|
||||
summary,
|
||||
filter,
|
||||
search,
|
||||
sort,
|
||||
pagination,
|
||||
canManageSystemAdmin
|
||||
};
|
||||
LIMIT ? OFFSET ?`)
|
||||
).all(...filterArgs, pagination.perPage, pagination.offset);
|
||||
const roleIds = roles.map((role) => String(role.id));
|
||||
const links = roleIds.length
|
||||
? await (
|
||||
await database.prepare(`SELECT role_id,permission_id FROM role_permissions
|
||||
WHERE role_id IN (${roleIds.map(() => '?').join(',')})`)
|
||||
).all(...roleIds)
|
||||
: [];
|
||||
return {
|
||||
roles: roles.map((role) => ({
|
||||
...role,
|
||||
permissionIds: links
|
||||
.filter((link) => link.role_id === role.id)
|
||||
.map((link) => String(link.permission_id)),
|
||||
systemRole: seededRoleIds.has(String(role.id)),
|
||||
protected: role.id === 'role-admin',
|
||||
canEdit: role.id !== 'role-admin' || canManageSystemAdmin,
|
||||
canDelete:
|
||||
role.id !== 'role-admin' &&
|
||||
(canManageSystemAdmin || !seededRoleIds.has(String(role.id))),
|
||||
})),
|
||||
permissions,
|
||||
summary,
|
||||
filter,
|
||||
search,
|
||||
sort,
|
||||
pagination,
|
||||
canManageSystemAdmin,
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
save: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.roles');
|
||||
const operatorPermissions = await getPermissions(operator);
|
||||
const canManageSystemAdmin = operatorPermissions.includes('*');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const name = String(form.get('name') ?? '').normalize('NFKC').trim();
|
||||
const description = String(form.get('description') ?? '').normalize('NFKC').trim();
|
||||
const permissionIds = [...new Set(form.getAll('permissionIds').map(String))];
|
||||
if (name.length < 2 || name.length > 60) return fail(400, { roleError: 'Role names must be 2–60 characters.' });
|
||||
if (description.length > 240) return fail(400, { roleError: 'Descriptions must be 240 characters or fewer.' });
|
||||
const database = await getDatabase();
|
||||
const existing = id ? await (await database.prepare('SELECT id,name,description FROM roles WHERE id=?')).get(id) : null;
|
||||
if (id && !existing) return fail(404, { roleError: 'Role not found.' });
|
||||
if (id === 'role-admin' && !canManageSystemAdmin) {
|
||||
return fail(403, { roleError: 'Only a full administrator can modify the core Admin role.' });
|
||||
}
|
||||
if (await (await database.prepare('SELECT id FROM roles WHERE name=? COLLATE NOCASE AND id<>?')).get(name, id || 'new')) {
|
||||
return fail(409, { roleError: 'A role with that name already exists.' });
|
||||
}
|
||||
const validPermissions = permissionIds.length ? await (await database.prepare(`SELECT id,name FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`)).all(...permissionIds) : [];
|
||||
if (validPermissions.length !== permissionIds.length) return fail(400, { roleError: 'One or more permissions are invalid.' });
|
||||
if (!canManageSystemAdmin && validPermissions.some((permission) => permission.name === 'system.admin')) {
|
||||
return fail(403, { roleError: 'Only a full administrator can grant unrestricted system access.' });
|
||||
}
|
||||
const roleId = id || randomUUID();
|
||||
const beforeLinks = id ? 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(id) : [];
|
||||
if (id) await (await database.prepare('UPDATE roles SET name=?,description=? WHERE id=?')).run(name, description || null, id);
|
||||
else await (await database.prepare('INSERT INTO roles(id,name,description) VALUES(?,?,?)')).run(roleId, name, description || null);
|
||||
await (await database.prepare('DELETE FROM role_permissions WHERE role_id=?')).run(roleId);
|
||||
for (const permissionId of permissionIds) {
|
||||
await (await database.prepare('INSERT INTO role_permissions(role_id,permission_id) VALUES(?,?)')).run(roleId, permissionId);
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id, category: 'role_management', action: id ? 'role.updated' : 'role.created', entityId: roleId,
|
||||
before: id ? { name: existing?.name, description: existing?.description, permissions: beforeLinks.map((row) => row.name) } : null,
|
||||
after: { name, description: description || null, permissions: validPermissions.map((row) => row.name) }
|
||||
});
|
||||
return { roleSaved: true, roleMode: id ? 'edit' : 'add', resultToken: randomUUID() };
|
||||
},
|
||||
delete: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.roles');
|
||||
const operatorPermissions = await getPermissions(operator);
|
||||
const canManageSystemAdmin = operatorPermissions.includes('*');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '').trim();
|
||||
const confirmation = String(form.get('confirmation') ?? '').normalize('NFKC').trim();
|
||||
if (!id) return fail(400, { roleDeleteError: 'Choose a role to delete.' });
|
||||
if (id === 'role-admin') {
|
||||
return fail(403, { roleDeleteError: 'The core Admin role cannot be deleted.' });
|
||||
}
|
||||
if (seededRoleIds.has(id) && !canManageSystemAdmin) {
|
||||
return fail(403, { roleDeleteError: 'Only a full administrator can delete a seeded system role.' });
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const role = await (await database.prepare(
|
||||
'SELECT id,name,description FROM roles WHERE id=?'
|
||||
)).get(id);
|
||||
if (!role) return fail(404, { roleDeleteError: 'Role not found.' });
|
||||
if (confirmation !== String(role.name)) {
|
||||
return fail(400, { roleDeleteError: 'Enter the exact role name to confirm deletion.' });
|
||||
}
|
||||
const [permissions, assignedUsers] = await Promise.all([
|
||||
(await database.prepare(`SELECT permissions.name FROM role_permissions
|
||||
save: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'users.roles',
|
||||
);
|
||||
const operatorPermissions = await getPermissions(operator);
|
||||
const canManageSystemAdmin = operatorPermissions.includes('*');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '');
|
||||
const name = String(form.get('name') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
const description = String(form.get('description') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
const permissionIds = [
|
||||
...new Set(form.getAll('permissionIds').map(String)),
|
||||
];
|
||||
if (name.length < 2 || name.length > 60)
|
||||
return fail(400, { roleError: 'Role names must be 2–60 characters.' });
|
||||
if (description.length > 240)
|
||||
return fail(400, {
|
||||
roleError: 'Descriptions must be 240 characters or fewer.',
|
||||
});
|
||||
const database = await getDatabase();
|
||||
const existing = id
|
||||
? await (
|
||||
await database.prepare(
|
||||
'SELECT id,name,description FROM roles WHERE id=?',
|
||||
)
|
||||
).get(id)
|
||||
: null;
|
||||
if (id && !existing) return fail(404, { roleError: 'Role not found.' });
|
||||
if (id === 'role-admin' && !canManageSystemAdmin) {
|
||||
return fail(403, {
|
||||
roleError: 'Only a full administrator can modify the core Admin role.',
|
||||
});
|
||||
}
|
||||
if (
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT id FROM roles WHERE name=? COLLATE NOCASE AND id<>?',
|
||||
)
|
||||
).get(name, id || 'new')
|
||||
) {
|
||||
return fail(409, { roleError: 'A role with that name already exists.' });
|
||||
}
|
||||
const validPermissions = permissionIds.length
|
||||
? await (
|
||||
await database.prepare(
|
||||
`SELECT id,name FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).all(...permissionIds)
|
||||
: [];
|
||||
if (validPermissions.length !== permissionIds.length)
|
||||
return fail(400, { roleError: 'One or more permissions are invalid.' });
|
||||
if (
|
||||
!canManageSystemAdmin &&
|
||||
validPermissions.some((permission) => permission.name === 'system.admin')
|
||||
) {
|
||||
return fail(403, {
|
||||
roleError:
|
||||
'Only a full administrator can grant unrestricted system access.',
|
||||
});
|
||||
}
|
||||
const roleId = id || randomUUID();
|
||||
const beforeLinks = id
|
||||
? 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(id)
|
||||
: [];
|
||||
if (id)
|
||||
await (
|
||||
await database.prepare(
|
||||
'UPDATE roles SET name=?,description=? WHERE id=?',
|
||||
)
|
||||
).run(name, description || null, id);
|
||||
else
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO roles(id,name,description) VALUES(?,?,?)',
|
||||
)
|
||||
).run(roleId, name, description || null);
|
||||
await (
|
||||
await database.prepare('DELETE FROM role_permissions WHERE role_id=?')
|
||||
).run(roleId);
|
||||
for (const permissionId of permissionIds) {
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO role_permissions(role_id,permission_id) VALUES(?,?)',
|
||||
)
|
||||
).run(roleId, permissionId);
|
||||
}
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'role_management',
|
||||
action: id ? 'role.updated' : 'role.created',
|
||||
entityId: roleId,
|
||||
before: id
|
||||
? {
|
||||
name: existing?.name,
|
||||
description: existing?.description,
|
||||
permissions: beforeLinks.map((row) => row.name),
|
||||
}
|
||||
: null,
|
||||
after: {
|
||||
name,
|
||||
description: description || null,
|
||||
permissions: validPermissions.map((row) => row.name),
|
||||
},
|
||||
});
|
||||
return {
|
||||
roleSaved: true,
|
||||
roleMode: id ? 'edit' : 'add',
|
||||
resultToken: randomUUID(),
|
||||
};
|
||||
},
|
||||
delete: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'users.roles',
|
||||
);
|
||||
const operatorPermissions = await getPermissions(operator);
|
||||
const canManageSystemAdmin = operatorPermissions.includes('*');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '').trim();
|
||||
const confirmation = String(form.get('confirmation') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
if (!id) return fail(400, { roleDeleteError: 'Choose a role to delete.' });
|
||||
if (id === 'role-admin') {
|
||||
return fail(403, {
|
||||
roleDeleteError: 'The core Admin role cannot be deleted.',
|
||||
});
|
||||
}
|
||||
if (seededRoleIds.has(id) && !canManageSystemAdmin) {
|
||||
return fail(403, {
|
||||
roleDeleteError:
|
||||
'Only a full administrator can delete a seeded system role.',
|
||||
});
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const role = await (
|
||||
await database.prepare('SELECT id,name,description FROM roles WHERE id=?')
|
||||
).get(id);
|
||||
if (!role) return fail(404, { roleDeleteError: 'Role not found.' });
|
||||
if (confirmation !== String(role.name)) {
|
||||
return fail(400, {
|
||||
roleDeleteError: 'Enter the exact role name to confirm deletion.',
|
||||
});
|
||||
}
|
||||
const [permissions, assignedUsers] = await Promise.all([
|
||||
(
|
||||
await database.prepare(`SELECT permissions.name FROM role_permissions
|
||||
JOIN permissions ON permissions.id=role_permissions.permission_id
|
||||
WHERE role_permissions.role_id=? ORDER BY permissions.name COLLATE NOCASE`)).all(id),
|
||||
(await database.prepare(`SELECT users.id,
|
||||
WHERE role_permissions.role_id=? ORDER BY permissions.name COLLATE NOCASE`)
|
||||
).all(id),
|
||||
(
|
||||
await database.prepare(`SELECT users.id,
|
||||
COALESCE(user_profiles.display_name,users.username) AS display_name
|
||||
FROM user_roles JOIN users ON users.id=user_roles.user_id
|
||||
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||
WHERE user_roles.role_id=?
|
||||
ORDER BY display_name COLLATE NOCASE,users.id`)).all(id)
|
||||
]);
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
if (seededRoleIds.has(id)) {
|
||||
await (await database.prepare(`INSERT INTO deleted_roles(role_id,deleted_at,deleted_by)
|
||||
ORDER BY display_name COLLATE NOCASE,users.id`)
|
||||
).all(id),
|
||||
]);
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
if (seededRoleIds.has(id)) {
|
||||
await (
|
||||
await database.prepare(`INSERT INTO deleted_roles(role_id,deleted_at,deleted_by)
|
||||
VALUES(?,?,?)
|
||||
ON CONFLICT(role_id) DO UPDATE SET
|
||||
deleted_at=excluded.deleted_at,
|
||||
deleted_by=excluded.deleted_by`))
|
||||
.run(id, new Date().toISOString(), operator.id);
|
||||
}
|
||||
await (await database.prepare('DELETE FROM user_roles WHERE role_id=?')).run(id);
|
||||
await (await database.prepare('DELETE FROM role_permissions WHERE role_id=?')).run(id);
|
||||
const deleted = await (await database.prepare('DELETE FROM roles WHERE id=?')).run(id);
|
||||
if (!deleted.changes) throw new Error('The role changed before it could be deleted.');
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'role_management',
|
||||
action: 'role.deleted',
|
||||
entityId: id,
|
||||
before: {
|
||||
name: String(role.name),
|
||||
description: role.description ? String(role.description) : null,
|
||||
permissions: permissions.map((row) => String(row.name)),
|
||||
assignedUsers: assignedUsers.map((row) => ({
|
||||
id: String(row.id),
|
||||
displayName: String(row.display_name)
|
||||
}))
|
||||
},
|
||||
after: {
|
||||
deleted: true,
|
||||
systemRole: seededRoleIds.has(id),
|
||||
removedAssignmentCount: assignedUsers.length
|
||||
}
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
return { roleDeleted: true, resultToken: randomUUID() };
|
||||
}
|
||||
deleted_by=excluded.deleted_by`)
|
||||
).run(id, new Date().toISOString(), operator.id);
|
||||
}
|
||||
await (
|
||||
await database.prepare('DELETE FROM user_roles WHERE role_id=?')
|
||||
).run(id);
|
||||
await (
|
||||
await database.prepare('DELETE FROM role_permissions WHERE role_id=?')
|
||||
).run(id);
|
||||
const deleted = await (
|
||||
await database.prepare('DELETE FROM roles WHERE id=?')
|
||||
).run(id);
|
||||
if (!deleted.changes)
|
||||
throw new Error('The role changed before it could be deleted.');
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'role_management',
|
||||
action: 'role.deleted',
|
||||
entityId: id,
|
||||
before: {
|
||||
name: String(role.name),
|
||||
description: role.description ? String(role.description) : null,
|
||||
permissions: permissions.map((row) => String(row.name)),
|
||||
assignedUsers: assignedUsers.map((row) => ({
|
||||
id: String(row.id),
|
||||
displayName: String(row.display_name),
|
||||
})),
|
||||
},
|
||||
after: {
|
||||
deleted: true,
|
||||
systemRole: seededRoleIds.has(id),
|
||||
removedAssignmentCount: assignedUsers.length,
|
||||
},
|
||||
});
|
||||
await database.exec('COMMIT');
|
||||
} catch (error) {
|
||||
await database.exec('ROLLBACK');
|
||||
throw error;
|
||||
}
|
||||
return { roleDeleted: true, resultToken: randomUUID() };
|
||||
},
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,223 +1,244 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
audits: [] as Array<Record<string, unknown>>,
|
||||
executed: [] as string[],
|
||||
inserts: [] as unknown[][],
|
||||
invalidatePolicy: vi.fn(),
|
||||
invalidateSiteAccess: vi.fn(),
|
||||
siteSettings: {} as Record<string, unknown>,
|
||||
moderationSettings: {} as Record<string, unknown>
|
||||
audits: [] as Array<Record<string, unknown>>,
|
||||
executed: [] as string[],
|
||||
inserts: [] as unknown[][],
|
||||
invalidatePolicy: vi.fn(),
|
||||
invalidateSiteAccess: vi.fn(),
|
||||
siteSettings: {} as Record<string, unknown>,
|
||||
moderationSettings: {} as Record<string, unknown>,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/session', () => ({
|
||||
getSessionUser: vi.fn(async () => ({ id: 'operator-1' }))
|
||||
getSessionUser: vi.fn(async () => ({ id: 'operator-1' })),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/authorization', () => ({
|
||||
requirePermission: vi.fn(async (user) => user)
|
||||
requirePermission: vi.fn(async (user) => user),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/audit', () => ({
|
||||
recordIsacAudit: vi.fn(async (event) => {
|
||||
mocks.audits.push(event);
|
||||
})
|
||||
recordIsacAudit: vi.fn(async (event) => {
|
||||
mocks.audits.push(event);
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/site-access', () => ({
|
||||
invalidateSiteAccessSettings: mocks.invalidateSiteAccess
|
||||
invalidateSiteAccessSettings: mocks.invalidateSiteAccess,
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/community-moderation', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('$lib/server/community-moderation')>();
|
||||
return { ...actual, invalidateCommunityModerationPolicy: mocks.invalidatePolicy };
|
||||
const actual =
|
||||
await importOriginal<typeof import('$lib/server/community-moderation')>();
|
||||
return {
|
||||
...actual,
|
||||
invalidateCommunityModerationPolicy: mocks.invalidatePolicy,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('$lib/server/database', () => ({
|
||||
getDatabase: vi.fn(async () => ({
|
||||
exec: async (statement: string) => {
|
||||
mocks.executed.push(statement);
|
||||
},
|
||||
prepare: (statement: string) => ({
|
||||
all: async () => statement.includes('FROM community_moderation_terms') ? [] : [],
|
||||
get: async () => {
|
||||
if (statement.includes('FROM site_launch_settings')) return { ...mocks.siteSettings };
|
||||
if (statement.includes('FROM community_moderation_settings')) return { ...mocks.moderationSettings };
|
||||
return null;
|
||||
},
|
||||
run: async (...values: unknown[]) => {
|
||||
if (statement.includes('INSERT INTO community_moderation_terms')) mocks.inserts.push(values);
|
||||
if (statement.includes('SET coming_soon_enabled=')) {
|
||||
mocks.siteSettings.coming_soon_enabled = values[0];
|
||||
mocks.siteSettings.updated_at = values[1];
|
||||
mocks.siteSettings.updated_by = values[2];
|
||||
}
|
||||
if (statement.includes('SET\n\t\t\tautomated_enabled=')) {
|
||||
mocks.moderationSettings.automated_enabled = values[0];
|
||||
mocks.moderationSettings.provider_model = values[1];
|
||||
mocks.moderationSettings.policy_version = Number(mocks.moderationSettings.policy_version) + 1;
|
||||
mocks.moderationSettings.updated_at = values[2];
|
||||
mocks.moderationSettings.updated_by = values[3];
|
||||
}
|
||||
return { changes: 1 };
|
||||
}
|
||||
})
|
||||
}))
|
||||
getDatabase: vi.fn(async () => ({
|
||||
exec: async (statement: string) => {
|
||||
mocks.executed.push(statement);
|
||||
},
|
||||
prepare: (statement: string) => ({
|
||||
all: async () =>
|
||||
statement.includes('FROM community_moderation_terms') ? [] : [],
|
||||
get: async () => {
|
||||
if (statement.includes('FROM site_launch_settings'))
|
||||
return { ...mocks.siteSettings };
|
||||
if (statement.includes('FROM community_moderation_settings'))
|
||||
return { ...mocks.moderationSettings };
|
||||
return null;
|
||||
},
|
||||
run: async (...values: unknown[]) => {
|
||||
if (statement.includes('INSERT INTO community_moderation_terms'))
|
||||
mocks.inserts.push(values);
|
||||
if (statement.includes('SET coming_soon_enabled=')) {
|
||||
mocks.siteSettings.coming_soon_enabled = values[0];
|
||||
mocks.siteSettings.updated_at = values[1];
|
||||
mocks.siteSettings.updated_by = values[2];
|
||||
}
|
||||
if (statement.includes('SET\n\t\t\tautomated_enabled=')) {
|
||||
mocks.moderationSettings.automated_enabled = values[0];
|
||||
mocks.moderationSettings.provider_model = values[1];
|
||||
mocks.moderationSettings.policy_version =
|
||||
Number(mocks.moderationSettings.policy_version) + 1;
|
||||
mocks.moderationSettings.updated_at = values[2];
|
||||
mocks.moderationSettings.updated_by = values[3];
|
||||
}
|
||||
return { changes: 1 };
|
||||
},
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { actions } from './+page.server';
|
||||
|
||||
describe('bulk moderation term action', () => {
|
||||
beforeEach(() => {
|
||||
mocks.audits.length = 0;
|
||||
mocks.executed.length = 0;
|
||||
mocks.inserts.length = 0;
|
||||
mocks.invalidatePolicy.mockClear();
|
||||
mocks.invalidateSiteAccess.mockClear();
|
||||
mocks.siteSettings = {
|
||||
id: 'primary',
|
||||
coming_soon_enabled: 1,
|
||||
account_creation_enabled: 1,
|
||||
updated_at: '2026-07-01T00:00:00.000Z',
|
||||
updated_by: null
|
||||
};
|
||||
mocks.moderationSettings = {
|
||||
id: 'primary',
|
||||
automated_enabled: 1,
|
||||
provider_model: 'omni-moderation-latest',
|
||||
policy_version: 4,
|
||||
updated_at: '2026-07-01T00:00:00.000Z',
|
||||
updated_by: null
|
||||
};
|
||||
});
|
||||
beforeEach(() => {
|
||||
mocks.audits.length = 0;
|
||||
mocks.executed.length = 0;
|
||||
mocks.inserts.length = 0;
|
||||
mocks.invalidatePolicy.mockClear();
|
||||
mocks.invalidateSiteAccess.mockClear();
|
||||
mocks.siteSettings = {
|
||||
id: 'primary',
|
||||
coming_soon_enabled: 1,
|
||||
account_creation_enabled: 1,
|
||||
updated_at: '2026-07-01T00:00:00.000Z',
|
||||
updated_by: null,
|
||||
};
|
||||
mocks.moderationSettings = {
|
||||
id: 'primary',
|
||||
automated_enabled: 1,
|
||||
provider_model: 'omni-moderation-latest',
|
||||
policy_version: 4,
|
||||
updated_at: '2026-07-01T00:00:00.000Z',
|
||||
updated_by: null,
|
||||
};
|
||||
});
|
||||
|
||||
it('creates and audits each normalized rule in one transaction', async () => {
|
||||
const form = new FormData();
|
||||
form.set('listType', 'allow');
|
||||
form.set('enabled', '1');
|
||||
form.append('scopes', 'comment');
|
||||
form.append('scopes', 'guide');
|
||||
form.set('terms', 'Kill\nmachine gun\nkill');
|
||||
form.set('note', 'Common game vocabulary');
|
||||
const result = await actions.bulkAddModerationTerms?.({
|
||||
cookies: {} as never,
|
||||
request: new Request('http://localhost/isac/site-controls?/bulkAddModerationTerms', {
|
||||
method: 'POST',
|
||||
body: form
|
||||
})
|
||||
} as never);
|
||||
it('creates and audits each normalized rule in one transaction', async () => {
|
||||
const form = new FormData();
|
||||
form.set('listType', 'allow');
|
||||
form.set('enabled', '1');
|
||||
form.append('scopes', 'comment');
|
||||
form.append('scopes', 'guide');
|
||||
form.set('terms', 'Kill\nmachine gun\nkill');
|
||||
form.set('note', 'Common game vocabulary');
|
||||
const result = await actions.bulkAddModerationTerms?.({
|
||||
cookies: {} as never,
|
||||
request: new Request(
|
||||
'http://localhost/isac/site-controls?/bulkAddModerationTerms',
|
||||
{
|
||||
method: 'POST',
|
||||
body: form,
|
||||
},
|
||||
),
|
||||
} as never);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
bulkModerationTermsSaved: true,
|
||||
bulkModerationTermsAdded: 2,
|
||||
bulkModerationTermsSkipped: 1
|
||||
});
|
||||
expect(mocks.executed).toEqual(['BEGIN IMMEDIATE', 'COMMIT']);
|
||||
expect(mocks.inserts).toHaveLength(2);
|
||||
expect(mocks.inserts.map((values) => values.slice(1, 5))).toEqual([
|
||||
['allow', 'word', 'Kill', 'kill'],
|
||||
['allow', 'phrase', 'machine gun', 'machine gun']
|
||||
]);
|
||||
expect(mocks.audits).toHaveLength(2);
|
||||
expect(mocks.audits[0]).toMatchObject({
|
||||
actorId: 'operator-1',
|
||||
category: 'system_configuration',
|
||||
action: 'moderation.term_created',
|
||||
entityId: expect.any(String),
|
||||
before: null,
|
||||
after: {
|
||||
listKind: 'allow',
|
||||
matchMode: 'word',
|
||||
value: 'Kill',
|
||||
normalizedValue: 'kill',
|
||||
scopes: ['comment', 'guide'],
|
||||
enabled: 1,
|
||||
note: 'Common game vocabulary'
|
||||
}
|
||||
});
|
||||
expect(mocks.invalidatePolicy).toHaveBeenCalledOnce();
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
bulkModerationTermsSaved: true,
|
||||
bulkModerationTermsAdded: 2,
|
||||
bulkModerationTermsSkipped: 1,
|
||||
});
|
||||
expect(mocks.executed).toEqual(['BEGIN IMMEDIATE', 'COMMIT']);
|
||||
expect(mocks.inserts).toHaveLength(2);
|
||||
expect(mocks.inserts.map((values) => values.slice(1, 5))).toEqual([
|
||||
['allow', 'word', 'Kill', 'kill'],
|
||||
['allow', 'phrase', 'machine gun', 'machine gun'],
|
||||
]);
|
||||
expect(mocks.audits).toHaveLength(2);
|
||||
expect(mocks.audits[0]).toMatchObject({
|
||||
actorId: 'operator-1',
|
||||
category: 'system_configuration',
|
||||
action: 'moderation.term_created',
|
||||
entityId: expect.any(String),
|
||||
before: null,
|
||||
after: {
|
||||
listKind: 'allow',
|
||||
matchMode: 'word',
|
||||
value: 'Kill',
|
||||
normalizedValue: 'kill',
|
||||
scopes: ['comment', 'guide'],
|
||||
enabled: 1,
|
||||
note: 'Common game vocabulary',
|
||||
},
|
||||
});
|
||||
expect(mocks.invalidatePolicy).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('saves and audits the launch gate without changing unrelated site controls', async () => {
|
||||
const form = new FormData();
|
||||
form.set('comingSoonEnabled', '0');
|
||||
it('saves and audits the launch gate without changing unrelated site controls', async () => {
|
||||
const form = new FormData();
|
||||
form.set('comingSoonEnabled', '0');
|
||||
|
||||
const result = await actions.saveLaunchGate?.({
|
||||
cookies: {} as never,
|
||||
request: new Request('http://localhost/isac/site-controls?/saveLaunchGate', {
|
||||
method: 'POST',
|
||||
body: form
|
||||
})
|
||||
} as never);
|
||||
const result = await actions.saveLaunchGate?.({
|
||||
cookies: {} as never,
|
||||
request: new Request(
|
||||
'http://localhost/isac/site-controls?/saveLaunchGate',
|
||||
{
|
||||
method: 'POST',
|
||||
body: form,
|
||||
},
|
||||
),
|
||||
} as never);
|
||||
|
||||
expect(result).toMatchObject({ launchGateSaved: true });
|
||||
expect(mocks.siteSettings).toMatchObject({
|
||||
coming_soon_enabled: 0,
|
||||
account_creation_enabled: 1,
|
||||
updated_by: 'operator-1'
|
||||
});
|
||||
expect(mocks.invalidateSiteAccess).toHaveBeenCalledOnce();
|
||||
expect(mocks.audits).toEqual([
|
||||
expect.objectContaining({
|
||||
actorId: 'operator-1',
|
||||
category: 'system_configuration',
|
||||
action: 'site_access.launch_gate_updated',
|
||||
entityId: 'primary',
|
||||
before: expect.objectContaining({ coming_soon_enabled: 1 }),
|
||||
after: expect.objectContaining({ coming_soon_enabled: 0 })
|
||||
})
|
||||
]);
|
||||
});
|
||||
expect(result).toMatchObject({ launchGateSaved: true });
|
||||
expect(mocks.siteSettings).toMatchObject({
|
||||
coming_soon_enabled: 0,
|
||||
account_creation_enabled: 1,
|
||||
updated_by: 'operator-1',
|
||||
});
|
||||
expect(mocks.invalidateSiteAccess).toHaveBeenCalledOnce();
|
||||
expect(mocks.audits).toEqual([
|
||||
expect.objectContaining({
|
||||
actorId: 'operator-1',
|
||||
category: 'system_configuration',
|
||||
action: 'site_access.launch_gate_updated',
|
||||
entityId: 'primary',
|
||||
before: expect.objectContaining({ coming_soon_enabled: 1 }),
|
||||
after: expect.objectContaining({ coming_soon_enabled: 0 }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('saves one moderation panel, increments policy version, and audits its evidence', async () => {
|
||||
const form = new FormData();
|
||||
form.set('automatedEnabled', '0');
|
||||
form.set('providerModel', 'omni-moderation-2024-09-26');
|
||||
it('saves one moderation panel, increments policy version, and audits its evidence', async () => {
|
||||
const form = new FormData();
|
||||
form.set('automatedEnabled', '0');
|
||||
form.set('providerModel', 'omni-moderation-2024-09-26');
|
||||
|
||||
const result = await actions.saveModerationScreening?.({
|
||||
cookies: {} as never,
|
||||
request: new Request('http://localhost/isac/site-controls?/saveModerationScreening', {
|
||||
method: 'POST',
|
||||
body: form
|
||||
})
|
||||
} as never);
|
||||
const result = await actions.saveModerationScreening?.({
|
||||
cookies: {} as never,
|
||||
request: new Request(
|
||||
'http://localhost/isac/site-controls?/saveModerationScreening',
|
||||
{
|
||||
method: 'POST',
|
||||
body: form,
|
||||
},
|
||||
),
|
||||
} as never);
|
||||
|
||||
expect(result).toMatchObject({ moderationScreeningSaved: true });
|
||||
expect(mocks.moderationSettings).toMatchObject({
|
||||
automated_enabled: 0,
|
||||
provider_model: 'omni-moderation-2024-09-26',
|
||||
policy_version: 5,
|
||||
updated_by: 'operator-1'
|
||||
});
|
||||
expect(mocks.invalidatePolicy).toHaveBeenCalledOnce();
|
||||
expect(mocks.audits).toEqual([
|
||||
expect.objectContaining({
|
||||
actorId: 'operator-1',
|
||||
category: 'system_configuration',
|
||||
action: 'moderation.screening_updated',
|
||||
entityId: 'primary',
|
||||
before: expect.objectContaining({ policy_version: 4 }),
|
||||
after: expect.objectContaining({ policy_version: 5 })
|
||||
})
|
||||
]);
|
||||
});
|
||||
expect(result).toMatchObject({ moderationScreeningSaved: true });
|
||||
expect(mocks.moderationSettings).toMatchObject({
|
||||
automated_enabled: 0,
|
||||
provider_model: 'omni-moderation-2024-09-26',
|
||||
policy_version: 5,
|
||||
updated_by: 'operator-1',
|
||||
});
|
||||
expect(mocks.invalidatePolicy).toHaveBeenCalledOnce();
|
||||
expect(mocks.audits).toEqual([
|
||||
expect.objectContaining({
|
||||
actorId: 'operator-1',
|
||||
category: 'system_configuration',
|
||||
action: 'moderation.screening_updated',
|
||||
entityId: 'primary',
|
||||
before: expect.objectContaining({ policy_version: 4 }),
|
||||
after: expect.objectContaining({ policy_version: 5 }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects an invalid panel value without mutation, audit, or cache invalidation', async () => {
|
||||
const form = new FormData();
|
||||
form.set('automatedEnabled', 'sometimes');
|
||||
form.set('providerModel', 'omni-moderation-latest');
|
||||
it('rejects an invalid panel value without mutation, audit, or cache invalidation', async () => {
|
||||
const form = new FormData();
|
||||
form.set('automatedEnabled', 'sometimes');
|
||||
form.set('providerModel', 'omni-moderation-latest');
|
||||
|
||||
const result = await actions.saveModerationScreening?.({
|
||||
cookies: {} as never,
|
||||
request: new Request('http://localhost/isac/site-controls?/saveModerationScreening', {
|
||||
method: 'POST',
|
||||
body: form
|
||||
})
|
||||
} as never);
|
||||
const result = await actions.saveModerationScreening?.({
|
||||
cookies: {} as never,
|
||||
request: new Request(
|
||||
'http://localhost/isac/site-controls?/saveModerationScreening',
|
||||
{
|
||||
method: 'POST',
|
||||
body: form,
|
||||
},
|
||||
),
|
||||
} as never);
|
||||
|
||||
expect(result).toMatchObject({ status: 400 });
|
||||
expect(mocks.moderationSettings).toMatchObject({ policy_version: 4 });
|
||||
expect(mocks.audits).toHaveLength(0);
|
||||
expect(mocks.invalidatePolicy).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(result).toMatchObject({ status: 400 });
|
||||
expect(mocks.moderationSettings).toMatchObject({ policy_version: 4 });
|
||||
expect(mocks.audits).toHaveLength(0);
|
||||
expect(mocks.invalidatePolicy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||||
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
|
||||
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
@@ -21,11 +22,13 @@
|
||||
clauses:[{key:'primary',label:'Primary',event:'skill_hit',if:{kind:'true'},reducer:{kind:'none'},mutations:[],outcomes:[{key:'effect-1',label:'Effect 1',phase:'while_active',kind:'attribute_modifier',scope:'assigned_ability',attributeId:'',basis:'flat',perStack:true,descriptionSlot:1,variants:{minimum:{value:0},maximum:{value:0}}}]}]
|
||||
});
|
||||
const blankTalent = () => ({ id: '', name: '', description_template: '', conditionalRule: blankRule() });
|
||||
const blank = () => ({ id: '', name: '', abilityId: '', iconUrl: '', active: 1, twoPiece: { stat_attribute_id: '', min_value: '', max_value: '' }, primaryStatIds: [], secondaryStatIds: [], talent: blankTalent() });
|
||||
const blank = () => ({ id: '', name: '', abilityId: '', iconUrl: '', renderUrl: '', active: 1, twoPiece: { stat_attribute_id: '', min_value: '', max_value: '' }, primaryStatIds: [], secondaryStatIds: [], talent: blankTalent() });
|
||||
let editing = $state<any | null>(null);
|
||||
let highEndCount = $state('0');
|
||||
let handledResultToken = $state('');
|
||||
let iconResetToken = $state(0);
|
||||
let renderResetToken = $state(0);
|
||||
let openedRequestedItem = $state(false);
|
||||
const qualitySteps = [
|
||||
{ value: '0', label: '0 HIGH-END' },
|
||||
{ value: '1', label: '1 HIGH-END' },
|
||||
@@ -69,9 +72,17 @@
|
||||
highEndCount = '0';
|
||||
};
|
||||
$effect(() => {
|
||||
if (!openedRequestedItem && data.requestedItemId) {
|
||||
const requested = data.sets.find((item: any) => item.id === data.requestedItemId);
|
||||
if (requested) edit(requested);
|
||||
openedRequestedItem = true;
|
||||
}
|
||||
if (form?.skillChipSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
||||
handledResultToken = form.resultToken;
|
||||
if (form.skillChipMode === 'add') iconResetToken += 1;
|
||||
if (form.skillChipMode === 'add') {
|
||||
iconResetToken += 1;
|
||||
renderResetToken += 1;
|
||||
}
|
||||
editing = form.skillChipMode === 'add' ? blank() : null;
|
||||
highEndCount = '0';
|
||||
}
|
||||
@@ -96,7 +107,7 @@
|
||||
<PendingForm action="?/save" enctype="multipart/form-data">
|
||||
<input type="hidden" name="id" value={editing.id}/>
|
||||
<input type="hidden" name="conditionalRule" value={JSON.stringify(editing.talent.conditionalRule)}/>
|
||||
<div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON" resetToken={iconResetToken}/><label class="field">CHIP SET NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label><div class="quality-scope"><span>SUPPORTED QUALITY</span><div><b class="quality-superior">SUPERIOR</b><b class="quality-highend">HIGH-END</b></div><small>Only these qualities participate in chip scaling.</small></div></div>
|
||||
<div class="skill-chip-identity"><IconUploadField currentUrl={editing.iconUrl} label="SKILL CHIP ICON" resetToken={iconResetToken}/><GameDataRenderUploadField currentUrl={editing.renderUrl} label="SKILL CHIP RENDER" resetToken={renderResetToken}/><label class="field">CHIP SET NAME<input name="name" bind:value={editing.name} maxlength="80" required/></label><div class="quality-scope"><span>SUPPORTED QUALITY</span><div><b class="quality-superior">SUPERIOR</b><b class="quality-highend">HIGH-END</b></div><small>Only these qualities participate in chip scaling.</small></div></div>
|
||||
<section class="skill-chip-ability-assignment">
|
||||
<div class="selection-head"><div><span>SPECIALIZATION ABILITY</span><small>EXCLUSIVE RELATIONSHIP · THREE ACTIVE SKILL CHIPS PER ABILITY</small></div><b>{editing.abilityId ? `${ability(editing.abilityId)?.chipCount ?? 0} / 3 ASSIGNED` : 'REQUIRED'}</b></div>
|
||||
<SearchableAssignment items={data.abilities} fieldName="abilityId" mode="single" compact subjectLabel="SPECIALIZATION ABILITY" searchPlaceholder="Search abilities or specializations…" emptySelectionLabel="NO ABILITY ASSIGNED" bind:value={editing.abilityId}/>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@
|
||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||||
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
|
||||
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||
import GameIcon from '$lib/components/GameIcon.svelte';
|
||||
import StructuredEffectEditor, { type StructuredEffect } from '$lib/components/StructuredEffectEditor.svelte';
|
||||
@@ -14,7 +15,7 @@
|
||||
import type { ConditionalMechanicDraftV1 } from '$lib/calculations/mechanic-editor';
|
||||
|
||||
let { data, form } = $props();
|
||||
type SpecializationEditor = { id: string; name: string; description: string; active: number; iconUrl?: string };
|
||||
type SpecializationEditor = { id: string; name: string; description: string; active: number; iconUrl?: string; renderUrl?: string };
|
||||
type FocusEditor = {
|
||||
id: string;
|
||||
specializationId: string;
|
||||
@@ -47,6 +48,7 @@
|
||||
effects: StructuredEffect[];
|
||||
active: number;
|
||||
iconUrl?: string;
|
||||
renderUrl?: string;
|
||||
};
|
||||
type CombatEffectEditor = {
|
||||
id: string;
|
||||
@@ -77,6 +79,7 @@
|
||||
ranks: RankEditor[];
|
||||
active: number;
|
||||
iconUrl?: string;
|
||||
renderUrl?: string;
|
||||
};
|
||||
|
||||
const primaryOptions = [
|
||||
@@ -89,7 +92,7 @@
|
||||
const deliveryOptions = [{ value: 'direct', label: 'DIRECT' }, { value: 'periodic', label: 'PERIODIC' }];
|
||||
const chargePolicyOptions = [{ value: 'none', label: 'NO CHARGES' }, { value: 'independent', label: 'INDEPENDENT' }, { value: 'full_set', label: 'FULL SET' }];
|
||||
const conditionalValueColumns = [{ key: 'base', variantKey: 'base', label: 'VALUE PER STACK' }];
|
||||
const blankSpecialization = (): SpecializationEditor => ({ id: '', name: '', description: '', active: 1, iconUrl: '' });
|
||||
const blankSpecialization = (): SpecializationEditor => ({ id: '', name: '', description: '', active: 1, iconUrl: '', renderUrl: '' });
|
||||
const blankFocus = (specializationId = ''): FocusEditor => ({
|
||||
id: '', specializationId, name: '', description: '', primaryAttribute: 'Firepower', displayOrder: 0, active: 1
|
||||
});
|
||||
@@ -100,7 +103,7 @@
|
||||
hitsPerActivation: 1, tickIntervalSeconds: '', damageDurationSeconds: '',
|
||||
castSeconds: 0, targetCap: 1, chargeCount: 1, chargePolicy: 'none'
|
||||
},
|
||||
effects: [], active: 1, iconUrl: ''
|
||||
effects: [], active: 1, iconUrl: '', renderUrl: ''
|
||||
});
|
||||
const blankCombatEffect = (specializationId = ''): CombatEffectEditor => ({
|
||||
id: '', specializationId, name: '', descriptionTemplate: '', active: 1
|
||||
@@ -124,7 +127,7 @@
|
||||
});
|
||||
const blankNode = (focusId = '', rowNumber = 1): NodeEditor => ({
|
||||
id: '', focusId, name: '', descriptionTemplate: '', rowNumber,
|
||||
columnNumber: rowNumber === 1 ? 3 : 1, maxRank: '1', ranks: [blankRank(1)], active: 1, iconUrl: ''
|
||||
columnNumber: rowNumber === 1 ? 3 : 1, maxRank: '1', ranks: [blankRank(1)], active: 1, iconUrl: '', renderUrl: ''
|
||||
});
|
||||
const normalizedEffect = (effect: any): StructuredEffect => ({
|
||||
type: (effect.effect_type ?? (effect.stat_attribute_id ? 'Attribute' : 'Custom')) as StructuredEffect['type'],
|
||||
@@ -150,6 +153,8 @@
|
||||
let abilitySlots = $state<string[]>(['', '', '']);
|
||||
let handledResultToken = $state('');
|
||||
let iconResetToken = $state('');
|
||||
let renderResetToken = $state('');
|
||||
let openedRequestedItem = $state(false);
|
||||
|
||||
const specializationById = (id: string) => data.specializations.find((item: any) => item.id === id);
|
||||
const focusById = (id: string) => data.focuses.find((item: any) => item.id === id);
|
||||
@@ -319,6 +324,14 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!openedRequestedItem && data.requestedItemId) {
|
||||
const requested = data.specializations.find((item: any) => item.id === data.requestedItemId);
|
||||
if (requested) {
|
||||
selectSpecialization(String(requested.id));
|
||||
editSpecialization(requested);
|
||||
}
|
||||
openedRequestedItem = true;
|
||||
}
|
||||
const token = String(form?.resultToken ?? '');
|
||||
if (!token || token === handledResultToken) return;
|
||||
handledResultToken = token;
|
||||
@@ -327,6 +340,7 @@
|
||||
if (form.specializationMode === 'add') {
|
||||
specializationEditing = blankSpecialization();
|
||||
iconResetToken = token;
|
||||
renderResetToken = token;
|
||||
} else specializationEditing = null;
|
||||
}
|
||||
if (form?.focusSaved) focusEditing = form.focusMode === 'add' ? blankFocus(selectedSpecializationId) : null;
|
||||
@@ -334,6 +348,7 @@
|
||||
if (form.abilityMode === 'add') {
|
||||
abilityEditing = blankAbility(selectedSpecializationId);
|
||||
iconResetToken = token;
|
||||
renderResetToken = token;
|
||||
} else abilityEditing = null;
|
||||
}
|
||||
if (form?.combatEffectSaved) {
|
||||
@@ -344,6 +359,7 @@
|
||||
if (form.nodeMode === 'add') {
|
||||
nodeEditing = blankNode(selectedFocusId, Number(nodeEditing?.rowNumber ?? 1));
|
||||
iconResetToken = token;
|
||||
renderResetToken = token;
|
||||
} else nodeEditing = null;
|
||||
}
|
||||
if (form?.specializationArchived || form?.specializationUnarchived) specializationEditing = null;
|
||||
@@ -416,6 +432,7 @@
|
||||
<input type="hidden" name="id" value={specializationEditing.id}/>
|
||||
<div class="isac-record-identity specialization-record-identity">
|
||||
<IconUploadField currentUrl={specializationEditing.iconUrl} label="SPECIALIZATION ICON" resetToken={iconResetToken}/>
|
||||
<GameDataRenderUploadField currentUrl={specializationEditing.renderUrl} label="SPECIALIZATION RENDER" resetToken={renderResetToken}/>
|
||||
<div>
|
||||
<label class="field isac-record-name">NAME<input name="name" bind:value={specializationEditing.name} maxlength="80" required/></label>
|
||||
<label class="field">DESCRIPTION<textarea name="description" bind:value={specializationEditing.description} maxlength="1200" placeholder="Describe the specialization's class identity and purpose."></textarea></label>
|
||||
@@ -464,6 +481,7 @@
|
||||
<input type="hidden" name="attackJson" value={JSON.stringify(abilityEditing.attack)}/>
|
||||
<div class="isac-record-identity specialization-record-identity">
|
||||
<IconUploadField currentUrl={abilityEditing.iconUrl} label="ABILITY ICON" resetToken={iconResetToken}/>
|
||||
<GameDataRenderUploadField currentUrl={abilityEditing.renderUrl} label="ABILITY RENDER" resetToken={renderResetToken}/>
|
||||
<div>
|
||||
<label class="field isac-record-name">NAME<input name="name" bind:value={abilityEditing.name} maxlength="100" required/></label>
|
||||
{#if abilityEditing.id}
|
||||
@@ -540,6 +558,7 @@
|
||||
<input type="hidden" name="ranksJson" value={JSON.stringify(nodeEditing.ranks)}/>
|
||||
<div class="isac-record-identity specialization-record-identity node-record-identity">
|
||||
<IconUploadField currentUrl={nodeEditing.iconUrl} label="NODE ICON" resetToken={iconResetToken}/>
|
||||
<GameDataRenderUploadField currentUrl={nodeEditing.renderUrl} label="NODE RENDER" resetToken={renderResetToken}/>
|
||||
<div>
|
||||
<label class="field isac-record-name">NAME<input name="name" bind:value={nodeEditing.name} maxlength="100" required/></label>
|
||||
{#if nodeEditing.id}
|
||||
|
||||
@@ -7,72 +7,150 @@ import { AUTOMATED_MESSAGE_EVENTS } from '$lib/server/automated-messages';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const parseActions = (value: unknown) => {
|
||||
try {
|
||||
const parsed = JSON.parse(String(value ?? '[]'));
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(String(value ?? '[]'));
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
await requirePermission(await getSessionUser(cookies), 'system.notifications.manage');
|
||||
const database = await getDatabase();
|
||||
const messages = await (await database.prepare(`SELECT message.*,
|
||||
await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'system.notifications.manage',
|
||||
);
|
||||
const database = await getDatabase();
|
||||
const messages = await (
|
||||
await database.prepare(`SELECT message.*,
|
||||
(SELECT COUNT(*) FROM automated_system_message_deliveries delivery WHERE delivery.message_id=message.id) AS delivery_count,
|
||||
(SELECT MAX(delivered_at) FROM automated_system_message_deliveries delivery WHERE delivery.message_id=message.id) AS last_delivered_at
|
||||
FROM automated_system_messages message ORDER BY name`)).all();
|
||||
return {
|
||||
events: AUTOMATED_MESSAGE_EVENTS,
|
||||
messages: messages.map((message) => ({ ...message, actions: parseActions(message.actions_json) }))
|
||||
};
|
||||
FROM automated_system_messages message ORDER BY name`)
|
||||
).all();
|
||||
return {
|
||||
events: AUTOMATED_MESSAGE_EVENTS,
|
||||
messages: messages.map((message) => ({
|
||||
...message,
|
||||
actions: parseActions(message.actions_json),
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
save: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'system.notifications.manage');
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '').trim();
|
||||
const source = String(form.get('source') ?? '').normalize('NFKC').trim();
|
||||
const title = String(form.get('title') ?? '').normalize('NFKC').trim();
|
||||
const body = String(form.get('body') ?? '').normalize('NFKC').trim();
|
||||
const link = String(form.get('link') ?? '').trim();
|
||||
const delay = Number(form.get('sendDelayMinutes') ?? 0);
|
||||
const enabled = form.get('enabled') === 'on';
|
||||
if (!id) return fail(400, { messageError: 'Choose an automated message to edit.' });
|
||||
if (source.length < 2 || source.length > 80) return fail(400, { messageError: 'Source must be 2–80 characters.' });
|
||||
if (title.length < 2 || title.length > 140) return fail(400, { messageError: 'Title must be 2–140 characters.' });
|
||||
if (body.length < 20 || body.length > 2000) return fail(400, { messageError: 'Message content must be 20–2,000 characters.' });
|
||||
if (!Number.isInteger(delay) || delay < 0 || delay > 10080) return fail(400, { messageError: 'Delay must be between 0 and 10,080 minutes.' });
|
||||
const validHref = (href: string) => href.startsWith('/') || /^https:\/\/[^\s]+$/i.test(href);
|
||||
if (link && !validHref(link)) return fail(400, { messageError: 'Primary destinations must use an internal path or secure HTTPS URL.' });
|
||||
const actions = form.getAll('actionLabel').map((label, index) => ({
|
||||
label: String(label).normalize('NFKC').trim(),
|
||||
href: String(form.getAll('actionHref')[index] ?? '').trim()
|
||||
})).filter((action) => action.label || action.href);
|
||||
if (actions.length > 8) return fail(400, { messageError: 'Use no more than eight action callouts.' });
|
||||
if (actions.some((action) => action.label.length < 2 || action.label.length > 60 || !validHref(action.href))) {
|
||||
return fail(400, { messageError: 'Each action needs a 2–60 character label and a valid internal or HTTPS destination.' });
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const before = await (await database.prepare('SELECT * FROM automated_system_messages WHERE id=?')).get(id);
|
||||
if (!before) return fail(404, { messageError: 'Automated message not found.' });
|
||||
const now = new Date().toISOString();
|
||||
const after = {
|
||||
...before, source, title, body, link: link || null, actions_json: JSON.stringify(actions),
|
||||
enabled: enabled ? 1 : 0, send_delay_minutes: delay, updated_at: now, updated_by: operator.id
|
||||
};
|
||||
await (await database.prepare(`UPDATE automated_system_messages SET
|
||||
enabled=?,send_delay_minutes=?,source=?,title=?,body=?,link=?,actions_json=?,updated_at=?,updated_by=? WHERE id=?`))
|
||||
.run(enabled ? 1 : 0, delay, source, title, body, link || null, JSON.stringify(actions), now, operator.id, id);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'system_configuration',
|
||||
action: 'automated_message.updated',
|
||||
entityId: id,
|
||||
before,
|
||||
after
|
||||
});
|
||||
return { messageSaved: true, editorMode: 'edit', resultToken: crypto.randomUUID() };
|
||||
}
|
||||
save: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'system.notifications.manage',
|
||||
);
|
||||
const form = await request.formData();
|
||||
const id = String(form.get('id') ?? '').trim();
|
||||
const source = String(form.get('source') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
const title = String(form.get('title') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
const body = String(form.get('body') ?? '')
|
||||
.normalize('NFKC')
|
||||
.trim();
|
||||
const link = String(form.get('link') ?? '').trim();
|
||||
const delay = Number(form.get('sendDelayMinutes') ?? 0);
|
||||
const enabled = form.get('enabled') === 'on';
|
||||
if (!id)
|
||||
return fail(400, {
|
||||
messageError: 'Choose an automated message to edit.',
|
||||
});
|
||||
if (source.length < 2 || source.length > 80)
|
||||
return fail(400, { messageError: 'Source must be 2–80 characters.' });
|
||||
if (title.length < 2 || title.length > 140)
|
||||
return fail(400, { messageError: 'Title must be 2–140 characters.' });
|
||||
if (body.length < 20 || body.length > 2000)
|
||||
return fail(400, {
|
||||
messageError: 'Message content must be 20–2,000 characters.',
|
||||
});
|
||||
if (!Number.isInteger(delay) || delay < 0 || delay > 10080)
|
||||
return fail(400, {
|
||||
messageError: 'Delay must be between 0 and 10,080 minutes.',
|
||||
});
|
||||
const validHref = (href: string) =>
|
||||
href.startsWith('/') || /^https:\/\/[^\s]+$/i.test(href);
|
||||
if (link && !validHref(link))
|
||||
return fail(400, {
|
||||
messageError:
|
||||
'Primary destinations must use an internal path or secure HTTPS URL.',
|
||||
});
|
||||
const actions = form
|
||||
.getAll('actionLabel')
|
||||
.map((label, index) => ({
|
||||
label: String(label).normalize('NFKC').trim(),
|
||||
href: String(form.getAll('actionHref')[index] ?? '').trim(),
|
||||
}))
|
||||
.filter((action) => action.label || action.href);
|
||||
if (actions.length > 8)
|
||||
return fail(400, {
|
||||
messageError: 'Use no more than eight action callouts.',
|
||||
});
|
||||
if (
|
||||
actions.some(
|
||||
(action) =>
|
||||
action.label.length < 2 ||
|
||||
action.label.length > 60 ||
|
||||
!validHref(action.href),
|
||||
)
|
||||
) {
|
||||
return fail(400, {
|
||||
messageError:
|
||||
'Each action needs a 2–60 character label and a valid internal or HTTPS destination.',
|
||||
});
|
||||
}
|
||||
const database = await getDatabase();
|
||||
const before = await (
|
||||
await database.prepare(
|
||||
'SELECT * FROM automated_system_messages WHERE id=?',
|
||||
)
|
||||
).get(id);
|
||||
if (!before)
|
||||
return fail(404, { messageError: 'Automated message not found.' });
|
||||
const now = new Date().toISOString();
|
||||
const after = {
|
||||
...before,
|
||||
source,
|
||||
title,
|
||||
body,
|
||||
link: link || null,
|
||||
actions_json: JSON.stringify(actions),
|
||||
enabled: enabled ? 1 : 0,
|
||||
send_delay_minutes: delay,
|
||||
updated_at: now,
|
||||
updated_by: operator.id,
|
||||
};
|
||||
await (
|
||||
await database.prepare(`UPDATE automated_system_messages SET
|
||||
enabled=?,send_delay_minutes=?,source=?,title=?,body=?,link=?,actions_json=?,updated_at=?,updated_by=? WHERE id=?`)
|
||||
).run(
|
||||
enabled ? 1 : 0,
|
||||
delay,
|
||||
source,
|
||||
title,
|
||||
body,
|
||||
link || null,
|
||||
JSON.stringify(actions),
|
||||
now,
|
||||
operator.id,
|
||||
id,
|
||||
);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'system_configuration',
|
||||
action: 'automated_message.updated',
|
||||
entityId: id,
|
||||
before,
|
||||
after,
|
||||
});
|
||||
return {
|
||||
messageSaved: true,
|
||||
editorMode: 'edit',
|
||||
resultToken: crypto.randomUUID(),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||
import KpiFilters from '$lib/components/KpiFilters.svelte';
|
||||
import IconUploadField from '$lib/components/IconUploadField.svelte';
|
||||
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
|
||||
import ItemIcon from '$lib/components/ItemIcon.svelte';
|
||||
import RegistryInlineEditor from '$lib/components/RegistryInlineEditor.svelte';
|
||||
import SortableColumnHeader from '$lib/components/SortableColumnHeader.svelte';
|
||||
@@ -19,6 +20,8 @@
|
||||
let talent = $state<any>(null);
|
||||
let handledResultToken = $state('');
|
||||
let iconResetToken = $state(0);
|
||||
let renderResetToken = $state(0);
|
||||
let openedRequestedItem = $state(false);
|
||||
let validationSummary = $state<HTMLElement | null>(null);
|
||||
type TalentErrorArea = 'icon' | 'identity' | 'description' | 'mechanic' | 'outcomes';
|
||||
type TalentValidationError = { area: TalentErrorArea; message: string };
|
||||
@@ -48,7 +51,7 @@
|
||||
pools: [],
|
||||
clauses: []
|
||||
});
|
||||
const blankTalent = () => ({ id: '', name: '', iconUrl: '', category: 'Weapon Talent 1', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', tierIIDocumented: true, tierIIDocumentation: 'documented', conditionalRule: blankRule(), modelingOpen: false });
|
||||
const blankTalent = () => ({ id: '', name: '', iconUrl: '', renderUrl: '', category: 'Weapon Talent 1', descriptionTemplate: '', tierIDescription: '', tierIIDescription: '', tierIIDocumented: true, tierIIDocumentation: 'documented', conditionalRule: blankRule(), modelingOpen: false });
|
||||
const editTalent = (row: any) => {
|
||||
const tier = (name: string) => row.tiers.find((item: any) => item.tier === name) ?? { description: '', effects: [] };
|
||||
const one = tier('I'), two = tier('II');
|
||||
@@ -132,10 +135,18 @@
|
||||
});
|
||||
};
|
||||
$effect(() => {
|
||||
if (!openedRequestedItem && data.requestedItemId) {
|
||||
const requested = data.talents.find((item: any) => item.id === data.requestedItemId);
|
||||
if (requested) editTalent(requested);
|
||||
openedRequestedItem = true;
|
||||
}
|
||||
if (!form?.resultToken || form.resultToken === handledResultToken) return;
|
||||
handledResultToken = form.resultToken;
|
||||
if (form.talentSaved) {
|
||||
if (form.talentMode === 'add') iconResetToken += 1;
|
||||
if (form.talentMode === 'add') {
|
||||
iconResetToken += 1;
|
||||
renderResetToken += 1;
|
||||
}
|
||||
talent = form.talentMode === 'add' ? blankTalent() : null;
|
||||
} else if (talentErrors.length) {
|
||||
if (hasErrors(['mechanic', 'outcomes'])) talent.modelingOpen = true;
|
||||
@@ -168,6 +179,7 @@
|
||||
<div class="talent-icon-identity">
|
||||
<div id="talent-icon" class:validation-area-error={hasErrors(['icon'])}>
|
||||
<IconUploadField currentUrl={talent.iconUrl} label="TALENT ICON" resetToken={iconResetToken}/>
|
||||
<GameDataRenderUploadField currentUrl={talent.renderUrl} label="TALENT RENDER" resetToken={renderResetToken}/>
|
||||
{#each errorsFor('icon') as error}<small class="field-error">{error.message}</small>{/each}
|
||||
</div>
|
||||
<div id="talent-identity" class="talent-identity" class:validation-area-error={hasErrors(['identity'])}>
|
||||
|
||||
@@ -5,19 +5,37 @@ import type { PageServerLoad } from './$types';
|
||||
import { getPagination, getRegistrySort } from '$lib/server/pagination';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
await requirePermission(await getSessionUser(cookies), 'users.view');
|
||||
const query = url.searchParams.get('q')?.trim() ?? '';
|
||||
const pattern = `%${query.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||
const sort = getRegistrySort(url, {
|
||||
user: { asc: 'display_name ASC', desc: 'display_name DESC' },
|
||||
access: { asc: 'access_state ASC', desc: 'access_state DESC' },
|
||||
roles: { asc: 'assigned_roles ASC', desc: 'assigned_roles DESC' },
|
||||
lastLogin: { asc: 'last_login_at ASC', desc: 'last_login_at DESC', defaultDirection: 'desc' },
|
||||
lastActive: { asc: 'last_active_at ASC', desc: 'last_active_at DESC', defaultDirection: 'desc' },
|
||||
content: { asc: 'content_count ASC', desc: 'content_count DESC', defaultDirection: 'desc' }
|
||||
}, 'lastActive', 'desc');
|
||||
const database = await getDatabase();
|
||||
const summaryRows = await (await database.prepare(`
|
||||
await requirePermission(await getSessionUser(cookies), 'users.view');
|
||||
const query = url.searchParams.get('q')?.trim() ?? '';
|
||||
const pattern = `%${query.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||
const sort = getRegistrySort(
|
||||
url,
|
||||
{
|
||||
user: { asc: 'display_name ASC', desc: 'display_name DESC' },
|
||||
access: { asc: 'access_state ASC', desc: 'access_state DESC' },
|
||||
roles: { asc: 'assigned_roles ASC', desc: 'assigned_roles DESC' },
|
||||
lastLogin: {
|
||||
asc: 'last_login_at ASC',
|
||||
desc: 'last_login_at DESC',
|
||||
defaultDirection: 'desc',
|
||||
},
|
||||
lastActive: {
|
||||
asc: 'last_active_at ASC',
|
||||
desc: 'last_active_at DESC',
|
||||
defaultDirection: 'desc',
|
||||
},
|
||||
content: {
|
||||
asc: 'content_count ASC',
|
||||
desc: 'content_count DESC',
|
||||
defaultDirection: 'desc',
|
||||
},
|
||||
},
|
||||
'lastActive',
|
||||
'desc',
|
||||
);
|
||||
const database = await getDatabase();
|
||||
const summaryRows = await (
|
||||
await database.prepare(`
|
||||
SELECT users.id,users.login_username,
|
||||
COALESCE(user_profiles.display_name,users.username) AS display_name,
|
||||
(SELECT GROUP_CONCAT(roles.name, ' · ') FROM user_roles
|
||||
@@ -32,41 +50,88 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||
WHERE users.login_username LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR user_profiles.display_name LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
`)).all(pattern, pattern);
|
||||
const now = Date.now();
|
||||
const state = (user: Record<string, unknown>) => {
|
||||
const activity = user.last_active_at ?? user.last_login_at;
|
||||
const inactive = !activity || now - new Date(String(activity)).getTime() > 30 * 24 * 60 * 60 * 1000;
|
||||
const banned = typeof user.banned_until === 'string' && new Date(user.banned_until) > new Date();
|
||||
const accessState = user.deleted_at ? 'DELETED' : user.blocked_at ? 'BLOCKED' : banned ? 'BANNED' : 'ACTIVE';
|
||||
return { inactive, accessState };
|
||||
};
|
||||
const summaryStates = summaryRows.map((user) => ({ ...user, ...state(user) }));
|
||||
const filter = ['active', 'inactive', 'restricted'].includes(url.searchParams.get('filter') ?? '') ? String(url.searchParams.get('filter')) : '';
|
||||
const filteredStates = summaryStates.filter((user) => filter === 'active' ? user.accessState === 'ACTIVE' && !user.inactive
|
||||
: filter === 'inactive' ? user.inactive : filter === 'restricted' ? user.accessState !== 'ACTIVE' : true);
|
||||
const comparisonValue = (user: Record<string, any>) => {
|
||||
if (sort.key === 'user') return String(user.display_name ?? user.login_username ?? '').toLocaleLowerCase();
|
||||
if (sort.key === 'access') return `${user.accessState}:${user.inactive ? 'inactive' : 'current'}`;
|
||||
if (sort.key === 'roles') return String(user.assigned_roles ?? user.role ?? 'User').toLocaleLowerCase();
|
||||
if (sort.key === 'lastLogin') return user.last_login_at ? new Date(String(user.last_login_at)).getTime() : -1;
|
||||
if (sort.key === 'lastActive') return user.last_active_at ? new Date(String(user.last_active_at)).getTime() : -1;
|
||||
return Number(user.build_count ?? 0) + Number(user.comment_count ?? 0);
|
||||
};
|
||||
const direction = sort.direction === 'asc' ? 1 : -1;
|
||||
filteredStates.sort((left, right) => {
|
||||
const leftValue = comparisonValue(left);
|
||||
const rightValue = comparisonValue(right);
|
||||
const comparison = typeof leftValue === 'number' && typeof rightValue === 'number'
|
||||
? leftValue - rightValue
|
||||
: String(leftValue).localeCompare(String(rightValue));
|
||||
return comparison * direction
|
||||
|| String(left.display_name ?? left.login_username ?? '').localeCompare(String(right.display_name ?? right.login_username ?? ''))
|
||||
|| String(left.id).localeCompare(String(right.id));
|
||||
});
|
||||
const pagination = getPagination(url, filteredStates.length);
|
||||
const pageIds = filteredStates.slice(pagination.offset, pagination.offset + pagination.perPage).map((user) => String(user.id));
|
||||
const userRows = pageIds.length ? await (await database.prepare(`
|
||||
`)
|
||||
).all(pattern, pattern);
|
||||
const now = Date.now();
|
||||
const state = (user: Record<string, unknown>) => {
|
||||
const activity = user.last_active_at ?? user.last_login_at;
|
||||
const inactive =
|
||||
!activity ||
|
||||
now - new Date(String(activity)).getTime() > 30 * 24 * 60 * 60 * 1000;
|
||||
const banned =
|
||||
typeof user.banned_until === 'string' &&
|
||||
new Date(user.banned_until) > new Date();
|
||||
const accessState = user.deleted_at
|
||||
? 'DELETED'
|
||||
: user.blocked_at
|
||||
? 'BLOCKED'
|
||||
: banned
|
||||
? 'BANNED'
|
||||
: 'ACTIVE';
|
||||
return { inactive, accessState };
|
||||
};
|
||||
const summaryStates = summaryRows.map((user) => ({
|
||||
...user,
|
||||
...state(user),
|
||||
}));
|
||||
const filter = ['active', 'inactive', 'restricted'].includes(
|
||||
url.searchParams.get('filter') ?? '',
|
||||
)
|
||||
? String(url.searchParams.get('filter'))
|
||||
: '';
|
||||
const filteredStates = summaryStates.filter((user) =>
|
||||
filter === 'active'
|
||||
? user.accessState === 'ACTIVE' && !user.inactive
|
||||
: filter === 'inactive'
|
||||
? user.inactive
|
||||
: filter === 'restricted'
|
||||
? user.accessState !== 'ACTIVE'
|
||||
: true,
|
||||
);
|
||||
const comparisonValue = (user: Record<string, any>) => {
|
||||
if (sort.key === 'user')
|
||||
return String(
|
||||
user.display_name ?? user.login_username ?? '',
|
||||
).toLocaleLowerCase();
|
||||
if (sort.key === 'access')
|
||||
return `${user.accessState}:${user.inactive ? 'inactive' : 'current'}`;
|
||||
if (sort.key === 'roles')
|
||||
return String(
|
||||
user.assigned_roles ?? user.role ?? 'User',
|
||||
).toLocaleLowerCase();
|
||||
if (sort.key === 'lastLogin')
|
||||
return user.last_login_at
|
||||
? new Date(String(user.last_login_at)).getTime()
|
||||
: -1;
|
||||
if (sort.key === 'lastActive')
|
||||
return user.last_active_at
|
||||
? new Date(String(user.last_active_at)).getTime()
|
||||
: -1;
|
||||
return Number(user.build_count ?? 0) + Number(user.comment_count ?? 0);
|
||||
};
|
||||
const direction = sort.direction === 'asc' ? 1 : -1;
|
||||
filteredStates.sort((left, right) => {
|
||||
const leftValue = comparisonValue(left);
|
||||
const rightValue = comparisonValue(right);
|
||||
const comparison =
|
||||
typeof leftValue === 'number' && typeof rightValue === 'number'
|
||||
? leftValue - rightValue
|
||||
: String(leftValue).localeCompare(String(rightValue));
|
||||
return (
|
||||
comparison * direction ||
|
||||
String(left.display_name ?? left.login_username ?? '').localeCompare(
|
||||
String(right.display_name ?? right.login_username ?? ''),
|
||||
) ||
|
||||
String(left.id).localeCompare(String(right.id))
|
||||
);
|
||||
});
|
||||
const pagination = getPagination(url, filteredStates.length);
|
||||
const pageIds = filteredStates
|
||||
.slice(pagination.offset, pagination.offset + pagination.perPage)
|
||||
.map((user) => String(user.id));
|
||||
const userRows = pageIds.length
|
||||
? await (
|
||||
await database.prepare(`
|
||||
SELECT users.id,users.login_username AS username,COALESCE(user_profiles.display_name,users.username) AS display_name,
|
||||
users.role,users.discord_id,users.created_at,
|
||||
(SELECT GROUP_CONCAT(roles.name, ' · ') FROM user_roles
|
||||
@@ -81,22 +146,27 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id
|
||||
WHERE users.id IN (${pageIds.map(() => '?').join(',')})
|
||||
`)).all(...pageIds) : [];
|
||||
const rowsById = new Map(userRows.map((user) => [String(user.id), user]));
|
||||
const users = pageIds.map((id) => rowsById.get(id)).filter(Boolean);
|
||||
return {
|
||||
users: users.map((user) => {
|
||||
return { ...user, ...state(user) };
|
||||
}),
|
||||
query,
|
||||
filter,
|
||||
sort,
|
||||
summary: {
|
||||
results: summaryRows.length,
|
||||
active: summaryStates.filter((user) => user.accessState === 'ACTIVE' && !user.inactive).length,
|
||||
inactive: summaryStates.filter((user) => user.inactive).length,
|
||||
restricted: summaryStates.filter((user) => user.accessState !== 'ACTIVE').length
|
||||
},
|
||||
pagination
|
||||
};
|
||||
`)
|
||||
).all(...pageIds)
|
||||
: [];
|
||||
const rowsById = new Map(userRows.map((user) => [String(user.id), user]));
|
||||
const users = pageIds.map((id) => rowsById.get(id)).filter(Boolean);
|
||||
return {
|
||||
users: users.map((user) => {
|
||||
return { ...user, ...state(user) };
|
||||
}),
|
||||
query,
|
||||
filter,
|
||||
sort,
|
||||
summary: {
|
||||
results: summaryRows.length,
|
||||
active: summaryStates.filter(
|
||||
(user) => user.accessState === 'ACTIVE' && !user.inactive,
|
||||
).length,
|
||||
inactive: summaryStates.filter((user) => user.inactive).length,
|
||||
restricted: summaryStates.filter((user) => user.accessState !== 'ACTIVE')
|
||||
.length,
|
||||
},
|
||||
pagination,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 { softDeleteAccount } from '$lib/server/auth/account-deletion';
|
||||
@@ -7,46 +10,113 @@ import { randomUUID } from 'node:crypto';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
import { avatarUrl } from '$lib/avatar';
|
||||
import { deleteUploadedAvatar } from '$lib/server/avatars';
|
||||
import { canRemoveAuthenticationMethod, getAuthenticationMethods } from '$lib/server/auth/methods';
|
||||
import {
|
||||
canRemoveAuthenticationMethod,
|
||||
getAuthenticationMethods,
|
||||
} from '$lib/server/auth/methods';
|
||||
import { getPagination } from '$lib/server/pagination';
|
||||
import { purgeExpiredLoginMetadata } from '$lib/server/auth/session';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import { createNotification } from '$lib/server/notifications';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, params, url }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.view');
|
||||
const database = await getDatabase();
|
||||
await purgeExpiredLoginMetadata();
|
||||
const loginQuery = url.searchParams.get('loginQ')?.trim() ?? '';
|
||||
const loginStatus = ['success', 'failed'].includes(url.searchParams.get('loginStatus') ?? '') ? String(url.searchParams.get('loginStatus')) : '';
|
||||
const buildQuery = url.searchParams.get('buildQ')?.trim() ?? '';
|
||||
const buildVisibility = ['draft', 'private', 'public'].includes(url.searchParams.get('buildVisibility') ?? '') ? String(url.searchParams.get('buildVisibility')) : '';
|
||||
const commentQuery = url.searchParams.get('commentQ')?.trim() ?? '';
|
||||
const pattern = (value: string) => `%${value.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||
const loginWhere = `login_events.user_id=? AND (provider LIKE ? ESCAPE '\\' OR COALESCE(ip_address,'') LIKE ? ESCAPE '\\'
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'users.view',
|
||||
);
|
||||
const database = await getDatabase();
|
||||
await purgeExpiredLoginMetadata();
|
||||
const loginQuery = url.searchParams.get('loginQ')?.trim() ?? '';
|
||||
const loginStatus = ['success', 'failed'].includes(
|
||||
url.searchParams.get('loginStatus') ?? '',
|
||||
)
|
||||
? String(url.searchParams.get('loginStatus'))
|
||||
: '';
|
||||
const buildQuery = url.searchParams.get('buildQ')?.trim() ?? '';
|
||||
const buildVisibility = ['draft', 'private', 'public'].includes(
|
||||
url.searchParams.get('buildVisibility') ?? '',
|
||||
)
|
||||
? String(url.searchParams.get('buildVisibility'))
|
||||
: '';
|
||||
const commentQuery = url.searchParams.get('commentQ')?.trim() ?? '';
|
||||
const pattern = (value: string) =>
|
||||
`%${value.replaceAll('%', '\\%').replaceAll('_', '\\_')}%`;
|
||||
const loginWhere = `login_events.user_id=? AND (provider LIKE ? ESCAPE '\\' OR COALESCE(ip_address,'') LIKE ? ESCAPE '\\'
|
||||
OR COALESCE(client_name,'') LIKE ? ESCAPE '\\' OR COALESCE(operating_system,'') LIKE ? ESCAPE '\\'
|
||||
OR COALESCE(failure_reason,'') LIKE ? ESCAPE '\\')${loginStatus ? ' AND succeeded=?' : ''}`;
|
||||
const loginPattern = pattern(loginQuery);
|
||||
const loginArgs: unknown[] = [params.id, loginPattern, loginPattern, loginPattern, loginPattern, loginPattern, ...(loginStatus ? [loginStatus === 'success' ? 1 : 0] : [])];
|
||||
const buildWhere = `owner_id=? AND (title LIKE ? ESCAPE '\\' COLLATE NOCASE OR slug LIKE ? ESCAPE '\\' COLLATE NOCASE)${buildVisibility ? ' AND visibility=?' : ''}`;
|
||||
const buildArgs: unknown[] = [params.id, pattern(buildQuery), pattern(buildQuery), ...(buildVisibility ? [buildVisibility] : [])];
|
||||
const commentWhere = `comments.author_id=? AND (comments.body_markdown LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
const loginPattern = pattern(loginQuery);
|
||||
const loginArgs: unknown[] = [
|
||||
params.id,
|
||||
loginPattern,
|
||||
loginPattern,
|
||||
loginPattern,
|
||||
loginPattern,
|
||||
loginPattern,
|
||||
...(loginStatus ? [loginStatus === 'success' ? 1 : 0] : []),
|
||||
];
|
||||
const buildWhere = `owner_id=? AND (title LIKE ? ESCAPE '\\' COLLATE NOCASE OR slug LIKE ? ESCAPE '\\' COLLATE NOCASE)${buildVisibility ? ' AND visibility=?' : ''}`;
|
||||
const buildArgs: unknown[] = [
|
||||
params.id,
|
||||
pattern(buildQuery),
|
||||
pattern(buildQuery),
|
||||
...(buildVisibility ? [buildVisibility] : []),
|
||||
];
|
||||
const commentWhere = `comments.author_id=? AND (comments.body_markdown LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR COALESCE(builds.title,target_profiles.display_name,'Agent profile') LIKE ? ESCAPE '\\' COLLATE NOCASE)`;
|
||||
const commentArgs: unknown[] = [params.id, pattern(commentQuery), pattern(commentQuery)];
|
||||
const [loginCount, buildCount, commentCount] = await Promise.all([
|
||||
(await database.prepare(`SELECT COUNT(*) AS count FROM login_events WHERE ${loginWhere}`)).get(...loginArgs),
|
||||
(await database.prepare(`SELECT COUNT(*) AS count FROM builds WHERE ${buildWhere}`)).get(...buildArgs),
|
||||
(await database.prepare(`SELECT COUNT(*) AS count FROM comments
|
||||
const commentArgs: unknown[] = [
|
||||
params.id,
|
||||
pattern(commentQuery),
|
||||
pattern(commentQuery),
|
||||
];
|
||||
const [loginCount, buildCount, commentCount] = await Promise.all([
|
||||
(
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM login_events WHERE ${loginWhere}`,
|
||||
)
|
||||
).get(...loginArgs),
|
||||
(
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM builds WHERE ${buildWhere}`,
|
||||
)
|
||||
).get(...buildArgs),
|
||||
(
|
||||
await database.prepare(`SELECT COUNT(*) AS count FROM comments
|
||||
JOIN comment_threads ON comment_threads.id=comments.thread_id
|
||||
LEFT JOIN builds ON comment_threads.target_type IN ('build','guide') AND builds.id=comment_threads.target_id
|
||||
LEFT JOIN user_profiles target_profiles ON comment_threads.target_type='profile' AND target_profiles.user_id=comment_threads.target_id
|
||||
WHERE ${commentWhere}`)).get(...commentArgs)
|
||||
]);
|
||||
const loginPagination = getPagination(url, Number(loginCount?.count ?? 0), 'login');
|
||||
const buildPagination = getPagination(url, Number(buildCount?.count ?? 0), 'build');
|
||||
const commentPagination = getPagination(url, Number(commentCount?.count ?? 0), 'comment');
|
||||
const [profile, roles, permissions, assignedRoles, directPermissions, logins, builds, comments, authenticationMethods, emails, passkeys] = await Promise.all([
|
||||
(await database.prepare(`SELECT users.id,users.login_username AS username,COALESCE(user_profiles.display_name,users.username) AS display_name,
|
||||
WHERE ${commentWhere}`)
|
||||
).get(...commentArgs),
|
||||
]);
|
||||
const loginPagination = getPagination(
|
||||
url,
|
||||
Number(loginCount?.count ?? 0),
|
||||
'login',
|
||||
);
|
||||
const buildPagination = getPagination(
|
||||
url,
|
||||
Number(buildCount?.count ?? 0),
|
||||
'build',
|
||||
);
|
||||
const commentPagination = getPagination(
|
||||
url,
|
||||
Number(commentCount?.count ?? 0),
|
||||
'comment',
|
||||
);
|
||||
const [
|
||||
profile,
|
||||
roles,
|
||||
permissions,
|
||||
assignedRoles,
|
||||
directPermissions,
|
||||
logins,
|
||||
builds,
|
||||
comments,
|
||||
authenticationMethods,
|
||||
emails,
|
||||
passkeys,
|
||||
] = await Promise.all([
|
||||
(
|
||||
await database.prepare(`SELECT users.id,users.login_username AS username,COALESCE(user_profiles.display_name,users.username) AS display_name,
|
||||
users.role,users.discord_id,users.created_at,
|
||||
CASE WHEN users.password_hash IS NOT NULL THEN 1 ELSE 0 END AS has_password,
|
||||
discord_profiles.username AS discord_username,discord_profiles.display_name AS discord_display_name,
|
||||
@@ -58,190 +128,521 @@ export const load: PageServerLoad = async ({ cookies, params, url }) => {
|
||||
LEFT JOIN user_profiles ON user_profiles.user_id=users.id
|
||||
LEFT JOIN user_avatars ON user_avatars.user_id=users.id
|
||||
LEFT JOIN discord_profiles ON discord_profiles.user_id=users.id
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id WHERE users.id=?`)).get(params.id),
|
||||
(await database.prepare('SELECT * FROM roles ORDER BY name')).all(),
|
||||
(await database.prepare('SELECT * FROM permissions ORDER BY name')).all(),
|
||||
(await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')).all(params.id),
|
||||
(await database.prepare('SELECT permission_id FROM user_permissions WHERE user_id=?')).all(params.id),
|
||||
(await database.prepare(`SELECT login_events.provider,login_events.succeeded,login_events.created_at,
|
||||
LEFT JOIN deleted_accounts ON deleted_accounts.user_id=users.id WHERE users.id=?`)
|
||||
).get(params.id),
|
||||
(await database.prepare('SELECT * FROM roles ORDER BY name')).all(),
|
||||
(await database.prepare('SELECT * FROM permissions ORDER BY name')).all(),
|
||||
(
|
||||
await database.prepare('SELECT role_id FROM user_roles WHERE user_id=?')
|
||||
).all(params.id),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT permission_id FROM user_permissions WHERE user_id=?',
|
||||
)
|
||||
).all(params.id),
|
||||
(
|
||||
await database.prepare(`SELECT login_events.provider,login_events.succeeded,login_events.created_at,
|
||||
login_events.ip_address,login_events.user_agent,login_events.client_name,login_events.operating_system,
|
||||
login_events.device_type,login_events.location_country,login_events.location_region,login_events.location_city,
|
||||
login_events.failure_reason,login_events.risk_flags,login_events.credential_id,login_events.session_id,
|
||||
login_events.session_ended_at,login_events.session_end_reason,login_events.sensitive_purged_at,
|
||||
sessions.expires_at AS session_expires_at
|
||||
FROM login_events LEFT JOIN sessions ON sessions.id=login_events.session_id
|
||||
WHERE ${loginWhere} ORDER BY login_events.created_at DESC LIMIT ? OFFSET ?`)).all(...loginArgs, loginPagination.perPage, loginPagination.offset),
|
||||
(await database.prepare(`SELECT id,title,slug,visibility,updated_at FROM builds WHERE ${buildWhere} ORDER BY updated_at DESC LIMIT ? OFFSET ?`)).all(...buildArgs, buildPagination.perPage, buildPagination.offset),
|
||||
(await database.prepare(`SELECT comments.id,comments.body_markdown AS body,comments.created_at,
|
||||
WHERE ${loginWhere} ORDER BY login_events.created_at DESC LIMIT ? OFFSET ?`)
|
||||
).all(...loginArgs, loginPagination.perPage, loginPagination.offset),
|
||||
(
|
||||
await database.prepare(
|
||||
`SELECT id,title,slug,visibility,updated_at FROM builds WHERE ${buildWhere} ORDER BY updated_at DESC LIMIT ? OFFSET ?`,
|
||||
)
|
||||
).all(...buildArgs, buildPagination.perPage, buildPagination.offset),
|
||||
(
|
||||
await database.prepare(`SELECT comments.id,comments.body_markdown AS body,comments.created_at,
|
||||
COALESCE(builds.title,target_profiles.display_name,'Agent profile') AS title,builds.slug
|
||||
FROM comments
|
||||
JOIN comment_threads ON comment_threads.id=comments.thread_id
|
||||
LEFT JOIN builds ON comment_threads.target_type IN ('build','guide') AND builds.id=comment_threads.target_id
|
||||
LEFT JOIN user_profiles target_profiles ON comment_threads.target_type='profile' AND target_profiles.user_id=comment_threads.target_id
|
||||
WHERE ${commentWhere} ORDER BY comments.created_at DESC LIMIT ? OFFSET ?`)).all(...commentArgs, commentPagination.perPage, commentPagination.offset)
|
||||
,getAuthenticationMethods(database, params.id)
|
||||
,(await database.prepare('SELECT email,verified_at,updated_at FROM user_emails WHERE user_id=?')).all(params.id)
|
||||
,(await database.prepare('SELECT id,name,device_type,backed_up,last_used_at,created_at FROM passkeys WHERE user_id=? ORDER BY created_at DESC')).all(params.id)
|
||||
]);
|
||||
if (!profile) error(404, 'User not found');
|
||||
return {
|
||||
profile: {
|
||||
...profile,
|
||||
avatar_url: avatarUrl(profile.avatar_source, profile.avatar_value, String(profile.id), profile.avatar_updated_at)
|
||||
},
|
||||
roles,
|
||||
permissions,
|
||||
assignedRoleIds: assignedRoles.map((row) => String(row.role_id)),
|
||||
directPermissionIds: directPermissions.map((row) => String(row.permission_id)),
|
||||
logins, builds, comments, emails, passkeys,
|
||||
historyFilters: { loginQuery, loginStatus, buildQuery, buildVisibility, commentQuery },
|
||||
historyPagination: { login: loginPagination, build: buildPagination, comment: commentPagination },
|
||||
authenticationMethods,
|
||||
operatorPermissions: await getPermissions(operator),
|
||||
isSelf: operator.id === params.id
|
||||
};
|
||||
WHERE ${commentWhere} ORDER BY comments.created_at DESC LIMIT ? OFFSET ?`)
|
||||
).all(...commentArgs, commentPagination.perPage, commentPagination.offset),
|
||||
getAuthenticationMethods(database, params.id),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT email,verified_at,updated_at FROM user_emails WHERE user_id=?',
|
||||
)
|
||||
).all(params.id),
|
||||
(
|
||||
await database.prepare(
|
||||
'SELECT id,name,device_type,backed_up,last_used_at,created_at FROM passkeys WHERE user_id=? ORDER BY created_at DESC',
|
||||
)
|
||||
).all(params.id),
|
||||
]);
|
||||
if (!profile) error(404, 'User not found');
|
||||
return {
|
||||
profile: {
|
||||
...profile,
|
||||
avatar_url: avatarUrl(
|
||||
profile.avatar_source,
|
||||
profile.avatar_value,
|
||||
String(profile.id),
|
||||
profile.avatar_updated_at,
|
||||
),
|
||||
},
|
||||
roles,
|
||||
permissions,
|
||||
assignedRoleIds: assignedRoles.map((row) => String(row.role_id)),
|
||||
directPermissionIds: directPermissions.map((row) =>
|
||||
String(row.permission_id),
|
||||
),
|
||||
logins,
|
||||
builds,
|
||||
comments,
|
||||
emails,
|
||||
passkeys,
|
||||
historyFilters: {
|
||||
loginQuery,
|
||||
loginStatus,
|
||||
buildQuery,
|
||||
buildVisibility,
|
||||
commentQuery,
|
||||
},
|
||||
historyPagination: {
|
||||
login: loginPagination,
|
||||
build: buildPagination,
|
||||
comment: commentPagination,
|
||||
},
|
||||
authenticationMethods,
|
||||
operatorPermissions: await getPermissions(operator),
|
||||
isSelf: operator.id === params.id,
|
||||
};
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
removeCustomAvatar: async ({ cookies, params, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'avatars.review');
|
||||
const reason = String((await request.formData()).get('reason') ?? '').trim().slice(0, 500);
|
||||
if (reason.length < 3) return fail(400, { avatarModerationError: 'Provide a reason for removing this avatar.' });
|
||||
const database = await getDatabase();
|
||||
const avatar = await (await database.prepare(
|
||||
'SELECT source,value,byte_size,updated_at FROM user_avatars WHERE user_id=?'
|
||||
)).get(params.id);
|
||||
if (avatar?.source !== 'upload') {
|
||||
return fail(409, { avatarModerationError: 'This agent does not currently use a custom avatar.' });
|
||||
}
|
||||
await deleteUploadedAvatar(database, params.id);
|
||||
await (await database.prepare(`UPDATE avatar_reviews SET status='removed',reviewed_at=?,reviewed_by=?,review_note=?
|
||||
WHERE user_id=? AND avatar_updated_at=? AND status IN ('pending','approved')`))
|
||||
.run(new Date().toISOString(), operator.id, reason, params.id, avatar.updated_at);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'user_moderation',
|
||||
action: 'avatar.removed',
|
||||
entityId: params.id,
|
||||
before: { source: avatar.source, storageKey: avatar.value, byteSize: avatar.byte_size },
|
||||
after: { source: 'default', reviewSource: 'agent_operations', reason }
|
||||
});
|
||||
await createNotification({ userId: params.id, category: 'isac', source: 'ISAC USER OPERATIONS', title: 'Custom avatar removed', body: `Your custom avatar was removed by Oversight. Reason: ${reason}`, link: '/account#avatar', mandatory: true });
|
||||
return { avatarRemoved: true };
|
||||
},
|
||||
setAccess: async ({ cookies, request, params }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.roles');
|
||||
const form = await request.formData();
|
||||
const roleIds = [...new Set(form.getAll('roleIds').map(String))];
|
||||
const permissionIds = [...new Set(form.getAll('permissionIds').map(String))];
|
||||
const database = await getDatabase();
|
||||
const target = await (await database.prepare('SELECT role FROM users WHERE id=?')).get(params.id);
|
||||
if (!target) return fail(404, { accessError: 'User not found.' });
|
||||
const operatorPermissions = await getPermissions(operator);
|
||||
const targetAdminRole = await (await database.prepare(`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`)).get(params.id);
|
||||
const changesAdminRole = roleIds.includes('role-admin') !== Boolean(targetAdminRole);
|
||||
if ((target.role === 'admin' || changesAdminRole) && !operatorPermissions.includes('*')) {
|
||||
return fail(403, { accessError: 'Only an administrator can grant, remove, or modify administrator access.' });
|
||||
}
|
||||
const validRoles = roleIds.length ? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM roles WHERE id IN (${roleIds.map(() => '?').join(',')})`)).get(...roleIds))?.count) : 0;
|
||||
const validPermissions = permissionIds.length ? Number((await (await database.prepare(`SELECT COUNT(*) AS count FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`)).get(...permissionIds))?.count) : 0;
|
||||
if (validRoles !== roleIds.length || validPermissions !== permissionIds.length) return fail(400, { accessError: 'One or more access selections are invalid.' });
|
||||
const before = {
|
||||
roleIds: (await (await database.prepare('SELECT role_id FROM user_roles WHERE user_id=? ORDER BY role_id')).all(params.id)).map((row) => row.role_id),
|
||||
permissionIds: (await (await database.prepare('SELECT permission_id FROM user_permissions WHERE user_id=? ORDER BY permission_id')).all(params.id)).map((row) => row.permission_id)
|
||||
};
|
||||
await (await database.prepare('DELETE FROM user_roles WHERE user_id=?')).run(params.id);
|
||||
await (await database.prepare('DELETE FROM user_permissions WHERE user_id=?')).run(params.id);
|
||||
for (const roleId of roleIds) await (await database.prepare('INSERT INTO user_roles(user_id,role_id) VALUES(?,?)')).run(params.id, roleId);
|
||||
for (const permissionId of permissionIds) await (await database.prepare('INSERT INTO user_permissions(user_id,permission_id) VALUES(?,?)')).run(params.id, permissionId);
|
||||
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)'))
|
||||
.run(randomUUID(), operator.id, 'user_access', params.id, 'access.updated', JSON.stringify(before), JSON.stringify({ roleIds, permissionIds }), new Date().toISOString());
|
||||
return { accessSaved: true };
|
||||
},
|
||||
moderate: async ({ cookies, request, params }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.moderate');
|
||||
if (operator.id === params.id) return fail(400, { moderationError: 'You cannot restrict your own account.' });
|
||||
const form = await request.formData(), state = String(form.get('state') ?? ''), reason = String(form.get('reason') ?? '').trim();
|
||||
if (!['active', 'blocked', 'banned'].includes(state)) return fail(400, { moderationError: 'Choose a valid access state.' });
|
||||
if (state !== 'active' && reason.length < 3) return fail(400, { moderationError: 'Provide a moderation reason.' });
|
||||
const untilRaw = String(form.get('bannedUntil') ?? '');
|
||||
const bannedUntil = state === 'banned' ? new Date(untilRaw) : null;
|
||||
if (state === 'banned' && (!untilRaw || Number.isNaN(bannedUntil?.getTime()) || bannedUntil! <= new Date())) return fail(400, { moderationError: 'Choose a future ban expiration.' });
|
||||
const now = new Date().toISOString(), database = await getDatabase();
|
||||
const target = await (await database.prepare('SELECT role FROM users WHERE id=?')).get(params.id);
|
||||
if (!target) return fail(404, { moderationError: 'User not found.' });
|
||||
const targetAdminRole = await (await database.prepare(`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`)).get(params.id);
|
||||
if ((target.role === 'admin' || targetAdminRole) && !(await getPermissions(operator)).includes('*')) {
|
||||
return fail(403, { moderationError: 'Only an administrator can moderate another administrator.' });
|
||||
}
|
||||
const before = await (await database.prepare('SELECT blocked_at,blocked_by,banned_until,moderation_reason FROM user_status WHERE user_id=?')).get(params.id);
|
||||
await (await database.prepare(`INSERT INTO user_status(user_id,blocked_at,blocked_by,banned_until,moderation_reason,updated_at) VALUES(?,?,?,?,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET blocked_at=excluded.blocked_at,blocked_by=excluded.blocked_by,banned_until=excluded.banned_until,moderation_reason=excluded.moderation_reason,updated_at=excluded.updated_at`))
|
||||
.run(params.id, state === 'blocked' ? now : null, state === 'active' ? null : operator.id, state === 'banned' ? bannedUntil!.toISOString() : null, state === 'active' ? null : reason, now);
|
||||
if (state !== 'active') await (await database.prepare('DELETE FROM sessions WHERE user_id=?')).run(params.id);
|
||||
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)'))
|
||||
.run(randomUUID(), operator.id, 'user_moderation', params.id, `moderation.${state}`, JSON.stringify(before ?? null), JSON.stringify({ reason, bannedUntil: bannedUntil?.toISOString() }), now);
|
||||
return { moderationSaved: true };
|
||||
},
|
||||
deleteAccount: async ({ cookies, request, params }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.delete');
|
||||
if (operator.id === params.id) return fail(400, { adminDeleteError: 'Delete your own account from the agent profile page.' });
|
||||
const reason = String((await request.formData()).get('reason') ?? '').trim();
|
||||
if (reason.length < 3) return fail(400, { adminDeleteError: 'Provide a deletion reason.' });
|
||||
const database = await getDatabase();
|
||||
const target = await (await database.prepare('SELECT role FROM users WHERE id=?')).get(params.id);
|
||||
if (!target) return fail(404, { adminDeleteError: 'User not found.' });
|
||||
const targetAdminRole = await (await database.prepare(`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`)).get(params.id);
|
||||
if ((target.role === 'admin' || targetAdminRole) && !(await getPermissions(operator)).includes('*')) {
|
||||
return fail(403, { adminDeleteError: 'Only an administrator can delete another administrator.' });
|
||||
}
|
||||
if (!(await softDeleteAccount(database, params.id, operator.id, reason))) {
|
||||
return fail(409, { adminDeleteError: 'This account has already been deleted.' });
|
||||
}
|
||||
return { accountDeleted: true };
|
||||
},
|
||||
unlinkDiscord: async ({ cookies, params }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.identities');
|
||||
const database = await getDatabase();
|
||||
const target = await (await database.prepare('SELECT discord_id FROM users WHERE id=?')).get(params.id);
|
||||
if (!target) return fail(404, { identityError: 'User not found.' });
|
||||
if (!target.discord_id) return fail(409, { identityError: 'This user does not have a linked Discord account.' });
|
||||
if (!canRemoveAuthenticationMethod(await getAuthenticationMethods(database, params.id), 'discord')) {
|
||||
return fail(400, { identityError: 'Discord cannot be unlinked because it is this user’s only sign-in method.' });
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
await (await database.prepare('UPDATE users SET discord_id=NULL WHERE id=?')).run(params.id);
|
||||
await (await database.prepare('DELETE FROM discord_profiles WHERE user_id=?')).run(params.id);
|
||||
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)'))
|
||||
.run(randomUUID(), operator.id, 'identity', params.id, 'identity.discord_unlinked', JSON.stringify({ discordId: target.discord_id }), now);
|
||||
return { identityUnlinked: true };
|
||||
},
|
||||
removeEmail: async ({ cookies, params }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.identities');
|
||||
const database = await getDatabase();
|
||||
const email = await (await database.prepare('SELECT email FROM user_emails WHERE user_id=?')).get(params.id);
|
||||
if (!email) return fail(409, { identityError: 'This user does not have a verified email sign-in.' });
|
||||
if (!canRemoveAuthenticationMethod(await getAuthenticationMethods(database, params.id), 'email')) {
|
||||
return fail(400, { identityError: 'Email cannot be removed because it is this user’s only sign-in method.' });
|
||||
}
|
||||
await (await database.prepare('DELETE FROM user_emails WHERE user_id=?')).run(params.id);
|
||||
await (await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')).run(params.id);
|
||||
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)'))
|
||||
.run(randomUUID(), operator.id, 'identity', params.id, 'identity.email_removed', JSON.stringify({ email: email.email }), new Date().toISOString());
|
||||
return { emailRemoved: true };
|
||||
},
|
||||
removePasskey: async ({ cookies, request, params }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'users.identities');
|
||||
const passkeyId = String((await request.formData()).get('passkeyId') ?? '');
|
||||
const database = await getDatabase();
|
||||
const passkey = await (await database.prepare('SELECT id,name FROM passkeys WHERE id=? AND user_id=?')).get(passkeyId, params.id);
|
||||
if (!passkey) return fail(404, { identityError: 'That passkey was not found for this user.' });
|
||||
if (!canRemoveAuthenticationMethod(await getAuthenticationMethods(database, params.id), 'passkey')) {
|
||||
return fail(400, { identityError: 'This passkey cannot be removed because it is the user’s only sign-in method.' });
|
||||
}
|
||||
await (await database.prepare('DELETE FROM passkeys WHERE id=? AND user_id=?')).run(passkeyId, params.id);
|
||||
await (await database.prepare('INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)'))
|
||||
.run(randomUUID(), operator.id, 'identity', params.id, 'identity.passkey_removed', JSON.stringify({ id: passkey.id, name: passkey.name }), new Date().toISOString());
|
||||
return { passkeyRemoved: true };
|
||||
}
|
||||
removeCustomAvatar: async ({ cookies, params, request }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'avatars.review',
|
||||
);
|
||||
const reason = String((await request.formData()).get('reason') ?? '')
|
||||
.trim()
|
||||
.slice(0, 500);
|
||||
if (reason.length < 3)
|
||||
return fail(400, {
|
||||
avatarModerationError: 'Provide a reason for removing this avatar.',
|
||||
});
|
||||
const database = await getDatabase();
|
||||
const avatar = await (
|
||||
await database.prepare(
|
||||
'SELECT source,value,byte_size,updated_at FROM user_avatars WHERE user_id=?',
|
||||
)
|
||||
).get(params.id);
|
||||
if (avatar?.source !== 'upload') {
|
||||
return fail(409, {
|
||||
avatarModerationError:
|
||||
'This agent does not currently use a custom avatar.',
|
||||
});
|
||||
}
|
||||
await deleteUploadedAvatar(database, params.id);
|
||||
await (
|
||||
await database.prepare(`UPDATE avatar_reviews SET status='removed',reviewed_at=?,reviewed_by=?,review_note=?
|
||||
WHERE user_id=? AND avatar_updated_at=? AND status IN ('pending','approved')`)
|
||||
).run(
|
||||
new Date().toISOString(),
|
||||
operator.id,
|
||||
reason,
|
||||
params.id,
|
||||
avatar.updated_at,
|
||||
);
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'user_moderation',
|
||||
action: 'avatar.removed',
|
||||
entityId: params.id,
|
||||
before: {
|
||||
source: avatar.source,
|
||||
storageKey: avatar.value,
|
||||
byteSize: avatar.byte_size,
|
||||
},
|
||||
after: { source: 'default', reviewSource: 'agent_operations', reason },
|
||||
});
|
||||
await createNotification({
|
||||
userId: params.id,
|
||||
category: 'isac',
|
||||
source: 'ISAC USER OPERATIONS',
|
||||
title: 'Custom avatar removed',
|
||||
body: `Your custom avatar was removed by Oversight. Reason: ${reason}`,
|
||||
link: '/account#avatar',
|
||||
mandatory: true,
|
||||
});
|
||||
return { avatarRemoved: true };
|
||||
},
|
||||
setAccess: async ({ cookies, request, params }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'users.roles',
|
||||
);
|
||||
const form = await request.formData();
|
||||
const roleIds = [...new Set(form.getAll('roleIds').map(String))];
|
||||
const permissionIds = [
|
||||
...new Set(form.getAll('permissionIds').map(String)),
|
||||
];
|
||||
const database = await getDatabase();
|
||||
const target = await (
|
||||
await database.prepare('SELECT role FROM users WHERE id=?')
|
||||
).get(params.id);
|
||||
if (!target) return fail(404, { accessError: 'User not found.' });
|
||||
const operatorPermissions = await getPermissions(operator);
|
||||
const targetAdminRole = await (
|
||||
await database.prepare(
|
||||
`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`,
|
||||
)
|
||||
).get(params.id);
|
||||
const changesAdminRole =
|
||||
roleIds.includes('role-admin') !== Boolean(targetAdminRole);
|
||||
if (
|
||||
(target.role === 'admin' || changesAdminRole) &&
|
||||
!operatorPermissions.includes('*')
|
||||
) {
|
||||
return fail(403, {
|
||||
accessError:
|
||||
'Only an administrator can grant, remove, or modify administrator access.',
|
||||
});
|
||||
}
|
||||
const validRoles = roleIds.length
|
||||
? Number(
|
||||
(
|
||||
await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM roles WHERE id IN (${roleIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).get(...roleIds)
|
||||
)?.count,
|
||||
)
|
||||
: 0;
|
||||
const validPermissions = permissionIds.length
|
||||
? Number(
|
||||
(
|
||||
await (
|
||||
await database.prepare(
|
||||
`SELECT COUNT(*) AS count FROM permissions WHERE id IN (${permissionIds.map(() => '?').join(',')})`,
|
||||
)
|
||||
).get(...permissionIds)
|
||||
)?.count,
|
||||
)
|
||||
: 0;
|
||||
if (
|
||||
validRoles !== roleIds.length ||
|
||||
validPermissions !== permissionIds.length
|
||||
)
|
||||
return fail(400, {
|
||||
accessError: 'One or more access selections are invalid.',
|
||||
});
|
||||
const before = {
|
||||
roleIds: (
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT role_id FROM user_roles WHERE user_id=? ORDER BY role_id',
|
||||
)
|
||||
).all(params.id)
|
||||
).map((row) => row.role_id),
|
||||
permissionIds: (
|
||||
await (
|
||||
await database.prepare(
|
||||
'SELECT permission_id FROM user_permissions WHERE user_id=? ORDER BY permission_id',
|
||||
)
|
||||
).all(params.id)
|
||||
).map((row) => row.permission_id),
|
||||
};
|
||||
await (
|
||||
await database.prepare('DELETE FROM user_roles WHERE user_id=?')
|
||||
).run(params.id);
|
||||
await (
|
||||
await database.prepare('DELETE FROM user_permissions WHERE user_id=?')
|
||||
).run(params.id);
|
||||
for (const roleId of roleIds)
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO user_roles(user_id,role_id) VALUES(?,?)',
|
||||
)
|
||||
).run(params.id, roleId);
|
||||
for (const permissionId of permissionIds)
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO user_permissions(user_id,permission_id) VALUES(?,?)',
|
||||
)
|
||||
).run(params.id, permissionId);
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)',
|
||||
)
|
||||
).run(
|
||||
randomUUID(),
|
||||
operator.id,
|
||||
'user_access',
|
||||
params.id,
|
||||
'access.updated',
|
||||
JSON.stringify(before),
|
||||
JSON.stringify({ roleIds, permissionIds }),
|
||||
new Date().toISOString(),
|
||||
);
|
||||
return { accessSaved: true };
|
||||
},
|
||||
moderate: async ({ cookies, request, params }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'users.moderate',
|
||||
);
|
||||
if (operator.id === params.id)
|
||||
return fail(400, {
|
||||
moderationError: 'You cannot restrict your own account.',
|
||||
});
|
||||
const form = await request.formData(),
|
||||
state = String(form.get('state') ?? ''),
|
||||
reason = String(form.get('reason') ?? '').trim();
|
||||
if (!['active', 'blocked', 'banned'].includes(state))
|
||||
return fail(400, { moderationError: 'Choose a valid access state.' });
|
||||
if (state !== 'active' && reason.length < 3)
|
||||
return fail(400, { moderationError: 'Provide a moderation reason.' });
|
||||
const untilRaw = String(form.get('bannedUntil') ?? '');
|
||||
const bannedUntil = state === 'banned' ? new Date(untilRaw) : null;
|
||||
if (
|
||||
state === 'banned' &&
|
||||
(!untilRaw ||
|
||||
Number.isNaN(bannedUntil?.getTime()) ||
|
||||
bannedUntil! <= new Date())
|
||||
)
|
||||
return fail(400, { moderationError: 'Choose a future ban expiration.' });
|
||||
const now = new Date().toISOString(),
|
||||
database = await getDatabase();
|
||||
const target = await (
|
||||
await database.prepare('SELECT role FROM users WHERE id=?')
|
||||
).get(params.id);
|
||||
if (!target) return fail(404, { moderationError: 'User not found.' });
|
||||
const targetAdminRole = await (
|
||||
await database.prepare(
|
||||
`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`,
|
||||
)
|
||||
).get(params.id);
|
||||
if (
|
||||
(target.role === 'admin' || targetAdminRole) &&
|
||||
!(await getPermissions(operator)).includes('*')
|
||||
) {
|
||||
return fail(403, {
|
||||
moderationError:
|
||||
'Only an administrator can moderate another administrator.',
|
||||
});
|
||||
}
|
||||
const before = await (
|
||||
await database.prepare(
|
||||
'SELECT blocked_at,blocked_by,banned_until,moderation_reason FROM user_status WHERE user_id=?',
|
||||
)
|
||||
).get(params.id);
|
||||
await (
|
||||
await database.prepare(`INSERT INTO user_status(user_id,blocked_at,blocked_by,banned_until,moderation_reason,updated_at) VALUES(?,?,?,?,?,?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET blocked_at=excluded.blocked_at,blocked_by=excluded.blocked_by,banned_until=excluded.banned_until,moderation_reason=excluded.moderation_reason,updated_at=excluded.updated_at`)
|
||||
).run(
|
||||
params.id,
|
||||
state === 'blocked' ? now : null,
|
||||
state === 'active' ? null : operator.id,
|
||||
state === 'banned' ? bannedUntil!.toISOString() : null,
|
||||
state === 'active' ? null : reason,
|
||||
now,
|
||||
);
|
||||
if (state !== 'active')
|
||||
await (
|
||||
await database.prepare('DELETE FROM sessions WHERE user_id=?')
|
||||
).run(params.id);
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,after_json,created_at) VALUES(?,?,?,?,?,?,?,?)',
|
||||
)
|
||||
).run(
|
||||
randomUUID(),
|
||||
operator.id,
|
||||
'user_moderation',
|
||||
params.id,
|
||||
`moderation.${state}`,
|
||||
JSON.stringify(before ?? null),
|
||||
JSON.stringify({ reason, bannedUntil: bannedUntil?.toISOString() }),
|
||||
now,
|
||||
);
|
||||
return { moderationSaved: true };
|
||||
},
|
||||
deleteAccount: async ({ cookies, request, params }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'users.delete',
|
||||
);
|
||||
if (operator.id === params.id)
|
||||
return fail(400, {
|
||||
adminDeleteError:
|
||||
'Delete your own account from the agent profile page.',
|
||||
});
|
||||
const reason = String(
|
||||
(await request.formData()).get('reason') ?? '',
|
||||
).trim();
|
||||
if (reason.length < 3)
|
||||
return fail(400, { adminDeleteError: 'Provide a deletion reason.' });
|
||||
const database = await getDatabase();
|
||||
const target = await (
|
||||
await database.prepare('SELECT role FROM users WHERE id=?')
|
||||
).get(params.id);
|
||||
if (!target) return fail(404, { adminDeleteError: 'User not found.' });
|
||||
const targetAdminRole = await (
|
||||
await database.prepare(
|
||||
`SELECT 1 FROM user_roles WHERE user_id=? AND role_id='role-admin'`,
|
||||
)
|
||||
).get(params.id);
|
||||
if (
|
||||
(target.role === 'admin' || targetAdminRole) &&
|
||||
!(await getPermissions(operator)).includes('*')
|
||||
) {
|
||||
return fail(403, {
|
||||
adminDeleteError:
|
||||
'Only an administrator can delete another administrator.',
|
||||
});
|
||||
}
|
||||
if (!(await softDeleteAccount(database, params.id, operator.id, reason))) {
|
||||
return fail(409, {
|
||||
adminDeleteError: 'This account has already been deleted.',
|
||||
});
|
||||
}
|
||||
return { accountDeleted: true };
|
||||
},
|
||||
unlinkDiscord: async ({ cookies, params }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'users.identities',
|
||||
);
|
||||
const database = await getDatabase();
|
||||
const target = await (
|
||||
await database.prepare('SELECT discord_id FROM users WHERE id=?')
|
||||
).get(params.id);
|
||||
if (!target) return fail(404, { identityError: 'User not found.' });
|
||||
if (!target.discord_id)
|
||||
return fail(409, {
|
||||
identityError: 'This user does not have a linked Discord account.',
|
||||
});
|
||||
if (
|
||||
!canRemoveAuthenticationMethod(
|
||||
await getAuthenticationMethods(database, params.id),
|
||||
'discord',
|
||||
)
|
||||
) {
|
||||
return fail(400, {
|
||||
identityError:
|
||||
'Discord cannot be unlinked because it is this user’s only sign-in method.',
|
||||
});
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
await (
|
||||
await database.prepare('UPDATE users SET discord_id=NULL WHERE id=?')
|
||||
).run(params.id);
|
||||
await (
|
||||
await database.prepare('DELETE FROM discord_profiles WHERE user_id=?')
|
||||
).run(params.id);
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)',
|
||||
)
|
||||
).run(
|
||||
randomUUID(),
|
||||
operator.id,
|
||||
'identity',
|
||||
params.id,
|
||||
'identity.discord_unlinked',
|
||||
JSON.stringify({ discordId: target.discord_id }),
|
||||
now,
|
||||
);
|
||||
return { identityUnlinked: true };
|
||||
},
|
||||
removeEmail: async ({ cookies, params }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'users.identities',
|
||||
);
|
||||
const database = await getDatabase();
|
||||
const email = await (
|
||||
await database.prepare('SELECT email FROM user_emails WHERE user_id=?')
|
||||
).get(params.id);
|
||||
if (!email)
|
||||
return fail(409, {
|
||||
identityError: 'This user does not have a verified email sign-in.',
|
||||
});
|
||||
if (
|
||||
!canRemoveAuthenticationMethod(
|
||||
await getAuthenticationMethods(database, params.id),
|
||||
'email',
|
||||
)
|
||||
) {
|
||||
return fail(400, {
|
||||
identityError:
|
||||
'Email cannot be removed because it is this user’s only sign-in method.',
|
||||
});
|
||||
}
|
||||
await (
|
||||
await database.prepare('DELETE FROM user_emails WHERE user_id=?')
|
||||
).run(params.id);
|
||||
await (
|
||||
await database.prepare('DELETE FROM magic_link_tokens WHERE user_id=?')
|
||||
).run(params.id);
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)',
|
||||
)
|
||||
).run(
|
||||
randomUUID(),
|
||||
operator.id,
|
||||
'identity',
|
||||
params.id,
|
||||
'identity.email_removed',
|
||||
JSON.stringify({ email: email.email }),
|
||||
new Date().toISOString(),
|
||||
);
|
||||
return { emailRemoved: true };
|
||||
},
|
||||
removePasskey: async ({ cookies, request, params }) => {
|
||||
const operator = await requirePermission(
|
||||
await getSessionUser(cookies),
|
||||
'users.identities',
|
||||
);
|
||||
const passkeyId = String((await request.formData()).get('passkeyId') ?? '');
|
||||
const database = await getDatabase();
|
||||
const passkey = await (
|
||||
await database.prepare(
|
||||
'SELECT id,name FROM passkeys WHERE id=? AND user_id=?',
|
||||
)
|
||||
).get(passkeyId, params.id);
|
||||
if (!passkey)
|
||||
return fail(404, {
|
||||
identityError: 'That passkey was not found for this user.',
|
||||
});
|
||||
if (
|
||||
!canRemoveAuthenticationMethod(
|
||||
await getAuthenticationMethods(database, params.id),
|
||||
'passkey',
|
||||
)
|
||||
) {
|
||||
return fail(400, {
|
||||
identityError:
|
||||
'This passkey cannot be removed because it is the user’s only sign-in method.',
|
||||
});
|
||||
}
|
||||
await (
|
||||
await database.prepare('DELETE FROM passkeys WHERE id=? AND user_id=?')
|
||||
).run(passkeyId, params.id);
|
||||
await (
|
||||
await database.prepare(
|
||||
'INSERT INTO audit_log(id,actor_id,entity_type,entity_id,action,before_json,created_at) VALUES(?,?,?,?,?,?,?)',
|
||||
)
|
||||
).run(
|
||||
randomUUID(),
|
||||
operator.id,
|
||||
'identity',
|
||||
params.id,
|
||||
'identity.passkey_removed',
|
||||
JSON.stringify({ id: passkey.id, name: passkey.name }),
|
||||
new Date().toISOString(),
|
||||
);
|
||||
return { passkeyRemoved: true };
|
||||
},
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,7 @@
|
||||
import SearchableAssignment from '$lib/components/SearchableAssignment.svelte';
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import GameIcon from '$lib/components/GameIcon.svelte';
|
||||
import GameDataRenderUploadField from '$lib/components/GameDataRenderUploadField.svelte';
|
||||
import type { GameIconName } from '$lib/game-icons';
|
||||
|
||||
let { data, form } = $props();
|
||||
@@ -18,7 +19,7 @@
|
||||
const blankEffect = () => ({ statId: '', values: Object.fromEntries(data.qualities.map((quality: string) => [quality, 0])) });
|
||||
const blankExoticAttribute = () => ({ statId:'',level1Value:0,level20Value:0 });
|
||||
const blank = () => ({
|
||||
id: '', name: '', active: 1, slot_type: '', exotic_weapon_id: '',
|
||||
id: '', name: '', active: 1, slot_type: '', exotic_weapon_id: '', renderUrl: '',
|
||||
compatibleCategories: [], qualityValues: data.qualities.map(qualityRow),
|
||||
secondaryRanges: [blankSecondary(),blankSecondary()], effects: [blankEffect()], exoticAttributes:[blankExoticAttribute()]
|
||||
});
|
||||
@@ -27,6 +28,8 @@
|
||||
let qualityPreview = $state('High-end');
|
||||
let isExotic = $state(false);
|
||||
let handledResultToken = $state('');
|
||||
let renderResetToken = $state(0);
|
||||
let openedRequestedItem = $state(false);
|
||||
const categoryOptions = $derived(data.weaponCategories.map((category: any) => ({ value:category.id,label:category.name,icon:category.id as GameIconName,iconOnly:true })));
|
||||
const slotOptions = $derived(data.slots.map((slot: any) => ({ value:slot.id,label:slot.name.toUpperCase() })));
|
||||
const exoticWeapons = $derived(data.weapons.filter((weapon: any) => Number(weapon.is_exotic)));
|
||||
@@ -60,8 +63,14 @@
|
||||
const addExoticAttribute = () => { if(editing.exoticAttributes.length<12) editing.exoticAttributes=[...editing.exoticAttributes,blankExoticAttribute()]; };
|
||||
const removeExoticAttribute = (index:number) => { if(editing.exoticAttributes.length>1) editing.exoticAttributes=editing.exoticAttributes.filter((_:any,itemIndex:number)=>itemIndex!==index); };
|
||||
$effect(() => {
|
||||
if (!openedRequestedItem && data.requestedItemId) {
|
||||
const requested = data.mods.find((item: any) => item.id === data.requestedItemId);
|
||||
if (requested) edit(requested);
|
||||
openedRequestedItem = true;
|
||||
}
|
||||
if (form?.weaponModSaved && form.resultToken && form.resultToken !== handledResultToken) {
|
||||
handledResultToken = form.resultToken;
|
||||
if (form.weaponModMode === 'add') renderResetToken += 1;
|
||||
editing = form.weaponModMode === 'add' ? blank() : null;
|
||||
levelPreview = 1;
|
||||
isExotic = false;
|
||||
@@ -83,7 +92,7 @@
|
||||
{#snippet weaponModEditor()}
|
||||
<div class="dashboard-editor weapon-mod-editor">
|
||||
<div class="editor-title"><div><p class="eyebrow">{editing.id ? 'MODIFY WEAPON MOD' : 'NEW WEAPON MOD'}</p><h3>{editing.id ? editing.name : 'WEAPON MOD DEFINITION'}</h3></div><button aria-label="Close editor" onclick={() => editing = null}>×</button></div>
|
||||
<PendingForm action="?/save">
|
||||
<PendingForm action="?/save" enctype="multipart/form-data">
|
||||
<input type="hidden" name="id" value={editing.id}/>
|
||||
<input type="hidden" name="isExotic" value={isExotic ? '1' : '0'}/>
|
||||
<input type="hidden" name="qualityValues" value={JSON.stringify(editing.qualityValues)}/>
|
||||
@@ -96,6 +105,7 @@
|
||||
{#if isExotic}<div class="weapon-mod-exotic-quality"><GameIcon name="exotic" decorative/><span><b>EXOTIC QUALITY</b><small>UNIQUE MOD · NO QUALITY VARIANTS OR RANDOM ROLLS</small></span></div>{:else}<div class="weapon-mod-primary-types"><span>PRIMARY ATTRIBUTE OPTIONS</span><div>{#each ['firepower','toughness','engineering'] as primary}<b><GameIcon name={primary as GameIconName} decorative/>{primary.toUpperCase()}</b>{/each}</div><small>Selected by the agent while configuring a build.</small></div>{/if}
|
||||
<SegmentedChoice legend="WEAPON MOD SLOT" name="slotType" options={slotOptions} columns={8} bind:value={editing.slot_type} required/>
|
||||
</div>
|
||||
<GameDataRenderUploadField currentUrl={editing.renderUrl} label="WEAPON MOD RENDER" resetToken={renderResetToken}/>
|
||||
|
||||
<div class="weapon-mod-compatibility">
|
||||
<div>
|
||||
@@ -158,7 +168,7 @@
|
||||
<div class="dashboard-table weapon-mod-dashboard-table">
|
||||
<div class="dashboard-row weapon-mod-row table-heading"><SortableColumnHeader label="WEAPON MOD" column="name" sort={data.sort}/><SortableColumnHeader label="SLOT" column="slot" sort={data.sort}/><SortableColumnHeader label="PRIMARY" column="primary" sort={data.sort}/><SortableColumnHeader label="SECONDARIES" column="secondaries" sort={data.sort}/><SortableColumnHeader label="COMPATIBILITY" column="compatibility" sort={data.sort}/><SortableColumnHeader label="MOD EFFECT" column="effects" sort={data.sort}/><SortableColumnHeader label="STATE" column="state" sort={data.sort} defaultDirection="desc"/><span></span></div>
|
||||
{#each data.mods as item}
|
||||
<div class="dashboard-row weapon-mod-row" class:archived={Number(item.active)!==1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.exotic_weapon_id ? 'EXOTIC · PRE-EQUIPPED' : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div><span>{item.slot_type}</span><span class="weapon-mod-primary">{#if item.exotic_weapon_id}<GameIcon name="exotic" decorative/>FIXED{:else}<span class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></span>ANY CORE{/if}</span><span>{item.exotic_weapon_id ? `${item.exoticAttributes.length} FIXED` : `${item.secondaryRanges.length} POSSIBLE`}<small> · {item.exotic_weapon_id ? 'LEVEL SCALED' : 'SELECT 2 IN BUILDER'}</small></span><span>{item.exotic_weapon_id ? weapon(item.exotic_weapon_id)?.name ?? item.exotic_weapon_name : item.compatibleCategories.map((category: string) => category.toUpperCase()).join(' · ')}</span><span>{item.exotic_weapon_id ? 'NO ROLLS' : `${item.effects.length} EFFECT${item.effects.length===1?'':'S'}`}</span><b class:pending={item.pending} class="state-dot">{item.pending?'PENDING':Number(item.active)?'ACTIVE':'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)} aria-expanded={editing?.id === item.id} aria-controls={`weapon-mod-editor-${item.id}`}>EDIT</button>{/if}</div>
|
||||
<div class="dashboard-row weapon-mod-row" class:archived={Number(item.active)!==1} class:pending-record={item.pending} class:registry-row-editing={editing?.id === item.id}><div class="registry-render-identity">{#if item.renderUrl}<img class="registry-render" src={item.renderUrl} alt=""/>{/if}<div><strong>{item.name}</strong><small>{item.pending ? `PROPOSED BY ${item.proposerName}` : item.exotic_weapon_id ? 'EXOTIC · PRE-EQUIPPED' : 'STANDARD · SPECIALIZED · SUPERIOR · HIGH-END'}</small></div></div><span>{item.slot_type}</span><span class="weapon-mod-primary">{#if item.exotic_weapon_id}<GameIcon name="exotic" decorative/>FIXED{:else}<span class="core-icon-stack"><GameIcon name="firepower" decorative/><GameIcon name="toughness" decorative/><GameIcon name="engineering" decorative/></span>ANY CORE{/if}</span><span>{item.exotic_weapon_id ? `${item.exoticAttributes.length} FIXED` : `${item.secondaryRanges.length} POSSIBLE`}<small> · {item.exotic_weapon_id ? 'LEVEL SCALED' : 'SELECT 2 IN BUILDER'}</small></span><span>{item.exotic_weapon_id ? weapon(item.exotic_weapon_id)?.name ?? item.exotic_weapon_name : item.compatibleCategories.map((category: string) => category.toUpperCase()).join(' · ')}</span><span>{item.exotic_weapon_id ? 'NO ROLLS' : `${item.effects.length} EFFECT${item.effects.length===1?'':'S'}`}</span><b class:pending={item.pending} class="state-dot">{item.pending?'PENDING':Number(item.active)?'ACTIVE':'ARCHIVED'}</b>{#if item.pending}<span></span>{:else}<button onclick={() => edit(item)} aria-expanded={editing?.id === item.id} aria-controls={`weapon-mod-editor-${item.id}`}>EDIT</button>{/if}</div>
|
||||
{#if editing?.id === item.id}
|
||||
<RegistryInlineEditor id={`weapon-mod-editor-${item.id}`} label={`Edit ${item.name}`}>
|
||||
{@render weaponModEditor()}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user