Improve moderation controls and term management
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let {
|
||||
id,
|
||||
kicker,
|
||||
title,
|
||||
status,
|
||||
statusTone = 'default',
|
||||
open = $bindable(true),
|
||||
class: className = '',
|
||||
children
|
||||
}: {
|
||||
id: string;
|
||||
kicker: string;
|
||||
title: string;
|
||||
status?: string;
|
||||
statusTone?: 'default' | 'success' | 'danger';
|
||||
open?: boolean;
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<section class={`collapsible-section ${className}`} aria-labelledby={`${id}-heading`}>
|
||||
<h3 id={`${id}-heading`}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={`${id}-content`}
|
||||
onclick={() => open = !open}
|
||||
>
|
||||
<span class="collapsible-section-identity">
|
||||
<small>{kicker}</small>
|
||||
<b>{title}</b>
|
||||
</span>
|
||||
{#if status}<strong class:success={statusTone === 'success'} class:danger={statusTone === 'danger'}>{status}</strong>{/if}
|
||||
<span class="collapsible-section-action">
|
||||
<span>{open ? 'COLLAPSE' : 'EXPAND'}</span>
|
||||
<i aria-hidden="true">{open ? '−' : '+'}</i>
|
||||
</span>
|
||||
</button>
|
||||
</h3>
|
||||
<div id={`${id}-content`} class="collapsible-section-content" hidden={!open}>
|
||||
{@render children()}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.collapsible-section{min-width:0;border:1px solid var(--shd-border);background:var(--shd-surface)}
|
||||
h3{margin:0}
|
||||
h3>button{width:100%;min-height:58px;display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:12px;padding:9px 11px;border:0;background:#0d1011;color:var(--shd-text);text-align:left;cursor:pointer}
|
||||
h3>button:hover{background:#141819}
|
||||
h3>button:active{filter:brightness(.88)}
|
||||
h3>button:focus-visible{position:relative;z-index:1;outline:2px solid var(--shd-orange);outline-offset:2px}
|
||||
.collapsible-section-identity{min-width:0;display:grid;gap:2px}
|
||||
.collapsible-section-identity small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}
|
||||
.collapsible-section-identity b{overflow:hidden;font-size:var(--shd-type-body);letter-spacing:.5px;text-overflow:ellipsis;white-space:nowrap}
|
||||
h3>button>strong{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.7px;white-space:nowrap}
|
||||
h3>button>strong.success{color:var(--shd-success)}
|
||||
h3>button>strong.danger{color:var(--shd-danger)}
|
||||
.collapsible-section-action{display:flex;align-items:center;gap:8px;color:var(--shd-orange);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}
|
||||
.collapsible-section-action i{width:28px;height:28px;display:grid;place-items:center;border:1px solid var(--shd-border-strong);font-size:16px;font-style:normal}
|
||||
.collapsible-section-content{padding:11px;border-top:1px solid var(--shd-border)}
|
||||
.collapsible-section-content[hidden]{display:none}
|
||||
@media(max-width:650px){
|
||||
h3>button{min-height:64px;grid-template-columns:minmax(0,1fr) auto;gap:8px}
|
||||
h3>button>strong{grid-column:1;grid-row:2}
|
||||
.collapsible-section-action{grid-column:2;grid-row:1/3}
|
||||
.collapsible-section-action>span{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
||||
.collapsible-section-content{padding:9px}
|
||||
}
|
||||
@media(prefers-reduced-motion:reduce){h3>button{transition:none}}
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
assessCommunityModeration,
|
||||
findLocalModerationMatches,
|
||||
normalizeModerationTerm,
|
||||
parseModerationTermBatch,
|
||||
type CommunityModerationPolicy
|
||||
} from './community-moderation';
|
||||
|
||||
@@ -34,6 +35,27 @@ describe('community moderation policy', () => {
|
||||
expect(normalizeModerationTerm(' Rogue\u00a0Agent ')).toBe('rogue agent');
|
||||
});
|
||||
|
||||
it('parses pasted terms with automatic word and phrase matching', () => {
|
||||
expect(parseModerationTermBatch('weapon\nkill, machine gun; assault rifle')).toEqual({
|
||||
terms: [
|
||||
{ value: 'weapon', normalizedValue: 'weapon', matchMode: 'word' },
|
||||
{ value: 'kill', normalizedValue: 'kill', matchMode: 'word' },
|
||||
{ value: 'machine gun', normalizedValue: 'machine gun', matchMode: 'phrase' },
|
||||
{ value: 'assault rifle', normalizedValue: 'assault rifle', matchMode: 'phrase' }
|
||||
],
|
||||
duplicateCount: 0
|
||||
});
|
||||
});
|
||||
|
||||
it('deduplicates pasted terms after compatibility and case normalization', () => {
|
||||
const parsed = parseModerationTermBatch('Weapon\nweapon\nMACHINE GUN\nmachine gun');
|
||||
expect(parsed.terms).toEqual([
|
||||
{ value: 'Weapon', normalizedValue: 'weapon', matchMode: 'word' },
|
||||
{ value: 'MACHINE GUN', normalizedValue: 'machine gun', matchMode: 'phrase' }
|
||||
]);
|
||||
expect(parsed.duplicateCount).toBe(2);
|
||||
});
|
||||
|
||||
it('allows an exact local exception to suppress a matching banned term', () => {
|
||||
const matches = findLocalModerationMatches('Rogue Agent tactics', 'comment', policy({
|
||||
detectPii: false,
|
||||
|
||||
@@ -107,6 +107,37 @@ export function normalizeModerationTerm(value: string) {
|
||||
return value.normalize('NFKC').trim().replace(/\s+/g, ' ').toLocaleLowerCase();
|
||||
}
|
||||
|
||||
export type ParsedModerationTermBatch = {
|
||||
terms: Array<{
|
||||
value: string;
|
||||
normalizedValue: string;
|
||||
matchMode: 'word' | 'phrase';
|
||||
}>;
|
||||
duplicateCount: number;
|
||||
};
|
||||
|
||||
export function parseModerationTermBatch(value: string): ParsedModerationTermBatch {
|
||||
const entries = value.normalize('NFKC')
|
||||
.split(/[\r\n,;]+/u)
|
||||
.map((entry) => entry.trim().replace(/\s+/g, ' '))
|
||||
.filter(Boolean);
|
||||
const terms: ParsedModerationTermBatch['terms'] = [];
|
||||
const seen = new Set<string>();
|
||||
let duplicateCount = 0;
|
||||
for (const entry of entries) {
|
||||
const normalizedValue = normalizeModerationTerm(entry);
|
||||
const matchMode = /\s/u.test(normalizedValue) ? 'phrase' : 'word';
|
||||
const key = `${matchMode}:${normalizedValue}`;
|
||||
if (seen.has(key)) {
|
||||
duplicateCount += 1;
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
terms.push({ value: entry, normalizedValue, matchMode });
|
||||
}
|
||||
return { terms, duplicateCount };
|
||||
}
|
||||
|
||||
export function hashModeratedContent(value: string) {
|
||||
return createHash('sha256').update(value.normalize('NFC')).digest('hex');
|
||||
}
|
||||
|
||||
@@ -4,14 +4,17 @@ import { requirePermission } from '$lib/server/auth/authorization';
|
||||
import { getSessionUser } from '$lib/server/auth/session';
|
||||
import { getDatabase } from '$lib/server/database';
|
||||
import { recordIsacAudit } from '$lib/server/audit';
|
||||
import { getPagination, getRegistrySearch } from '$lib/server/pagination';
|
||||
import { invalidateSiteAccessSettings } from '$lib/server/site-access';
|
||||
import { invalidateCommunityModerationPolicy } from '$lib/server/community-moderation';
|
||||
import {
|
||||
invalidateCommunityModerationPolicy,
|
||||
normalizeModerationTerm,
|
||||
parseModerationTermBatch
|
||||
} from '$lib/server/community-moderation';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { Actions, PageServerLoad } from './$types';
|
||||
|
||||
const SETTINGS_ID = 'primary';
|
||||
const CONFIRMATION = 'apply-site-access-settings';
|
||||
const MODERATION_CONFIRMATION = 'apply-community-moderation-policy';
|
||||
const MODERATION_MODELS = new Set(['omni-moderation-latest', 'omni-moderation-2024-09-26']);
|
||||
const MODERATION_SCOPES = new Set(['comment', 'profile_bio', 'guide']);
|
||||
|
||||
@@ -22,19 +25,6 @@ const settingSnapshot = async () => {
|
||||
)).get(SETTINGS_ID) ?? null;
|
||||
};
|
||||
|
||||
const flagNames = [
|
||||
'comingSoonEnabled', 'accountCreationEnabled', 'newsEnabled', 'databaseEnabled',
|
||||
'buildsEnabled', 'guidesEnabled', 'builderEnabled'
|
||||
] as const;
|
||||
|
||||
const enabled = (form: FormData, name: (typeof flagNames)[number]) =>
|
||||
String(form.get(name)) === '1' ? 1 : 0;
|
||||
|
||||
const moderationBooleanNames = [
|
||||
'automatedEnabled', 'autoPublishClean', 'holdOnProviderError', 'scanComments',
|
||||
'scanProfiles', 'scanGuides', 'scanAvatars', 'detectPii'
|
||||
] as const;
|
||||
|
||||
const strictBoolean = (form: FormData, name: string) => {
|
||||
const value = String(form.get(name) ?? '');
|
||||
return value === '1' ? 1 : value === '0' ? 0 : null;
|
||||
@@ -46,9 +36,6 @@ const finiteNumber = (value: FormDataEntryValue | null) => {
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
const normalizeTermValue = (value: FormDataEntryValue | null) =>
|
||||
String(value ?? '').normalize('NFKC').trim().toLowerCase();
|
||||
|
||||
const moderationPolicySnapshot = async () => {
|
||||
const database = await getDatabase();
|
||||
const [settings, thresholds] = await Promise.all([
|
||||
@@ -58,10 +45,26 @@ const moderationPolicySnapshot = async () => {
|
||||
return { settings: settings ?? null, thresholds };
|
||||
};
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies }) => {
|
||||
const moderationSettingsSnapshot = async () => {
|
||||
const database = await getDatabase();
|
||||
return await (await database.prepare(
|
||||
'SELECT * FROM community_moderation_settings WHERE id=?'
|
||||
)).get(SETTINGS_ID) ?? null;
|
||||
};
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
await requirePermission(await getSessionUser(cookies), 'site.settings.manage');
|
||||
const database = await getDatabase();
|
||||
const [settings, moderationSettings, moderationThresholds, moderationTerms] = await Promise.all([
|
||||
const { search: moderationTermSearch, pattern: moderationTermPattern } = getRegistrySearch(url, 'terms');
|
||||
const termWhere = moderationTermSearch
|
||||
? `WHERE terms.value LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR terms.normalized_value LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR COALESCE(terms.note,'') LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR terms.list_kind LIKE ? ESCAPE '\\' COLLATE NOCASE
|
||||
OR terms.match_mode LIKE ? ESCAPE '\\' COLLATE NOCASE`
|
||||
: '';
|
||||
const termArgs = moderationTermSearch ? Array(5).fill(moderationTermPattern) : [];
|
||||
const [settings, moderationSettings, moderationThresholds, termCount] = await Promise.all([
|
||||
(await database.prepare(`SELECT settings.*,
|
||||
COALESCE(profile.display_name,user.username) AS updated_by_name
|
||||
FROM site_launch_settings settings
|
||||
@@ -80,20 +83,31 @@ export const load: PageServerLoad = async ({ cookies }) => {
|
||||
LEFT JOIN users user ON user.id=thresholds.updated_by
|
||||
LEFT JOIN user_profiles profile ON profile.user_id=user.id
|
||||
ORDER BY thresholds.category`)).all(),
|
||||
(await database.prepare(`SELECT terms.*,
|
||||
COALESCE(profile.display_name,user.username) AS updated_by_name
|
||||
FROM community_moderation_terms terms
|
||||
LEFT JOIN users user ON user.id=terms.updated_by
|
||||
LEFT JOIN user_profiles profile ON profile.user_id=user.id
|
||||
ORDER BY terms.enabled DESC,terms.list_kind,terms.normalized_value`)).all()
|
||||
(await database.prepare(`SELECT COUNT(*) AS total
|
||||
FROM community_moderation_terms terms ${termWhere}`)).get(...termArgs)
|
||||
]);
|
||||
if (!settings) error(500, 'Site launch settings are not initialized.');
|
||||
if (!moderationSettings) error(500, 'Community moderation settings are not initialized.');
|
||||
const moderationTermPagination = getPagination(url, Number(termCount?.total ?? 0), 'terms');
|
||||
const moderationTerms = await (await database.prepare(`SELECT terms.*,
|
||||
COALESCE(profile.display_name,user.username) AS updated_by_name
|
||||
FROM community_moderation_terms terms
|
||||
LEFT JOIN users user ON user.id=terms.updated_by
|
||||
LEFT JOIN user_profiles profile ON profile.user_id=user.id
|
||||
${termWhere}
|
||||
ORDER BY terms.enabled DESC,terms.list_kind,terms.normalized_value
|
||||
LIMIT ? OFFSET ?`)).all(
|
||||
...termArgs,
|
||||
moderationTermPagination.perPage,
|
||||
moderationTermPagination.offset
|
||||
);
|
||||
return {
|
||||
settings,
|
||||
moderationSettings,
|
||||
moderationThresholds,
|
||||
moderationTerms,
|
||||
moderationTermSearch,
|
||||
moderationTermPagination,
|
||||
providerConfigured: Boolean(
|
||||
env.OPENAI_MODERATION_API_KEY?.trim() || env.OPENAI_MECHANIC_API_KEY?.trim()
|
||||
)
|
||||
@@ -101,78 +115,241 @@ export const load: PageServerLoad = async ({ cookies }) => {
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
save: async ({ cookies, request }) => {
|
||||
saveLaunchGate: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'site.settings.manage');
|
||||
const form = await request.formData();
|
||||
if (String(form.get('confirmation') ?? '') !== CONFIRMATION) {
|
||||
return fail(400, { siteSettingsError: 'Review and confirm the complete site access profile before applying it.' });
|
||||
}
|
||||
if (flagNames.some((name) => !['0', '1'].includes(String(form.get(name))))) {
|
||||
return fail(400, { siteSettingsError: 'Every site access control must have an explicit enabled or disabled state.' });
|
||||
}
|
||||
const comingSoonEnabled = strictBoolean(form, 'comingSoonEnabled');
|
||||
if (comingSoonEnabled === null) return fail(400, {
|
||||
launchGateError: 'Choose a public network state.',
|
||||
siteResultToken: randomUUID()
|
||||
});
|
||||
const before = await settingSnapshot();
|
||||
if (!before) return fail(404, { siteSettingsError: 'Site launch settings are not initialized.' });
|
||||
const next = {
|
||||
comingSoonEnabled: enabled(form, 'comingSoonEnabled'),
|
||||
accountCreationEnabled: enabled(form, 'accountCreationEnabled'),
|
||||
newsEnabled: enabled(form, 'newsEnabled'),
|
||||
databaseEnabled: enabled(form, 'databaseEnabled'),
|
||||
buildsEnabled: enabled(form, 'buildsEnabled'),
|
||||
guidesEnabled: enabled(form, 'guidesEnabled'),
|
||||
builderEnabled: enabled(form, 'builderEnabled')
|
||||
};
|
||||
if (!before) return fail(404, {
|
||||
launchGateError: 'Site launch settings are not initialized.',
|
||||
siteResultToken: randomUUID()
|
||||
});
|
||||
const database = await getDatabase();
|
||||
const updatedAt = new Date().toISOString();
|
||||
await (await database.prepare(`UPDATE site_launch_settings SET
|
||||
coming_soon_enabled=?,account_creation_enabled=?,news_enabled=?,database_enabled=?,
|
||||
builds_enabled=?,guides_enabled=?,builder_enabled=?,updated_at=?,updated_by=?
|
||||
WHERE id=?`)).run(
|
||||
next.comingSoonEnabled, next.accountCreationEnabled, next.newsEnabled, next.databaseEnabled,
|
||||
next.buildsEnabled, next.guidesEnabled, next.builderEnabled, updatedAt, operator.id, SETTINGS_ID
|
||||
);
|
||||
await (await database.prepare(`UPDATE site_launch_settings
|
||||
SET coming_soon_enabled=?,updated_at=?,updated_by=? WHERE id=?`))
|
||||
.run(comingSoonEnabled, updatedAt, operator.id, SETTINGS_ID);
|
||||
invalidateSiteAccessSettings();
|
||||
const after = await settingSnapshot();
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'system_configuration',
|
||||
action: 'site_access.updated',
|
||||
action: 'site_access.launch_gate_updated',
|
||||
entityId: SETTINGS_ID,
|
||||
before,
|
||||
after
|
||||
});
|
||||
return { siteSettingsSaved: true, resultToken: randomUUID() };
|
||||
return { launchGateSaved: true, siteResultToken: randomUUID() };
|
||||
},
|
||||
|
||||
saveModerationPolicy: async ({ cookies, request }) => {
|
||||
saveAccountCreation: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'site.settings.manage');
|
||||
const form = await request.formData();
|
||||
if (String(form.get('confirmation') ?? '') !== MODERATION_CONFIRMATION) {
|
||||
return fail(400, {
|
||||
moderationPolicyError: 'Review and confirm the complete community moderation policy before applying it.',
|
||||
moderationResultToken: randomUUID()
|
||||
});
|
||||
}
|
||||
const booleanValues = Object.fromEntries(
|
||||
moderationBooleanNames.map((name) => [name, strictBoolean(form, name)])
|
||||
) as Record<(typeof moderationBooleanNames)[number], number | null>;
|
||||
if (moderationBooleanNames.some((name) => booleanValues[name] === null)) {
|
||||
return fail(400, {
|
||||
moderationPolicyError: 'Every moderation control must have an explicit enabled or disabled state.',
|
||||
moderationResultToken: randomUUID()
|
||||
});
|
||||
}
|
||||
const accountCreationEnabled = strictBoolean(form, 'accountCreationEnabled');
|
||||
if (accountCreationEnabled === null) return fail(400, {
|
||||
accountCreationError: 'Choose an account creation state.',
|
||||
siteResultToken: randomUUID()
|
||||
});
|
||||
const before = await settingSnapshot();
|
||||
if (!before) return fail(404, {
|
||||
accountCreationError: 'Site launch settings are not initialized.',
|
||||
siteResultToken: randomUUID()
|
||||
});
|
||||
const database = await getDatabase();
|
||||
await (await database.prepare(`UPDATE site_launch_settings
|
||||
SET account_creation_enabled=?,updated_at=?,updated_by=? WHERE id=?`))
|
||||
.run(accountCreationEnabled, new Date().toISOString(), operator.id, SETTINGS_ID);
|
||||
invalidateSiteAccessSettings();
|
||||
const after = await settingSnapshot();
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'system_configuration',
|
||||
action: 'site_access.account_creation_updated',
|
||||
entityId: SETTINGS_ID,
|
||||
before,
|
||||
after
|
||||
});
|
||||
return { accountCreationSaved: true, siteResultToken: randomUUID() };
|
||||
},
|
||||
|
||||
saveSectionAvailability: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'site.settings.manage');
|
||||
const form = await request.formData();
|
||||
const fields = ['newsEnabled', 'databaseEnabled', 'buildsEnabled', 'guidesEnabled', 'builderEnabled'] as const;
|
||||
const values = Object.fromEntries(fields.map((name) => [name, strictBoolean(form, name)])) as
|
||||
Record<(typeof fields)[number], number | null>;
|
||||
if (fields.some((name) => values[name] === null)) return fail(400, {
|
||||
sectionAvailabilityError: 'Every public section must have an explicit enabled or disabled state.',
|
||||
siteResultToken: randomUUID()
|
||||
});
|
||||
const before = await settingSnapshot();
|
||||
if (!before) return fail(404, {
|
||||
sectionAvailabilityError: 'Site launch settings are not initialized.',
|
||||
siteResultToken: randomUUID()
|
||||
});
|
||||
const database = await getDatabase();
|
||||
await (await database.prepare(`UPDATE site_launch_settings SET
|
||||
news_enabled=?,database_enabled=?,builds_enabled=?,guides_enabled=?,builder_enabled=?,
|
||||
updated_at=?,updated_by=? WHERE id=?`)).run(
|
||||
values.newsEnabled,
|
||||
values.databaseEnabled,
|
||||
values.buildsEnabled,
|
||||
values.guidesEnabled,
|
||||
values.builderEnabled,
|
||||
new Date().toISOString(),
|
||||
operator.id,
|
||||
SETTINGS_ID
|
||||
);
|
||||
invalidateSiteAccessSettings();
|
||||
const after = await settingSnapshot();
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'system_configuration',
|
||||
action: 'site_access.section_availability_updated',
|
||||
entityId: SETTINGS_ID,
|
||||
before,
|
||||
after
|
||||
});
|
||||
return { sectionAvailabilitySaved: true, siteResultToken: randomUUID() };
|
||||
},
|
||||
|
||||
saveModerationScreening: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'site.settings.manage');
|
||||
const form = await request.formData();
|
||||
const automatedEnabled = strictBoolean(form, 'automatedEnabled');
|
||||
const providerModel = String(form.get('providerModel') ?? '').trim();
|
||||
if (automatedEnabled === null) return fail(400, {
|
||||
moderationScreeningError: 'Choose whether automated moderation is enabled.',
|
||||
moderationResultToken: randomUUID()
|
||||
});
|
||||
if (!MODERATION_MODELS.has(providerModel)) {
|
||||
return fail(400, {
|
||||
moderationPolicyError: 'Choose a supported OpenAI moderation model.',
|
||||
moderationScreeningError: 'Choose a supported OpenAI moderation model.',
|
||||
moderationResultToken: randomUUID()
|
||||
});
|
||||
}
|
||||
const before = await moderationSettingsSnapshot();
|
||||
if (!before) return fail(404, {
|
||||
moderationScreeningError: 'Community moderation settings are not initialized.',
|
||||
moderationResultToken: randomUUID()
|
||||
});
|
||||
const database = await getDatabase();
|
||||
await (await database.prepare(`UPDATE community_moderation_settings SET
|
||||
automated_enabled=?,provider_model=?,policy_version=policy_version+1,
|
||||
updated_at=?,updated_by=? WHERE id=?`)).run(
|
||||
automatedEnabled,
|
||||
providerModel,
|
||||
new Date().toISOString(),
|
||||
operator.id,
|
||||
SETTINGS_ID
|
||||
);
|
||||
const after = await moderationSettingsSnapshot();
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'system_configuration',
|
||||
action: 'moderation.screening_updated',
|
||||
entityId: SETTINGS_ID,
|
||||
before,
|
||||
after
|
||||
});
|
||||
invalidateCommunityModerationPolicy();
|
||||
return { moderationScreeningSaved: true, moderationResultToken: randomUUID() };
|
||||
},
|
||||
|
||||
saveModerationPublication: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'site.settings.manage');
|
||||
const form = await request.formData();
|
||||
const autoPublishClean = strictBoolean(form, 'autoPublishClean');
|
||||
const holdOnProviderError = strictBoolean(form, 'holdOnProviderError');
|
||||
if (autoPublishClean === null || holdOnProviderError === null) return fail(400, {
|
||||
moderationPublicationError: 'Choose an explicit clean-content and provider-failure decision.',
|
||||
moderationResultToken: randomUUID()
|
||||
});
|
||||
const before = await moderationSettingsSnapshot();
|
||||
if (!before) return fail(404, {
|
||||
moderationPublicationError: 'Community moderation settings are not initialized.',
|
||||
moderationResultToken: randomUUID()
|
||||
});
|
||||
const database = await getDatabase();
|
||||
await (await database.prepare(`UPDATE community_moderation_settings SET
|
||||
auto_publish_clean=?,hold_on_provider_error=?,policy_version=policy_version+1,
|
||||
updated_at=?,updated_by=? WHERE id=?`)).run(
|
||||
autoPublishClean,
|
||||
holdOnProviderError,
|
||||
new Date().toISOString(),
|
||||
operator.id,
|
||||
SETTINGS_ID
|
||||
);
|
||||
const after = await moderationSettingsSnapshot();
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'system_configuration',
|
||||
action: 'moderation.publication_updated',
|
||||
entityId: SETTINGS_ID,
|
||||
before,
|
||||
after
|
||||
});
|
||||
invalidateCommunityModerationPolicy();
|
||||
return { moderationPublicationSaved: true, moderationResultToken: randomUUID() };
|
||||
},
|
||||
|
||||
saveModerationCoverage: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'site.settings.manage');
|
||||
const form = await request.formData();
|
||||
const fields = ['scanComments', 'scanProfiles', 'scanGuides', 'scanAvatars'] as const;
|
||||
const values = Object.fromEntries(fields.map((name) => [name, strictBoolean(form, name)])) as
|
||||
Record<(typeof fields)[number], number | null>;
|
||||
if (fields.some((name) => values[name] === null)) return fail(400, {
|
||||
moderationCoverageError: 'Every content channel must have an explicit enabled or disabled state.',
|
||||
moderationResultToken: randomUUID()
|
||||
});
|
||||
const before = await moderationSettingsSnapshot();
|
||||
if (!before) return fail(404, {
|
||||
moderationCoverageError: 'Community moderation settings are not initialized.',
|
||||
moderationResultToken: randomUUID()
|
||||
});
|
||||
const database = await getDatabase();
|
||||
await (await database.prepare(`UPDATE community_moderation_settings SET
|
||||
scan_comments=?,scan_profiles=?,scan_guides=?,scan_avatars=?,
|
||||
policy_version=policy_version+1,updated_at=?,updated_by=? WHERE id=?`)).run(
|
||||
values.scanComments,
|
||||
values.scanProfiles,
|
||||
values.scanGuides,
|
||||
values.scanAvatars,
|
||||
new Date().toISOString(),
|
||||
operator.id,
|
||||
SETTINGS_ID
|
||||
);
|
||||
const after = await moderationSettingsSnapshot();
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'system_configuration',
|
||||
action: 'moderation.coverage_updated',
|
||||
entityId: SETTINGS_ID,
|
||||
before,
|
||||
after
|
||||
});
|
||||
invalidateCommunityModerationPolicy();
|
||||
return { moderationCoverageSaved: true, moderationResultToken: randomUUID() };
|
||||
},
|
||||
|
||||
saveModerationLimits: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'site.settings.manage');
|
||||
const form = await request.formData();
|
||||
const detectPii = strictBoolean(form, 'detectPii');
|
||||
const maxLinks = finiteNumber(form.get('maxLinks'));
|
||||
const duplicateWindowSeconds = finiteNumber(form.get('duplicateWindowSeconds'));
|
||||
if (detectPii === null) return fail(400, {
|
||||
moderationLimitsError: 'Choose whether local PII detection is enabled.',
|
||||
moderationResultToken: randomUUID()
|
||||
});
|
||||
if (!Number.isInteger(maxLinks) || maxLinks === null || maxLinks < 0 || maxLinks > 20) {
|
||||
return fail(400, {
|
||||
moderationPolicyError: 'Maximum links must be a whole number from 0 through 20.',
|
||||
moderationLimitsError: 'Maximum links must be a whole number from 0 through 20.',
|
||||
moderationResultToken: randomUUID()
|
||||
});
|
||||
}
|
||||
@@ -183,18 +360,49 @@ export const actions: Actions = {
|
||||
|| duplicateWindowSeconds > 3_600
|
||||
) {
|
||||
return fail(400, {
|
||||
moderationPolicyError: 'Duplicate detection must use a whole-number window from 0 through 3,600 seconds.',
|
||||
moderationLimitsError: 'Duplicate detection must use a whole-number window from 0 through 3,600 seconds.',
|
||||
moderationResultToken: randomUUID()
|
||||
});
|
||||
}
|
||||
const before = await moderationSettingsSnapshot();
|
||||
if (!before) return fail(404, {
|
||||
moderationLimitsError: 'Community moderation settings are not initialized.',
|
||||
moderationResultToken: randomUUID()
|
||||
});
|
||||
const database = await getDatabase();
|
||||
await (await database.prepare(`UPDATE community_moderation_settings SET
|
||||
detect_pii=?,max_links=?,duplicate_window_seconds=?,policy_version=policy_version+1,
|
||||
updated_at=?,updated_by=? WHERE id=?`)).run(
|
||||
detectPii,
|
||||
maxLinks,
|
||||
duplicateWindowSeconds,
|
||||
new Date().toISOString(),
|
||||
operator.id,
|
||||
SETTINGS_ID
|
||||
);
|
||||
const after = await moderationSettingsSnapshot();
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'system_configuration',
|
||||
action: 'moderation.local_limits_updated',
|
||||
entityId: SETTINGS_ID,
|
||||
before,
|
||||
after
|
||||
});
|
||||
invalidateCommunityModerationPolicy();
|
||||
return { moderationLimitsSaved: true, moderationResultToken: randomUUID() };
|
||||
},
|
||||
|
||||
saveModerationThresholds: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'site.settings.manage');
|
||||
const form = await request.formData();
|
||||
const database = await getDatabase();
|
||||
const thresholdRows = await (await database.prepare(
|
||||
'SELECT category FROM community_moderation_thresholds ORDER BY category'
|
||||
)).all();
|
||||
if (!thresholdRows.length) {
|
||||
return fail(409, {
|
||||
moderationPolicyError: 'Moderation categories are not initialized.',
|
||||
moderationThresholdsError: 'Moderation categories are not initialized.',
|
||||
moderationResultToken: randomUUID()
|
||||
});
|
||||
}
|
||||
@@ -220,7 +428,7 @@ export const actions: Actions = {
|
||||
|| priorityThreshold < reviewThreshold
|
||||
) {
|
||||
return fail(400, {
|
||||
moderationPolicyError: `Review the ${category} thresholds. Values must be between 0 and 1, and priority must be at least the review threshold.`,
|
||||
moderationThresholdsError: `Review the ${category} thresholds. Values must be between 0 and 1, and priority must be at least the review threshold.`,
|
||||
moderationResultToken: randomUUID()
|
||||
});
|
||||
}
|
||||
@@ -232,25 +440,8 @@ export const actions: Actions = {
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
await (await database.prepare(`UPDATE community_moderation_settings SET
|
||||
automated_enabled=?,auto_publish_clean=?,hold_on_provider_error=?,
|
||||
scan_comments=?,scan_profiles=?,scan_guides=?,scan_avatars=?,provider_model=?,detect_pii=?,
|
||||
max_links=?,duplicate_window_seconds=?,policy_version=policy_version+1,
|
||||
updated_at=?,updated_by=? WHERE id=?`)).run(
|
||||
booleanValues.automatedEnabled,
|
||||
booleanValues.autoPublishClean,
|
||||
booleanValues.holdOnProviderError,
|
||||
booleanValues.scanComments,
|
||||
booleanValues.scanProfiles,
|
||||
booleanValues.scanGuides,
|
||||
booleanValues.scanAvatars,
|
||||
providerModel,
|
||||
booleanValues.detectPii,
|
||||
maxLinks,
|
||||
duplicateWindowSeconds,
|
||||
now,
|
||||
operator.id,
|
||||
SETTINGS_ID
|
||||
);
|
||||
policy_version=policy_version+1,updated_at=?,updated_by=? WHERE id=?`))
|
||||
.run(now, operator.id, SETTINGS_ID);
|
||||
for (const threshold of thresholds) {
|
||||
await (await database.prepare(`UPDATE community_moderation_thresholds SET
|
||||
enabled=?,review_threshold=?,priority_threshold=?,updated_at=?,updated_by=?
|
||||
@@ -267,7 +458,7 @@ export const actions: Actions = {
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'system_configuration',
|
||||
action: 'moderation.policy_updated',
|
||||
action: 'moderation.thresholds_updated',
|
||||
entityId: SETTINGS_ID,
|
||||
before,
|
||||
after
|
||||
@@ -279,7 +470,7 @@ export const actions: Actions = {
|
||||
}
|
||||
invalidateCommunityModerationPolicy();
|
||||
return {
|
||||
moderationPolicySaved: true,
|
||||
moderationThresholdsSaved: true,
|
||||
moderationResultToken: randomUUID()
|
||||
};
|
||||
},
|
||||
@@ -291,7 +482,7 @@ export const actions: Actions = {
|
||||
const listType = String(form.get('listType') ?? '');
|
||||
const matchMode = String(form.get('matchMode') ?? '');
|
||||
const value = String(form.get('value') ?? '').normalize('NFKC').trim();
|
||||
const normalizedValue = normalizeTermValue(form.get('value'));
|
||||
const normalizedValue = normalizeModerationTerm(String(form.get('value') ?? ''));
|
||||
const requestedScopes = [...new Set(form.getAll('scopes').map(String))];
|
||||
const scopes = [...MODERATION_SCOPES].filter((scope) => requestedScopes.includes(scope));
|
||||
const termEnabled = strictBoolean(form, 'enabled');
|
||||
@@ -319,8 +510,8 @@ export const actions: Actions = {
|
||||
moderationResultToken: randomUUID()
|
||||
});
|
||||
const conflicts = await (await database.prepare(`SELECT id,scopes_json FROM community_moderation_terms
|
||||
WHERE normalized_value=? AND match_mode=? AND id<>?`))
|
||||
.all(normalizedValue, matchMode, id || '');
|
||||
WHERE normalized_value=? AND match_mode=? AND list_kind=? AND id<>?`))
|
||||
.all(normalizedValue, matchMode, listType, id || '');
|
||||
const scopeConflict = conflicts.some((candidate) => {
|
||||
try {
|
||||
const candidateScopes = JSON.parse(String(candidate.scopes_json ?? '[]'));
|
||||
@@ -385,6 +576,127 @@ export const actions: Actions = {
|
||||
};
|
||||
},
|
||||
|
||||
bulkAddModerationTerms: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'site.settings.manage');
|
||||
const form = await request.formData();
|
||||
const listType = String(form.get('listType') ?? '');
|
||||
const requestedScopes = [...new Set(form.getAll('scopes').map(String))];
|
||||
const scopes = [...MODERATION_SCOPES].filter((scope) => requestedScopes.includes(scope));
|
||||
const termEnabled = strictBoolean(form, 'enabled');
|
||||
const note = String(form.get('note') ?? '').normalize('NFKC').trim();
|
||||
const rawTerms = String(form.get('terms') ?? '');
|
||||
const failure = (message: string, status = 400) => fail(status, {
|
||||
bulkModerationTermsError: message,
|
||||
moderationResultToken: randomUUID()
|
||||
});
|
||||
if (!['allow', 'ban'].includes(listType)) {
|
||||
return failure('Choose whether the imported terms allow or ban matching content.');
|
||||
}
|
||||
if (!requestedScopes.length || requestedScopes.some((scope) => !MODERATION_SCOPES.has(scope))) {
|
||||
return failure('Choose at least one valid content scope.');
|
||||
}
|
||||
if (termEnabled === null) return failure('Choose whether the imported rules are enabled or disabled.');
|
||||
if (Array.from(note).length > 500) return failure('Operational notes may contain up to 500 characters.');
|
||||
if (Array.from(rawTerms).length > 50_000) {
|
||||
return failure('The pasted list is too large. Import no more than 250 terms at a time.');
|
||||
}
|
||||
|
||||
const parsed = parseModerationTermBatch(rawTerms);
|
||||
if (!parsed.terms.length) return failure('Paste at least one term or phrase.');
|
||||
if (parsed.terms.length > 250) return failure('Import no more than 250 unique terms at a time.');
|
||||
const invalidTerm = parsed.terms.find((term) =>
|
||||
!term.normalizedValue || Array.from(term.normalizedValue).length > 200
|
||||
);
|
||||
if (invalidTerm) {
|
||||
return failure(`Review “${invalidTerm.value.slice(0, 60)}”. Each term or phrase may contain up to 200 characters.`);
|
||||
}
|
||||
|
||||
const database = await getDatabase();
|
||||
const now = new Date().toISOString();
|
||||
const placeholders = parsed.terms.map(() => '?').join(',');
|
||||
await database.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const existingTerms = await (await database.prepare(`SELECT id,list_kind,normalized_value,match_mode,scopes_json
|
||||
FROM community_moderation_terms WHERE normalized_value IN (${placeholders})`))
|
||||
.all(...parsed.terms.map((term) => term.normalizedValue));
|
||||
const conflicts = new Map<string, string>();
|
||||
for (const existing of existingTerms) {
|
||||
if (String(existing.list_kind) !== listType) continue;
|
||||
let existingScopes: unknown;
|
||||
try {
|
||||
existingScopes = JSON.parse(String(existing.scopes_json ?? '[]'));
|
||||
} catch {
|
||||
existingScopes = null;
|
||||
}
|
||||
if (!Array.isArray(existingScopes) || existingScopes.some((scope) => scopes.includes(String(scope)))) {
|
||||
conflicts.set(`${existing.match_mode}:${existing.normalized_value}`, String(existing.id));
|
||||
}
|
||||
}
|
||||
|
||||
const created = parsed.terms.filter((term) =>
|
||||
!conflicts.has(`${term.matchMode}:${term.normalizedValue}`)
|
||||
);
|
||||
if (!created.length) {
|
||||
await database.exec('ROLLBACK');
|
||||
return failure('No terms were added. Every pasted term already has a rule in one or more selected scopes.', 409);
|
||||
}
|
||||
|
||||
for (const term of created) {
|
||||
const termId = randomUUID();
|
||||
await (await database.prepare(`INSERT INTO community_moderation_terms
|
||||
(id,list_kind,match_mode,value,normalized_value,scopes_json,enabled,note,created_at,created_by,updated_at,updated_by)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`)).run(
|
||||
termId,
|
||||
listType,
|
||||
term.matchMode,
|
||||
term.value,
|
||||
term.normalizedValue,
|
||||
JSON.stringify(scopes),
|
||||
termEnabled,
|
||||
note || null,
|
||||
now,
|
||||
operator.id,
|
||||
now,
|
||||
operator.id
|
||||
);
|
||||
const createdRow = {
|
||||
id: termId,
|
||||
listKind: listType,
|
||||
matchMode: term.matchMode,
|
||||
value: term.value,
|
||||
normalizedValue: term.normalizedValue,
|
||||
scopes,
|
||||
enabled: termEnabled,
|
||||
note: note || null
|
||||
};
|
||||
await recordIsacAudit({
|
||||
actorId: operator.id,
|
||||
category: 'system_configuration',
|
||||
action: 'moderation.term_created',
|
||||
entityId: termId,
|
||||
before: null,
|
||||
after: createdRow
|
||||
});
|
||||
}
|
||||
const existingConflictCount = parsed.terms.length - created.length;
|
||||
await database.exec('COMMIT');
|
||||
invalidateCommunityModerationPolicy();
|
||||
return {
|
||||
bulkModerationTermsSaved: true,
|
||||
bulkModerationTermsAdded: created.length,
|
||||
bulkModerationTermsSkipped: existingConflictCount + parsed.duplicateCount,
|
||||
moderationResultToken: randomUUID()
|
||||
};
|
||||
} catch (cause) {
|
||||
try {
|
||||
await database.exec('ROLLBACK');
|
||||
} catch {
|
||||
// Preserve the original mutation failure.
|
||||
}
|
||||
throw cause;
|
||||
}
|
||||
},
|
||||
|
||||
archiveModerationTerm: async ({ cookies, request }) => {
|
||||
const operator = await requirePermission(await getSessionUser(cookies), 'site.settings.manage');
|
||||
const id = String((await request.formData()).get('id') ?? '').trim();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<script lang="ts">
|
||||
import CollapsibleSection from '$lib/components/CollapsibleSection.svelte';
|
||||
import PendingForm from '$lib/components/PendingForm.svelte';
|
||||
import RegistryPagination from '$lib/components/RegistryPagination.svelte';
|
||||
import SegmentedChoice from '$lib/components/SegmentedChoice.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
@@ -45,6 +47,13 @@
|
||||
enabled: string;
|
||||
note: string;
|
||||
};
|
||||
type BulkModerationTermDraft = {
|
||||
listType: 'allow' | 'ban';
|
||||
terms: string;
|
||||
scopes: string[];
|
||||
enabled: string;
|
||||
note: string;
|
||||
};
|
||||
const settingValue = (key: string) => Number(data.settings[key]) ? '1' : '0';
|
||||
const moderationSettingValue = (key: string) => Number(data.moderationSettings[key]) ? '1' : '0';
|
||||
const parseScopes = (value: unknown) => {
|
||||
@@ -65,19 +74,33 @@
|
||||
enabled: '1',
|
||||
note: ''
|
||||
});
|
||||
const blankBulkModerationTerms = (): BulkModerationTermDraft => ({
|
||||
listType: 'allow',
|
||||
terms: '',
|
||||
scopes: ['comment', 'profile_bio', 'guide'],
|
||||
enabled: '1',
|
||||
note: ''
|
||||
});
|
||||
|
||||
let comingSoonEnabled = $state(settingValue('coming_soon_enabled'));
|
||||
let accountCreationEnabled = $state(settingValue('account_creation_enabled'));
|
||||
let sectionValues = $state<Record<string, string>>(Object.fromEntries(
|
||||
sectionDefinitions.map((section) => [section.key, settingValue(`${section.key}_enabled`)])
|
||||
));
|
||||
let reviewing = $state(false);
|
||||
let reviewingModerationPolicy = $state(false);
|
||||
let launchPanelOpen = $state(true);
|
||||
let accountPanelOpen = $state(true);
|
||||
let availabilityPanelOpen = $state(true);
|
||||
let screeningPanelOpen = $state(true);
|
||||
let publicationPanelOpen = $state(true);
|
||||
let coveragePanelOpen = $state(true);
|
||||
let limitsPanelOpen = $state(true);
|
||||
let thresholdsPanelOpen = $state(true);
|
||||
let termsPanelOpen = $state(true);
|
||||
let archivingModerationTerm = $state<any>(null);
|
||||
let editingModerationTerm = $state<ModerationTermDraft | null>(null);
|
||||
let bulkAddingModerationTerms = $state<BulkModerationTermDraft | null>(null);
|
||||
let synchronizedAt = $state('');
|
||||
let moderationSynchronizedAt = $state('');
|
||||
let handledResultToken = $state('');
|
||||
let handledModerationResultToken = $state('');
|
||||
let automatedEnabled = $state(moderationSettingValue('automated_enabled'));
|
||||
let autoPublishClean = $state(moderationSettingValue('auto_publish_clean'));
|
||||
@@ -136,11 +159,6 @@
|
||||
synchronizedAt = updatedAt;
|
||||
synchronize();
|
||||
});
|
||||
$effect(() => {
|
||||
if (!form?.siteSettingsSaved || !form.resultToken || form.resultToken === handledResultToken) return;
|
||||
handledResultToken = form.resultToken;
|
||||
reviewing = false;
|
||||
});
|
||||
$effect(() => {
|
||||
const syncKey = `${data.moderationSettings.updated_at}:${data.moderationSettings.policy_version}`;
|
||||
if (syncKey === moderationSynchronizedAt) return;
|
||||
@@ -151,71 +169,45 @@
|
||||
const token = String(form?.moderationResultToken ?? '');
|
||||
if (!token || token === handledModerationResultToken) return;
|
||||
handledModerationResultToken = token;
|
||||
if (form?.moderationPolicySaved) reviewingModerationPolicy = false;
|
||||
if (form?.moderationTermSaved) editingModerationTerm = null;
|
||||
if (form?.moderationTermSaved) {
|
||||
editingModerationTerm = editingModerationTerm?.id ? null : blankModerationTerm();
|
||||
}
|
||||
if (form?.bulkModerationTermsSaved) bulkAddingModerationTerms = blankBulkModerationTerms();
|
||||
if (form?.moderationTermArchived) archivingModerationTerm = null;
|
||||
});
|
||||
|
||||
const currentValue = (key: string) => Number(data.settings[key]) ? 'ENABLED' : 'DISABLED';
|
||||
const proposedValue = (value: string) => value === '1' ? 'ENABLED' : 'DISABLED';
|
||||
const changes = $derived.by(() => {
|
||||
const rows = [
|
||||
{
|
||||
label: 'LAUNCH GATE',
|
||||
before: Number(data.settings.coming_soon_enabled) ? 'COMING SOON' : 'PUBLIC LAUNCH',
|
||||
after: comingSoonEnabled === '1' ? 'COMING SOON' : 'PUBLIC LAUNCH'
|
||||
},
|
||||
{
|
||||
label: 'ACCOUNT CREATION',
|
||||
before: currentValue('account_creation_enabled'),
|
||||
after: proposedValue(accountCreationEnabled)
|
||||
},
|
||||
...sectionDefinitions.map((section) => ({
|
||||
label: section.label,
|
||||
before: currentValue(`${section.key}_enabled`),
|
||||
after: proposedValue(sectionValues[section.key] ?? '0')
|
||||
}))
|
||||
];
|
||||
return rows.filter((row) => row.before !== row.after);
|
||||
});
|
||||
const moderationChanges = $derived.by(() => {
|
||||
const settingsRows = [
|
||||
{ label: 'AUTOMATED MODERATION', before: Number(data.moderationSettings.automated_enabled) ? 'ENABLED' : 'DISABLED', after: automatedEnabled === '1' ? 'ENABLED' : 'DISABLED' },
|
||||
{ label: 'CLEAN CONTENT', before: Number(data.moderationSettings.auto_publish_clean) ? 'AUTO-PUBLISH' : 'HOLD', after: autoPublishClean === '1' ? 'AUTO-PUBLISH' : 'HOLD' },
|
||||
{ label: 'PROVIDER ERROR', before: Number(data.moderationSettings.hold_on_provider_error) ? 'HOLD' : 'PUBLISH', after: holdOnProviderError === '1' ? 'HOLD' : 'PUBLISH' },
|
||||
{ label: 'COMMENTS', before: Number(data.moderationSettings.scan_comments) ? 'SCAN' : 'BYPASS', after: scanComments === '1' ? 'SCAN' : 'BYPASS' },
|
||||
{ label: 'PROFILE BIOS', before: Number(data.moderationSettings.scan_profiles) ? 'SCAN' : 'BYPASS', after: scanProfiles === '1' ? 'SCAN' : 'BYPASS' },
|
||||
{ label: 'GUIDES', before: Number(data.moderationSettings.scan_guides) ? 'SCAN' : 'BYPASS', after: scanGuides === '1' ? 'SCAN' : 'BYPASS' },
|
||||
{ label: 'AVATARS', before: Number(data.moderationSettings.scan_avatars) ? 'SCAN' : 'BYPASS', after: scanAvatars === '1' ? 'SCAN' : 'BYPASS' },
|
||||
{ label: 'PII DETECTION', before: Number(data.moderationSettings.detect_pii) ? 'ENABLED' : 'DISABLED', after: detectPii === '1' ? 'ENABLED' : 'DISABLED' },
|
||||
{ label: 'PROVIDER MODEL', before: String(data.moderationSettings.provider_model), after: providerModel },
|
||||
{ label: 'MAXIMUM LINKS', before: String(data.moderationSettings.max_links), after: maxLinks },
|
||||
{ label: 'DUPLICATE WINDOW', before: `${data.moderationSettings.duplicate_window_seconds} SEC`, after: `${duplicateWindowSeconds} SEC` }
|
||||
];
|
||||
const thresholdRows = data.moderationThresholds.flatMap((threshold: any) => {
|
||||
const category = String(threshold.category);
|
||||
const draft = thresholdDrafts[category];
|
||||
if (!draft) return [];
|
||||
return [
|
||||
{
|
||||
label: `${categoryLabel(category)} STATE`,
|
||||
before: Number(threshold.enabled) ? 'ENABLED' : 'DISABLED',
|
||||
after: draft.enabled === '1' ? 'ENABLED' : 'DISABLED'
|
||||
},
|
||||
{
|
||||
label: `${categoryLabel(category)} REVIEW`,
|
||||
before: String(threshold.review_threshold),
|
||||
after: draft.review
|
||||
},
|
||||
{
|
||||
label: `${categoryLabel(category)} PRIORITY`,
|
||||
before: String(threshold.priority_threshold),
|
||||
after: draft.priority
|
||||
}
|
||||
];
|
||||
});
|
||||
return [...settingsRows, ...thresholdRows].filter((row) => row.before !== row.after);
|
||||
});
|
||||
const launchChanged = $derived(comingSoonEnabled !== settingValue('coming_soon_enabled'));
|
||||
const accountChanged = $derived(accountCreationEnabled !== settingValue('account_creation_enabled'));
|
||||
const availabilityChanged = $derived(sectionDefinitions.some((section) =>
|
||||
sectionValues[section.key] !== settingValue(`${section.key}_enabled`)
|
||||
));
|
||||
const screeningChanged = $derived(
|
||||
automatedEnabled !== moderationSettingValue('automated_enabled')
|
||||
|| providerModel !== String(data.moderationSettings.provider_model)
|
||||
);
|
||||
const publicationChanged = $derived(
|
||||
autoPublishClean !== moderationSettingValue('auto_publish_clean')
|
||||
|| holdOnProviderError !== moderationSettingValue('hold_on_provider_error')
|
||||
);
|
||||
const coverageChanged = $derived(
|
||||
scanComments !== moderationSettingValue('scan_comments')
|
||||
|| scanProfiles !== moderationSettingValue('scan_profiles')
|
||||
|| scanGuides !== moderationSettingValue('scan_guides')
|
||||
|| scanAvatars !== moderationSettingValue('scan_avatars')
|
||||
);
|
||||
const limitsChanged = $derived(
|
||||
detectPii !== moderationSettingValue('detect_pii')
|
||||
|| maxLinks !== String(data.moderationSettings.max_links)
|
||||
|| duplicateWindowSeconds !== String(data.moderationSettings.duplicate_window_seconds)
|
||||
);
|
||||
const thresholdsChanged = $derived(data.moderationThresholds.some((threshold: any) => {
|
||||
const draft = thresholdDrafts[String(threshold.category)];
|
||||
return draft && (
|
||||
draft.enabled !== (Number(threshold.enabled) ? '1' : '0')
|
||||
|| draft.review !== String(threshold.review_threshold)
|
||||
|| draft.priority !== String(threshold.priority_threshold)
|
||||
);
|
||||
}));
|
||||
const editModerationTerm = (term: any) => {
|
||||
editingModerationTerm = {
|
||||
id: String(term.id),
|
||||
@@ -227,6 +219,15 @@
|
||||
note: String(term.note ?? '')
|
||||
};
|
||||
};
|
||||
const bulkModerationTermCount = $derived.by(() => {
|
||||
if (!bulkAddingModerationTerms) return 0;
|
||||
return new Set(
|
||||
bulkAddingModerationTerms.terms.normalize('NFKC')
|
||||
.split(/[\r\n,;]+/u)
|
||||
.map((term) => term.trim().replace(/\s+/g, ' ').toLocaleLowerCase())
|
||||
.filter(Boolean)
|
||||
).size;
|
||||
});
|
||||
const date = (value: unknown) => value
|
||||
? new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' }).format(new Date(String(value)))
|
||||
: 'INITIAL CONFIGURATION';
|
||||
@@ -235,9 +236,8 @@
|
||||
<svelte:head><title>Site controls — ISAC</title></svelte:head>
|
||||
<svelte:window onkeydown={(event) => {
|
||||
if (event.key !== 'Escape') return;
|
||||
reviewing = false;
|
||||
reviewingModerationPolicy = false;
|
||||
archivingModerationTerm = null;
|
||||
bulkAddingModerationTerms = null;
|
||||
}}/>
|
||||
|
||||
<div class="module-toolbar site-control-toolbar">
|
||||
@@ -254,39 +254,54 @@
|
||||
</div>
|
||||
|
||||
<div class="site-control-grid">
|
||||
<section class="site-control-panel launch-control">
|
||||
<header><div><span>01 / LAUNCH GATE</span><h3>PUBLIC NETWORK STATE</h3></div><b class:restricted={comingSoonEnabled === '1'}>{comingSoonEnabled === '1' ? 'GATED' : 'LIVE'}</b></header>
|
||||
<p>The Coming Soon gate takes priority over individual sections for signed-out visitors. Signed-in agents bypass the launch gate, but section availability still applies.</p>
|
||||
<SegmentedChoice legend="LAUNCH STATE" name="launchStateDraft" options={launchOptions} bind:value={comingSoonEnabled}/>
|
||||
</section>
|
||||
<CollapsibleSection id="launch-gate" class="site-control-panel launch-control" kicker="01 / LAUNCH GATE" title="PUBLIC NETWORK STATE" status={comingSoonEnabled === '1' ? 'GATED' : 'LIVE'} statusTone={comingSoonEnabled === '1' ? 'danger' : 'success'} bind:open={launchPanelOpen}>
|
||||
<PendingForm action="?/saveLaunchGate" successMessage="Public network state saved.">
|
||||
{#snippet children(pending)}
|
||||
<p>The Coming Soon gate takes priority over individual sections for signed-out visitors. Signed-in agents bypass the launch gate, but section availability still applies.</p>
|
||||
<SegmentedChoice legend="LAUNCH STATE" name="comingSoonEnabled" options={launchOptions} bind:value={comingSoonEnabled}/>
|
||||
{#if form?.launchGateError}<p class="error" role="alert">{form.launchGateError}</p>{/if}
|
||||
<div class="section-save-row">
|
||||
<a class="secondary" href="/coming-soon">PREVIEW PAGE ↗</a>
|
||||
<button class="primary" disabled={pending || !launchChanged} aria-busy={pending}>{pending ? 'SAVING…' : 'SAVE NETWORK STATE'}</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</PendingForm>
|
||||
</CollapsibleSection>
|
||||
|
||||
<section class="site-control-panel account-control">
|
||||
<header><div><span>02 / IDENTITY INTAKE</span><h3>ACCOUNT CREATION</h3></div><b class:restricted={accountCreationEnabled === '0'}>{accountCreationEnabled === '1' ? 'OPEN' : 'CLOSED'}</b></header>
|
||||
<p>Controls creation of new accounts across supported registration methods. Existing agents retain their configured sign-in methods.</p>
|
||||
<SegmentedChoice legend="REGISTRATION STATE" name="accountCreationDraft" options={binaryOptions} bind:value={accountCreationEnabled}/>
|
||||
</section>
|
||||
<CollapsibleSection id="account-creation" class="site-control-panel account-control" kicker="02 / IDENTITY INTAKE" title="ACCOUNT CREATION" status={accountCreationEnabled === '1' ? 'OPEN' : 'CLOSED'} statusTone={accountCreationEnabled === '1' ? 'success' : 'danger'} bind:open={accountPanelOpen}>
|
||||
<PendingForm action="?/saveAccountCreation" successMessage="Account creation state saved.">
|
||||
{#snippet children(pending)}
|
||||
<p>Controls creation of new accounts across supported registration methods. Existing agents retain their configured sign-in methods.</p>
|
||||
<SegmentedChoice legend="REGISTRATION STATE" name="accountCreationEnabled" options={binaryOptions} bind:value={accountCreationEnabled}/>
|
||||
{#if form?.accountCreationError}<p class="error" role="alert">{form.accountCreationError}</p>{/if}
|
||||
<div class="section-save-row">
|
||||
<span>{accountChanged ? 'UNSAVED CHANGE' : 'SYNCHRONIZED'}</span>
|
||||
<button class="primary" disabled={pending || !accountChanged} aria-busy={pending}>{pending ? 'SAVING…' : 'SAVE ACCOUNT STATE'}</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</PendingForm>
|
||||
</CollapsibleSection>
|
||||
|
||||
<section class="site-control-panel public-sections">
|
||||
<header><div><span>03 / PUBLIC SECTIONS</span><h3>SECTION AVAILABILITY</h3></div><b>{sectionDefinitions.filter((section) => sectionValues[section.key] === '1').length} / {sectionDefinitions.length} ONLINE</b></header>
|
||||
<p>Section controls apply after the Launch Gate. Disabled sections disappear from navigation and reject direct access and mutations for every agent.</p>
|
||||
<div class="section-control-list">
|
||||
{#each sectionDefinitions as section}
|
||||
<article>
|
||||
<div><strong>{section.label}</strong><small>{section.description}</small></div>
|
||||
<SegmentedChoice legend={`${section.label} STATE`} name={`${section.key}StateDraft`} options={binaryOptions} bind:value={sectionValues[section.key]}/>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{#if form?.siteSettingsError}<p class="error">{form.siteSettingsError}</p>{/if}
|
||||
<div class="site-control-actions">
|
||||
<div><span>CHANGE SET</span><b>{changes.length ? `${changes.length} PENDING` : 'SYNCHRONIZED'}</b><small>UPDATED BY {data.settings.updated_by_name ?? 'SYSTEM BOOTSTRAP'}</small></div>
|
||||
<div class="site-control-action-buttons">
|
||||
<a class="secondary" href="/coming-soon">PREVIEW PAGE ↗</a>
|
||||
<button class="primary" disabled={!changes.length} onclick={() => reviewing = true}>REVIEW CHANGES →</button>
|
||||
</div>
|
||||
<CollapsibleSection id="section-availability" class="site-control-panel public-sections" kicker="03 / PUBLIC SECTIONS" title="SECTION AVAILABILITY" status={`${sectionDefinitions.filter((section) => sectionValues[section.key] === '1').length} / ${sectionDefinitions.length} ONLINE`} statusTone="success" bind:open={availabilityPanelOpen}>
|
||||
<PendingForm action="?/saveSectionAvailability" successMessage="Section availability saved.">
|
||||
{#snippet children(pending)}
|
||||
<p>Section controls apply after the Launch Gate. Disabled sections disappear from navigation and reject direct access and mutations for every agent.</p>
|
||||
<div class="section-control-list">
|
||||
{#each sectionDefinitions as section}
|
||||
<article>
|
||||
<div><strong>{section.label}</strong><small>{section.description}</small></div>
|
||||
<SegmentedChoice legend={`${section.label} STATE`} name={`${section.key}Enabled`} options={binaryOptions} bind:value={sectionValues[section.key]}/>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{#if form?.sectionAvailabilityError}<p class="error" role="alert">{form.sectionAvailabilityError}</p>{/if}
|
||||
<div class="section-save-row">
|
||||
<span>{availabilityChanged ? 'UNSAVED CHANGES' : 'SYNCHRONIZED'}</span>
|
||||
<button class="primary" disabled={pending || !availabilityChanged} aria-busy={pending}>{pending ? 'SAVING…' : 'SAVE SECTION STATES'}</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</PendingForm>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
<section class="community-safety" aria-labelledby="community-safety-heading">
|
||||
@@ -311,79 +326,141 @@
|
||||
{/if}
|
||||
|
||||
<div class="moderation-policy-grid">
|
||||
<section class="moderation-policy-panel moderation-master">
|
||||
<header><div><span>AUTOMATION GATE</span><h3>SCREENING PIPELINE</h3></div><b class:restricted={automatedEnabled === '0'}>{automatedEnabled === '1' ? 'ACTIVE' : 'DISABLED'}</b></header>
|
||||
<p>When enabled, selected content enters automated screening before publication. Reviewers receive exceptions, not a queue of clean submissions.</p>
|
||||
<SegmentedChoice legend="AUTOMATED MODERATION" name="moderationEnabledDraft" options={binaryOptions} bind:value={automatedEnabled}/>
|
||||
<SegmentedChoice legend="PROVIDER MODEL" name="providerModelDraft" options={moderationModelOptions} bind:value={providerModel}/>
|
||||
</section>
|
||||
<CollapsibleSection id="screening-pipeline" class="moderation-policy-panel moderation-master" kicker="AUTOMATION GATE" title="SCREENING PIPELINE" status={automatedEnabled === '1' ? 'ACTIVE' : 'DISABLED'} statusTone={automatedEnabled === '1' ? 'success' : 'danger'} bind:open={screeningPanelOpen}>
|
||||
<PendingForm action="?/saveModerationScreening" successMessage="Screening pipeline saved.">
|
||||
{#snippet children(pending)}
|
||||
<p>When enabled, selected content enters automated screening before publication. Reviewers receive exceptions, not a queue of clean submissions.</p>
|
||||
<SegmentedChoice legend="AUTOMATED MODERATION" name="automatedEnabled" options={binaryOptions} bind:value={automatedEnabled}/>
|
||||
<SegmentedChoice legend="PROVIDER MODEL" name="providerModel" options={moderationModelOptions} bind:value={providerModel}/>
|
||||
{#if form?.moderationScreeningError}<p class="error" role="alert">{form.moderationScreeningError}</p>{/if}
|
||||
<div class="section-save-row"><span>{screeningChanged ? 'UNSAVED CHANGES' : 'SYNCHRONIZED'}</span><button class="primary" disabled={pending || !screeningChanged} aria-busy={pending}>{pending ? 'SAVING…' : 'SAVE SCREENING'}</button></div>
|
||||
{/snippet}
|
||||
</PendingForm>
|
||||
</CollapsibleSection>
|
||||
|
||||
<section class="moderation-policy-panel moderation-publication">
|
||||
<header><div><span>PUBLICATION POLICY</span><h3>VISIBILITY DECISIONS</h3></div><b>{autoPublishClean === '1' ? 'EXCEPTION QUEUE' : 'REVIEW ALL'}</b></header>
|
||||
<p>Clean content can publish immediately. Flagged, uncertain, or failed checks remain hidden for Community Oversight.</p>
|
||||
<SegmentedChoice legend="CLEAN CONTENT" name="autoPublishCleanDraft" options={[{value:'1',label:'AUTO-PUBLISH'},{value:'0',label:'HOLD FOR REVIEW'}]} bind:value={autoPublishClean}/>
|
||||
<SegmentedChoice legend="PROVIDER FAILURE" name="holdOnProviderErrorDraft" options={[{value:'1',label:'HOLD CONTENT'},{value:'0',label:'ALLOW CONTENT'}]} bind:value={holdOnProviderError}/>
|
||||
</section>
|
||||
<CollapsibleSection id="visibility-decisions" class="moderation-policy-panel moderation-publication" kicker="PUBLICATION POLICY" title="VISIBILITY DECISIONS" status={autoPublishClean === '1' ? 'EXCEPTION QUEUE' : 'REVIEW ALL'} bind:open={publicationPanelOpen}>
|
||||
<PendingForm action="?/saveModerationPublication" successMessage="Visibility decisions saved.">
|
||||
{#snippet children(pending)}
|
||||
<p>Clean content can publish immediately. Flagged, uncertain, or failed checks remain hidden for Community Oversight.</p>
|
||||
<SegmentedChoice legend="CLEAN CONTENT" name="autoPublishClean" options={[{value:'1',label:'AUTO-PUBLISH'},{value:'0',label:'HOLD FOR REVIEW'}]} bind:value={autoPublishClean}/>
|
||||
<SegmentedChoice legend="PROVIDER FAILURE" name="holdOnProviderError" options={[{value:'1',label:'HOLD CONTENT'},{value:'0',label:'ALLOW CONTENT'}]} bind:value={holdOnProviderError}/>
|
||||
{#if form?.moderationPublicationError}<p class="error" role="alert">{form.moderationPublicationError}</p>{/if}
|
||||
<div class="section-save-row"><span>{publicationChanged ? 'UNSAVED CHANGES' : 'SYNCHRONIZED'}</span><button class="primary" disabled={pending || !publicationChanged} aria-busy={pending}>{pending ? 'SAVING…' : 'SAVE VISIBILITY'}</button></div>
|
||||
{/snippet}
|
||||
</PendingForm>
|
||||
</CollapsibleSection>
|
||||
|
||||
<section class="moderation-policy-panel moderation-scopes">
|
||||
<header><div><span>CONTENT COVERAGE</span><h3>SCREENED CHANNELS</h3></div><b>{[scanComments,scanProfiles,scanGuides,scanAvatars].filter((value) => value === '1').length} / 4 ACTIVE</b></header>
|
||||
<p>Disable a channel only when its submission flow should bypass automated checks.</p>
|
||||
<div class="moderation-scope-list">
|
||||
<div><span><b>COMMENTS</b><small>Build, guide, and profile discussion</small></span><SegmentedChoice legend="COMMENTS" name="scanCommentsDraft" options={binaryOptions} bind:value={scanComments}/></div>
|
||||
<div><span><b>PROFILE BIOS</b><small>Public Agent biography revisions</small></span><SegmentedChoice legend="PROFILE BIOS" name="scanProfilesDraft" options={binaryOptions} bind:value={scanProfiles}/></div>
|
||||
<div><span><b>GUIDES</b><small>Long-form community field manuals</small></span><SegmentedChoice legend="GUIDES" name="scanGuidesDraft" options={binaryOptions} bind:value={scanGuides}/></div>
|
||||
<div><span><b>AVATARS</b><small>Uploaded custom Agent portraits</small></span><SegmentedChoice legend="AVATARS" name="scanAvatarsDraft" options={binaryOptions} bind:value={scanAvatars}/></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="moderation-policy-panel moderation-local-rules">
|
||||
<header><div><span>DETERMINISTIC CHECKS</span><h3>LOCAL SAFETY LIMITS</h3></div><b>{detectPii === '1' ? 'PII ACTIVE' : 'PII DISABLED'}</b></header>
|
||||
<p>Local rules supplement provider categories with privacy, link, duplicate, and site-specific terminology checks.</p>
|
||||
<SegmentedChoice legend="PII DETECTION" name="detectPiiDraft" options={binaryOptions} bind:value={detectPii}/>
|
||||
<div class="moderation-limit-grid">
|
||||
<label class="field">MAXIMUM LINKS<input type="number" min="0" max="20" step="1" bind:value={maxLinks}/><small>0–20 links per submission</small></label>
|
||||
<label class="field">DUPLICATE WINDOW · SECONDS<input type="number" min="0" max="3600" step="1" bind:value={duplicateWindowSeconds}/><small>0 disables duplicate timing</small></label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="moderation-threshold-panel">
|
||||
<header>
|
||||
<div><span>PROVIDER CATEGORY MATRIX</span><h3>REVIEW & PRIORITY THRESHOLDS</h3></div>
|
||||
<p>Review scores hold content for Oversight. Priority scores must be equal to or higher than review scores and move urgent exceptions to the front of the queue.</p>
|
||||
</header>
|
||||
<div class="moderation-threshold-table">
|
||||
<div class="moderation-threshold-row heading"><span>CATEGORY</span><span>STATE</span><span>REVIEW · 0–1</span><span>PRIORITY · 0–1</span></div>
|
||||
{#each data.moderationThresholds as threshold}
|
||||
{@const category = String(threshold.category)}
|
||||
{@const draft = thresholdDrafts[category]}
|
||||
{#if draft}
|
||||
<div class="moderation-threshold-row">
|
||||
<div><strong>{categoryLabel(category)}</strong><small>{draft.enabled === '1' ? 'AUTOMATED DECISIONS ENABLED' : 'CATEGORY IGNORED'}</small></div>
|
||||
<SegmentedChoice legend={`${categoryLabel(category)} STATE`} name={`${category}EnabledDraft`} options={binaryOptions} bind:value={draft.enabled}/>
|
||||
<label class="field"><span class="sr-only">{categoryLabel(category)} review threshold</span><input type="number" min="0" max="1" step="0.01" bind:value={draft.review}/></label>
|
||||
<label class="field"><span class="sr-only">{categoryLabel(category)} priority threshold</span><input type="number" min="0" max="1" step="0.01" bind:value={draft.priority}/></label>
|
||||
<CollapsibleSection id="screened-channels" class="moderation-policy-panel moderation-scopes" kicker="CONTENT COVERAGE" title="SCREENED CHANNELS" status={`${[scanComments,scanProfiles,scanGuides,scanAvatars].filter((value) => value === '1').length} / 4 ACTIVE`} statusTone="success" bind:open={coveragePanelOpen}>
|
||||
<PendingForm action="?/saveModerationCoverage" successMessage="Screened channels saved.">
|
||||
{#snippet children(pending)}
|
||||
<p>Disable a channel only when its submission flow should bypass automated checks.</p>
|
||||
<div class="moderation-scope-list">
|
||||
<div><span><b>COMMENTS</b><small>Build, guide, and profile discussion</small></span><SegmentedChoice legend="COMMENTS" name="scanComments" options={binaryOptions} bind:value={scanComments}/></div>
|
||||
<div><span><b>PROFILE BIOS</b><small>Public Agent biography revisions</small></span><SegmentedChoice legend="PROFILE BIOS" name="scanProfiles" options={binaryOptions} bind:value={scanProfiles}/></div>
|
||||
<div><span><b>GUIDES</b><small>Long-form community field manuals</small></span><SegmentedChoice legend="GUIDES" name="scanGuides" options={binaryOptions} bind:value={scanGuides}/></div>
|
||||
<div><span><b>AVATARS</b><small>Uploaded custom Agent portraits</small></span><SegmentedChoice legend="AVATARS" name="scanAvatars" options={binaryOptions} bind:value={scanAvatars}/></div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{#if form?.moderationCoverageError}<p class="error" role="alert">{form.moderationCoverageError}</p>{/if}
|
||||
<div class="section-save-row"><span>{coverageChanged ? 'UNSAVED CHANGES' : 'SYNCHRONIZED'}</span><button class="primary" disabled={pending || !coverageChanged} aria-busy={pending}>{pending ? 'SAVING…' : 'SAVE CHANNELS'}</button></div>
|
||||
{/snippet}
|
||||
</PendingForm>
|
||||
</CollapsibleSection>
|
||||
|
||||
{#if form?.moderationPolicyError}<p class="error moderation-policy-error" role="alert">{form.moderationPolicyError}</p>{/if}
|
||||
<div class="moderation-policy-actions">
|
||||
<div>
|
||||
<span>POLICY CHANGE SET</span>
|
||||
<b>{moderationChanges.length ? `${moderationChanges.length} PENDING` : 'SYNCHRONIZED'}</b>
|
||||
<small>UPDATED BY {data.moderationSettings.updated_by_name ?? 'SYSTEM BOOTSTRAP'} · {date(data.moderationSettings.updated_at)}</small>
|
||||
</div>
|
||||
<button class="primary" disabled={!moderationChanges.length} onclick={() => reviewingModerationPolicy = true}>REVIEW POLICY →</button>
|
||||
<CollapsibleSection id="local-safety-limits" class="moderation-policy-panel moderation-local-rules" kicker="DETERMINISTIC CHECKS" title="LOCAL SAFETY LIMITS" status={detectPii === '1' ? 'PII ACTIVE' : 'PII DISABLED'} statusTone={detectPii === '1' ? 'success' : 'danger'} bind:open={limitsPanelOpen}>
|
||||
<PendingForm action="?/saveModerationLimits" successMessage="Local safety limits saved.">
|
||||
{#snippet children(pending)}
|
||||
<p>Local rules supplement provider categories with privacy, link, duplicate, and site-specific terminology checks.</p>
|
||||
<SegmentedChoice legend="PII DETECTION" name="detectPii" options={binaryOptions} bind:value={detectPii}/>
|
||||
<div class="moderation-limit-grid">
|
||||
<label class="field">MAXIMUM LINKS<input name="maxLinks" type="number" min="0" max="20" step="1" bind:value={maxLinks}/><small>0–20 links per submission</small></label>
|
||||
<label class="field">DUPLICATE WINDOW · SECONDS<input name="duplicateWindowSeconds" type="number" min="0" max="3600" step="1" bind:value={duplicateWindowSeconds}/><small>0 disables duplicate timing</small></label>
|
||||
</div>
|
||||
{#if form?.moderationLimitsError}<p class="error" role="alert">{form.moderationLimitsError}</p>{/if}
|
||||
<div class="section-save-row"><span>{limitsChanged ? 'UNSAVED CHANGES' : 'SYNCHRONIZED'}</span><button class="primary" disabled={pending || !limitsChanged} aria-busy={pending}>{pending ? 'SAVING…' : 'SAVE LOCAL LIMITS'}</button></div>
|
||||
{/snippet}
|
||||
</PendingForm>
|
||||
</CollapsibleSection>
|
||||
</div>
|
||||
|
||||
<CollapsibleSection id="moderation-thresholds" class="moderation-threshold-panel" kicker="PROVIDER CATEGORY MATRIX" title="REVIEW & PRIORITY THRESHOLDS" status={`${data.moderationThresholds.length} CATEGORIES`} bind:open={thresholdsPanelOpen}>
|
||||
<PendingForm action="?/saveModerationThresholds" successMessage="Review and priority thresholds saved.">
|
||||
{#snippet children(pending)}
|
||||
<p class="moderation-threshold-description">Review scores hold content for Oversight. Priority scores must be equal to or higher than review scores and move urgent exceptions to the front of the queue.</p>
|
||||
<div class="moderation-threshold-table">
|
||||
<div class="moderation-threshold-row heading"><span>CATEGORY</span><span>STATE</span><span>REVIEW · 0–1</span><span>PRIORITY · 0–1</span></div>
|
||||
{#each data.moderationThresholds as threshold}
|
||||
{@const category = String(threshold.category)}
|
||||
{@const draft = thresholdDrafts[category]}
|
||||
{#if draft}
|
||||
<div class="moderation-threshold-row">
|
||||
<div><strong>{categoryLabel(category)}</strong><small>{draft.enabled === '1' ? 'AUTOMATED DECISIONS ENABLED' : 'CATEGORY IGNORED'}</small></div>
|
||||
<SegmentedChoice legend={`${categoryLabel(category)} STATE`} name={`thresholdEnabled:${category}`} options={binaryOptions} bind:value={draft.enabled}/>
|
||||
<label class="field"><span class="sr-only">{categoryLabel(category)} review threshold</span><input name={`reviewThreshold:${category}`} type="number" min="0" max="1" step="0.01" bind:value={draft.review}/></label>
|
||||
<label class="field"><span class="sr-only">{categoryLabel(category)} priority threshold</span><input name={`priorityThreshold:${category}`} type="number" min="0" max="1" step="0.01" bind:value={draft.priority}/></label>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{#if form?.moderationThresholdsError}<p class="error" role="alert">{form.moderationThresholdsError}</p>{/if}
|
||||
<div class="section-save-row"><span>{thresholdsChanged ? 'UNSAVED CHANGES' : 'SYNCHRONIZED'}</span><button class="primary" disabled={pending || !thresholdsChanged} aria-busy={pending}>{pending ? 'SAVING…' : 'SAVE THRESHOLDS'}</button></div>
|
||||
{/snippet}
|
||||
</PendingForm>
|
||||
</CollapsibleSection>
|
||||
|
||||
<CollapsibleSection id="moderation-terms" class="moderation-term-section" kicker="DETERMINISTIC RULE REGISTRY" title="ALLOWED & BANNED TERMS" status={`${data.moderationTermPagination.total} RULE${Number(data.moderationTermPagination.total) === 1 ? '' : 'S'}`} bind:open={termsPanelOpen}>
|
||||
<div class="moderation-rule-toolbar">
|
||||
<div><span>DETERMINISTIC RULE REGISTRY</span><h3>ALLOWED & BANNED TERMS</h3><p>Normalized rules run alongside provider checks and can target comments, profile bios, guides, or any combination.</p></div>
|
||||
<button class="primary" disabled={Boolean(editingModerationTerm)} onclick={() => editingModerationTerm = blankModerationTerm()}>ADD RULE +</button>
|
||||
<p>Normalized rules run alongside provider checks and can target comments, profile bios, guides, or any combination.</p>
|
||||
<div class="moderation-rule-toolbar-actions">
|
||||
<button class="secondary" disabled={Boolean(editingModerationTerm || bulkAddingModerationTerms)} onclick={() => bulkAddingModerationTerms = blankBulkModerationTerms()}>BULK ADD</button>
|
||||
<button class="primary" disabled={Boolean(editingModerationTerm || bulkAddingModerationTerms)} onclick={() => editingModerationTerm = blankModerationTerm()}>ADD RULE +</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<RegistryPagination pagination={data.moderationTermPagination} namespace="terms" search={data.moderationTermSearch} searchPlaceholder="SEARCH TERM, TYPE, OR NOTE"/>
|
||||
|
||||
{#if bulkAddingModerationTerms}
|
||||
{@const bulkDraft = bulkAddingModerationTerms}
|
||||
<section class="dashboard-editor horizontal-editor moderation-term-editor moderation-term-bulk-editor" aria-label="Bulk add moderation terms">
|
||||
<div class="editor-title">
|
||||
<div><p class="eyebrow">BULK DETERMINISTIC RULES</p><h3>PASTE TERM LIST</h3></div>
|
||||
<button type="button" aria-label="Close bulk moderation term editor" onclick={() => bulkAddingModerationTerms = null}>×</button>
|
||||
</div>
|
||||
<PendingForm action="?/bulkAddModerationTerms" successMessage="Moderation terms added.">
|
||||
{#snippet children(pending)}
|
||||
<div class="moderation-term-bulk-grid">
|
||||
<label class="field moderation-term-bulk-values">
|
||||
<span>TERMS & PHRASES</span>
|
||||
<textarea name="terms" rows="9" maxlength="50000" required bind:value={bulkDraft.terms} placeholder={'weapon\nkill\nmachine gun\nassault rifle'}></textarea>
|
||||
<small>Enter one term or phrase per line. Commas and semicolons also work. Word matching is exact, so add forms such as kill, kills, killed, and killing separately.</small>
|
||||
</label>
|
||||
<div class="moderation-term-bulk-options">
|
||||
<SegmentedChoice legend="RULE EFFECT" name="listType" options={termListOptions} bind:value={bulkDraft.listType}/>
|
||||
<SegmentedChoice legend="RULE STATE" name="enabled" options={binaryOptions} bind:value={bulkDraft.enabled}/>
|
||||
<SegmentedChoice legend="CONTENT SCOPES" name="scopes" options={termScopeOptions} mode="checkbox" bind:selected={bulkDraft.scopes}/>
|
||||
<label class="field">SHARED OPERATIONAL NOTE<textarea name="note" rows="3" maxlength="500" bind:value={bulkDraft.note} placeholder="Optional reason or policy reference for this batch"></textarea></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="moderation-term-bulk-summary" aria-live="polite">
|
||||
<span>IMPORT PREVIEW</span>
|
||||
<strong class:restricted={bulkModerationTermCount > 250}>{bulkModerationTermCount} UNIQUE {bulkModerationTermCount === 1 ? 'ENTRY' : 'ENTRIES'}</strong>
|
||||
<small>{bulkDraft.listType === 'allow' ? 'ALLOW' : 'BAN'} · {bulkDraft.scopes.length} {bulkDraft.scopes.length === 1 ? 'SCOPE' : 'SCOPES'} · LIMIT 250</small>
|
||||
</div>
|
||||
{#if form?.bulkModerationTermsError}<p class="error" role="alert">{form.bulkModerationTermsError}</p>{/if}
|
||||
{#if form?.bulkModerationTermsSaved}
|
||||
<p class="moderation-term-bulk-success" role="status">
|
||||
{form.bulkModerationTermsAdded} {Number(form.bulkModerationTermsAdded) === 1 ? 'rule' : 'rules'} added.
|
||||
{#if Number(form.bulkModerationTermsSkipped)} {form.bulkModerationTermsSkipped} duplicate {Number(form.bulkModerationTermsSkipped) === 1 ? 'entry was' : 'entries were'} skipped.{/if}
|
||||
</p>
|
||||
{/if}
|
||||
<div class="form-actions">
|
||||
<button type="button" class="secondary" disabled={pending} onclick={() => bulkAddingModerationTerms = null}>CLOSE</button>
|
||||
<button class="primary" disabled={pending || !bulkModerationTermCount || bulkModerationTermCount > 250 || !bulkDraft.scopes.length} aria-busy={pending}>{pending ? 'ADDING TERMS…' : `ADD ${bulkModerationTermCount} ${bulkModerationTermCount === 1 ? 'RULE' : 'RULES'} →`}</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</PendingForm>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if editingModerationTerm}
|
||||
{@const termDraft = editingModerationTerm}
|
||||
<section class="dashboard-editor horizontal-editor moderation-term-editor" aria-label={editingModerationTerm.id ? `Edit ${editingModerationTerm.value}` : 'Add moderation rule'}>
|
||||
@@ -421,92 +498,21 @@
|
||||
<span class:restricted={!Number(term.enabled)}>{Number(term.enabled) ? 'ENABLED' : 'ARCHIVED'}</span>
|
||||
<span><b>{date(term.updated_at)}</b><small>{term.updated_by_name ?? 'SYSTEM'}</small></span>
|
||||
<div class="moderation-rule-actions">
|
||||
<button class="secondary" disabled={Boolean(editingModerationTerm)} onclick={() => editModerationTerm(term)}>EDIT</button>
|
||||
{#if Number(term.enabled)}<button class="danger" disabled={Boolean(editingModerationTerm)} onclick={() => archivingModerationTerm = term}>ARCHIVE</button>{/if}
|
||||
<button class="secondary" disabled={Boolean(editingModerationTerm || bulkAddingModerationTerms)} onclick={() => editModerationTerm(term)}>EDIT</button>
|
||||
{#if Number(term.enabled)}<button class="danger" disabled={Boolean(editingModerationTerm || bulkAddingModerationTerms)} onclick={() => archivingModerationTerm = term}>ARCHIVE</button>{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="moderation-rule-empty"><strong>NO DETERMINISTIC TERMS CONFIGURED</strong><span>Provider thresholds and local structural checks remain active. Add a term only for a specific SHD.build policy need.</span></div>
|
||||
{#if data.moderationTermSearch}
|
||||
<div class="moderation-rule-empty"><strong>NO TERMS MATCH THIS SEARCH</strong><span>Clear the search or try a term, rule type, match mode, or operational note.</span></div>
|
||||
{:else}
|
||||
<div class="moderation-rule-empty"><strong>NO DETERMINISTIC TERMS CONFIGURED</strong><span>Provider thresholds and local structural checks remain active. Add a term only for a specific SHD.build policy need.</span></div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</CollapsibleSection>
|
||||
</section>
|
||||
|
||||
{#if reviewing}
|
||||
<div class="isac-modal-backdrop" role="presentation">
|
||||
<div class="isac-confirm-modal site-control-review" role="dialog" aria-modal="true" aria-labelledby="site-control-review-title">
|
||||
<div class="editor-title">
|
||||
<div><p class="eyebrow">CONFIRM SITE ACCESS PROFILE</p><h3 id="site-control-review-title">{changes.length} OPERATIONAL CHANGE{changes.length === 1 ? '' : 'S'}</h3></div>
|
||||
<button type="button" aria-label="Close site control review" onclick={() => reviewing = false}>×</button>
|
||||
</div>
|
||||
<p>These settings change which public SHD.build services agents can reach. Review the full change set before deployment.</p>
|
||||
<div class="site-control-change-list">
|
||||
{#each changes as change}
|
||||
<div><strong>{change.label}</strong><span>{change.before}</span><b>→</b><span class:restricted={change.after === 'DISABLED' || change.after === 'COMING SOON'}>{change.after}</span></div>
|
||||
{/each}
|
||||
</div>
|
||||
<PendingForm action="?/save" successMessage="Site access settings updated.">
|
||||
{#snippet children(pending)}
|
||||
<input type="hidden" name="confirmation" value="apply-site-access-settings"/>
|
||||
<input type="hidden" name="comingSoonEnabled" value={comingSoonEnabled}/>
|
||||
<input type="hidden" name="accountCreationEnabled" value={accountCreationEnabled}/>
|
||||
{#each sectionDefinitions as section}<input type="hidden" name={`${section.key}Enabled`} value={sectionValues[section.key]}/>{/each}
|
||||
<div class="form-actions">
|
||||
<button type="button" class="secondary" onclick={() => reviewing = false} disabled={pending}>CANCEL</button>
|
||||
<button class="primary" disabled={pending || !changes.length} aria-busy={pending}>{pending ? 'APPLYING PROFILE…' : 'CONFIRM SITE CONTROLS →'}</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</PendingForm>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if reviewingModerationPolicy}
|
||||
<div class="isac-modal-backdrop" role="presentation">
|
||||
<div class="isac-confirm-modal moderation-policy-review" role="dialog" aria-modal="true" aria-labelledby="moderation-policy-review-title">
|
||||
<div class="editor-title">
|
||||
<div><p class="eyebrow">CONFIRM COMMUNITY SAFETY POLICY</p><h3 id="moderation-policy-review-title">{moderationChanges.length} POLICY CHANGE{moderationChanges.length === 1 ? '' : 'S'}</h3></div>
|
||||
<button type="button" aria-label="Close moderation policy review" onclick={() => reviewingModerationPolicy = false}>×</button>
|
||||
</div>
|
||||
<p>These settings control which submissions are scanned, published, or held for Community Oversight. Applying them creates policy version {Number(data.moderationSettings.policy_version) + 1}.</p>
|
||||
<div class="site-control-change-list moderation-change-list">
|
||||
{#each moderationChanges as change}
|
||||
<div><strong>{change.label}</strong><span>{change.before}</span><b>→</b><span class:restricted={['DISABLED','HOLD','BYPASS'].includes(change.after)}>{change.after}</span></div>
|
||||
{/each}
|
||||
</div>
|
||||
<PendingForm action="?/saveModerationPolicy" successMessage="Community moderation policy updated.">
|
||||
{#snippet children(pending)}
|
||||
<input type="hidden" name="confirmation" value="apply-community-moderation-policy"/>
|
||||
<input type="hidden" name="automatedEnabled" value={automatedEnabled}/>
|
||||
<input type="hidden" name="autoPublishClean" value={autoPublishClean}/>
|
||||
<input type="hidden" name="holdOnProviderError" value={holdOnProviderError}/>
|
||||
<input type="hidden" name="scanComments" value={scanComments}/>
|
||||
<input type="hidden" name="scanProfiles" value={scanProfiles}/>
|
||||
<input type="hidden" name="scanGuides" value={scanGuides}/>
|
||||
<input type="hidden" name="scanAvatars" value={scanAvatars}/>
|
||||
<input type="hidden" name="detectPii" value={detectPii}/>
|
||||
<input type="hidden" name="providerModel" value={providerModel}/>
|
||||
<input type="hidden" name="maxLinks" value={maxLinks}/>
|
||||
<input type="hidden" name="duplicateWindowSeconds" value={duplicateWindowSeconds}/>
|
||||
{#each data.moderationThresholds as threshold}
|
||||
{@const category = String(threshold.category)}
|
||||
{@const draft = thresholdDrafts[category]}
|
||||
{#if draft}
|
||||
<input type="hidden" name={`thresholdEnabled:${category}`} value={draft.enabled}/>
|
||||
<input type="hidden" name={`reviewThreshold:${category}`} value={draft.review}/>
|
||||
<input type="hidden" name={`priorityThreshold:${category}`} value={draft.priority}/>
|
||||
{/if}
|
||||
{/each}
|
||||
{#if form?.moderationPolicyError}<p class="error" role="alert">{form.moderationPolicyError}</p>{/if}
|
||||
<div class="form-actions">
|
||||
<button type="button" class="secondary" disabled={pending} onclick={() => reviewingModerationPolicy = false}>CANCEL</button>
|
||||
<button class="primary" disabled={pending || !moderationChanges.length} aria-busy={pending}>{pending ? 'APPLYING POLICY…' : 'CONFIRM POLICY →'}</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
</PendingForm>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if archivingModerationTerm}
|
||||
<div class="isac-modal-backdrop" role="presentation">
|
||||
<div class="isac-confirm-modal moderation-archive-review" role="dialog" aria-modal="true" aria-labelledby="moderation-archive-title">
|
||||
@@ -533,15 +539,16 @@
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.site-control-toolbar{align-items:stretch}.site-control-state{display:grid;align-content:center;min-width:230px;padding:10px 14px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-orange);background:var(--shd-surface)}.site-control-state span,.site-control-state small,.site-control-panel header span,.site-control-actions span,.site-control-actions small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.site-control-state strong{color:var(--shd-success);font-size:21px}.site-control-state strong.restricted,.site-control-panel b.restricted,.site-control-change-list span.restricted{color:var(--shd-danger)}.site-control-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.site-control-panel{display:grid;align-content:start;gap:10px;padding:12px;border:1px solid var(--shd-border);background:var(--shd-surface)}.site-control-panel>header{display:flex;justify-content:space-between;gap:12px;align-items:start;padding-bottom:9px;border-bottom:1px solid var(--shd-border)}.site-control-panel h3,.site-control-panel p{margin:0}.site-control-panel>p{color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}.site-control-panel header b{color:var(--shd-success);font-size:var(--shd-type-supporting);letter-spacing:.8px}.public-sections{grid-column:1/-1}.section-control-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.section-control-list article{display:grid;grid-template-columns:minmax(0,1fr) minmax(220px,.8fr);gap:10px;align-items:center;padding:9px;border:1px solid var(--shd-border);background:#0d1011}.section-control-list article>div{display:grid;gap:2px}.section-control-list small{color:var(--shd-text-muted)}.site-control-actions{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-top:9px;padding:10px;border:1px solid var(--shd-border);background:var(--shd-surface)}.site-control-actions>div:first-child{display:grid}.site-control-action-buttons{display:flex;gap:7px}.site-control-action-buttons>a{display:flex;align-items:center}.site-control-actions b{color:var(--shd-orange)}.site-control-review>p{margin:0;padding:12px;color:var(--shd-text-muted)}.site-control-change-list{display:grid;gap:4px;padding:0 12px 12px}.site-control-change-list>div{display:grid;grid-template-columns:minmax(140px,1fr) minmax(110px,.7fr) 28px minmax(110px,.7fr);gap:7px;align-items:center;padding:8px;border:1px solid var(--shd-border);background:#0d1011}.site-control-change-list>div>b{text-align:center;color:var(--shd-orange)}.site-control-change-list span:last-child{font-weight:900}.site-control-review .form-actions{padding:12px;border-top:1px solid var(--shd-border)}
|
||||
.community-safety{display:grid;gap:8px;margin-top:20px;padding-top:18px;border-top:1px solid var(--shd-orange)}.community-safety-head{display:flex;align-items:stretch;justify-content:space-between;gap:12px}.community-safety-head>div:first-child{max-width:72ch}.community-safety-head h2,.community-safety-head p,.moderation-policy-panel h3,.moderation-policy-panel p,.moderation-threshold-panel h3,.moderation-threshold-panel p,.moderation-rule-toolbar h3,.moderation-rule-toolbar p{margin:0}.community-safety-head>div:first-child>p:last-child,.moderation-policy-panel>p,.moderation-threshold-panel>header>p,.moderation-rule-toolbar p{color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45}.moderation-network-state{min-width:270px;display:grid;align-content:center;padding:10px 14px;border:1px solid var(--shd-border);background:var(--shd-surface)}.moderation-network-state span,.moderation-network-state small,.moderation-policy-panel header span,.moderation-threshold-panel header span,.moderation-policy-actions span,.moderation-policy-actions small,.moderation-rule-toolbar span{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.moderation-network-state strong{color:var(--shd-success);font-size:21px}.moderation-network-state strong.restricted,.moderation-policy-panel header b.restricted,.moderation-rule-row>span.restricted{color:var(--shd-danger)}.moderation-provider-warning{display:flex;align-items:center;gap:10px;padding:9px 11px;border:1px solid var(--shd-danger);background:#1b1111}.moderation-provider-warning b{flex:0 0 auto;color:var(--shd-danger);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.moderation-provider-warning span{color:#d9aaa0;font-size:var(--shd-type-supporting)}
|
||||
.moderation-policy-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.moderation-policy-panel{min-width:0;display:grid;align-content:start;gap:10px;padding:12px;border:1px solid var(--shd-border);background:var(--shd-surface)}.moderation-policy-panel>header{display:flex;align-items:start;justify-content:space-between;gap:12px;padding-bottom:9px;border-bottom:1px solid var(--shd-border)}.moderation-policy-panel header span{display:block}.moderation-policy-panel header b{color:var(--shd-success);font-size:var(--shd-type-supporting);letter-spacing:.8px}.moderation-master :global(.segmented-choice),.moderation-publication :global(.segmented-choice),.moderation-local-rules :global(.segmented-choice){margin:0}.moderation-scope-list{display:grid;gap:5px}.moderation-scope-list>div{display:grid;grid-template-columns:minmax(0,1fr) minmax(190px,.7fr);align-items:center;gap:10px;padding:8px;border:1px solid var(--shd-border-soft);background:#0d1011}.moderation-scope-list>div>span{min-width:0;display:grid;gap:2px}.moderation-scope-list small{color:var(--shd-text-muted);overflow-wrap:anywhere}.moderation-scope-list :global(.segmented-choice){margin:0}.moderation-limit-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.moderation-limit-grid .field{margin:0}.moderation-limit-grid small{color:var(--shd-text-muted)}
|
||||
.moderation-threshold-panel{border:1px solid var(--shd-border);background:var(--shd-surface)}.moderation-threshold-panel>header{display:grid;grid-template-columns:minmax(220px,.7fr) minmax(0,1.3fr);gap:16px;padding:12px;border-bottom:1px solid var(--shd-border)}.moderation-threshold-table{overflow-x:auto}.moderation-threshold-row{min-width:760px;display:grid;grid-template-columns:minmax(220px,1.2fr) minmax(210px,1fr) minmax(120px,.55fr) minmax(120px,.55fr);align-items:center;gap:8px;padding:7px 9px;border-top:1px solid var(--shd-border-soft)}.moderation-threshold-row:first-child{border-top:0}.moderation-threshold-row.heading{min-height:34px;background:#0d1011;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.moderation-threshold-row>div:first-child{min-width:0;display:grid;gap:2px}.moderation-threshold-row>div:first-child strong{overflow-wrap:anywhere}.moderation-threshold-row>div:first-child small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.moderation-threshold-row :global(.segmented-choice),.moderation-threshold-row .field{margin:0}.moderation-threshold-row .field input{margin:0}.moderation-policy-error{margin:0}.moderation-policy-actions{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px;border:1px solid var(--shd-border);background:var(--shd-surface)}.moderation-policy-actions>div{display:grid}.moderation-policy-actions b{color:var(--shd-orange)}
|
||||
.moderation-rule-toolbar{display:flex;align-items:end;justify-content:space-between;gap:12px;margin-top:12px;padding-bottom:10px;border-bottom:1px solid var(--shd-border)}.moderation-rule-toolbar>div{max-width:72ch}.moderation-term-editor{margin:0}.moderation-term-grid{display:grid;grid-template-columns:minmax(240px,1.25fr) repeat(3,minmax(170px,.65fr));gap:8px;padding:10px}.moderation-term-grid>.moderation-term-value{grid-row:span 2}.moderation-term-grid>:global(.segmented-choice){align-self:end;margin:0}.moderation-term-grid>:global(.segmented-choice):nth-of-type(4){grid-column:2/4}.moderation-term-note{grid-column:4;grid-row:2}.moderation-term-grid .field{margin:0}.moderation-term-grid small{color:var(--shd-text-muted)}.moderation-term-editor :global(form>.error){margin:0 10px}.moderation-term-editor :global(.form-actions){padding:10px;border-top:1px solid var(--shd-border)}
|
||||
.site-control-toolbar{align-items:stretch}.site-control-state{display:grid;align-content:center;min-width:230px;padding:10px 14px;border:1px solid var(--shd-border);border-left:2px solid var(--shd-orange);background:var(--shd-surface)}.site-control-state span,.site-control-state small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.site-control-state strong{color:var(--shd-success);font-size:var(--shd-type-body)}.site-control-state strong.restricted{color:var(--shd-danger)}.site-control-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}:global(.site-control-panel){min-width:0}:global(.site-control-panel) p{margin:0 0 10px;color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}:global(.public-sections){grid-column:1/-1}.section-control-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:7px}.section-control-list article{display:grid;grid-template-columns:minmax(0,1fr) minmax(220px,.8fr);gap:10px;align-items:center;padding:9px;border:1px solid var(--shd-border);background:#0d1011}.section-control-list article>div{display:grid;gap:2px}.section-control-list small{color:var(--shd-text-muted)}.section-save-row{display:flex;align-items:center;justify-content:flex-end;gap:8px;margin-top:10px;padding-top:10px;border-top:1px solid var(--shd-border-soft)}.section-save-row>span{margin-right:auto;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.7px}.section-save-row>a{display:flex;align-items:center}.section-save-row .primary{min-width:170px}
|
||||
.community-safety{display:grid;gap:8px;margin-top:20px;padding-top:18px;border-top:1px solid var(--shd-orange)}.community-safety-head{display:flex;align-items:stretch;justify-content:space-between;gap:12px}.community-safety-head>div:first-child{max-width:72ch}.community-safety-head h2,.community-safety-head p,:global(.moderation-policy-panel) p,:global(.moderation-threshold-panel) p,.moderation-rule-toolbar p{margin:0}.community-safety-head>div:first-child>p:last-child,:global(.moderation-policy-panel) p,.moderation-threshold-description,.moderation-rule-toolbar p{color:var(--shd-text-muted);font-size:var(--shd-type-supporting);line-height:1.45}.moderation-network-state{min-width:270px;display:grid;align-content:center;padding:10px 14px;border:1px solid var(--shd-border);background:var(--shd-surface)}.moderation-network-state span,.moderation-network-state small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.moderation-network-state strong{color:var(--shd-success);font-size:var(--shd-type-body)}.moderation-network-state strong.restricted,.moderation-rule-row>span.restricted{color:var(--shd-danger)}.moderation-provider-warning{display:flex;align-items:center;gap:10px;padding:9px 11px;border:1px solid var(--shd-danger);background:color-mix(in srgb,var(--shd-danger) 10%,var(--shd-surface))}.moderation-provider-warning b{flex:0 0 auto;color:var(--shd-danger);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.moderation-provider-warning span{color:var(--shd-danger);font-size:var(--shd-type-supporting)}
|
||||
.moderation-policy-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}:global(.moderation-policy-panel){min-width:0}:global(.moderation-policy-panel) p{margin-bottom:10px}:global(.moderation-master) :global(.segmented-choice),:global(.moderation-publication) :global(.segmented-choice),:global(.moderation-local-rules) :global(.segmented-choice){margin:0}.moderation-scope-list{display:grid;gap:5px}.moderation-scope-list>div{display:grid;grid-template-columns:minmax(0,1fr) minmax(190px,.7fr);align-items:center;gap:10px;padding:8px;border:1px solid var(--shd-border-soft);background:#0d1011}.moderation-scope-list>div>span{min-width:0;display:grid;gap:2px}.moderation-scope-list small{color:var(--shd-text-muted);overflow-wrap:anywhere}.moderation-scope-list :global(.segmented-choice){margin:0}.moderation-limit-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.moderation-limit-grid .field{margin:0}.moderation-limit-grid small{color:var(--shd-text-muted)}
|
||||
.moderation-threshold-description{padding-bottom:10px}.moderation-threshold-table{overflow-x:auto}.moderation-threshold-row{min-width:760px;display:grid;grid-template-columns:minmax(220px,1.2fr) minmax(210px,1fr) minmax(120px,.55fr) minmax(120px,.55fr);align-items:center;gap:8px;padding:7px 9px;border-top:1px solid var(--shd-border-soft)}.moderation-threshold-row:first-child{border-top:0}.moderation-threshold-row.heading{min-height:34px;background:#0d1011;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.moderation-threshold-row>div:first-child{min-width:0;display:grid;gap:2px}.moderation-threshold-row>div:first-child strong{overflow-wrap:anywhere}.moderation-threshold-row>div:first-child small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry)}.moderation-threshold-row :global(.segmented-choice),.moderation-threshold-row .field{margin:0}.moderation-threshold-row .field input{margin:0}
|
||||
.moderation-rule-toolbar{display:flex;align-items:end;justify-content:space-between;gap:12px;padding-bottom:10px}.moderation-rule-toolbar>p{max-width:72ch}.moderation-rule-toolbar-actions{display:flex;gap:7px}:global(.moderation-term-section) :global(.registry-pagination){margin:0 0 8px}.moderation-term-editor{margin:0}.moderation-term-grid{display:grid;grid-template-columns:minmax(240px,1.25fr) repeat(3,minmax(170px,.65fr));gap:8px;padding:10px}.moderation-term-grid>.moderation-term-value{grid-row:span 2}.moderation-term-grid>:global(.segmented-choice){align-self:end;margin:0}.moderation-term-grid>:global(.segmented-choice):nth-of-type(4){grid-column:2/4}.moderation-term-note{grid-column:4;grid-row:2}.moderation-term-grid .field{margin:0}.moderation-term-grid small{color:var(--shd-text-muted)}.moderation-term-editor :global(form>.error){margin:0 10px}.moderation-term-editor :global(.form-actions){padding:10px;border-top:1px solid var(--shd-border)}
|
||||
.moderation-term-bulk-grid{display:grid;grid-template-columns:minmax(300px,1.2fr) minmax(360px,.8fr);gap:10px;padding:10px}.moderation-term-bulk-grid .field{margin:0}.moderation-term-bulk-values textarea{min-height:244px;resize:vertical}.moderation-term-bulk-values small{color:var(--shd-text-muted);line-height:1.45}.moderation-term-bulk-options{display:grid;align-content:start;gap:8px}.moderation-term-bulk-options :global(.segmented-choice){margin:0}.moderation-term-bulk-summary{display:grid;grid-template-columns:minmax(140px,.5fr) minmax(180px,.8fr) minmax(0,1.2fr);align-items:center;gap:8px;margin:0 10px 10px;padding:9px 10px;border:1px solid var(--shd-border);background:#0d1011}.moderation-term-bulk-summary span,.moderation-term-bulk-summary small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.6px}.moderation-term-bulk-summary strong{color:var(--shd-success)}.moderation-term-bulk-summary strong.restricted{color:var(--shd-danger)}.moderation-term-bulk-success{margin:0 10px 10px;padding:8px 10px;border:1px solid var(--shd-success);color:var(--shd-success);font-size:var(--shd-type-supporting);font-weight:900}
|
||||
.moderation-rule-registry{border:1px solid var(--shd-border);background:var(--shd-surface)}.moderation-rule-row{display:grid;grid-template-columns:minmax(230px,1.25fr) minmax(180px,.9fr) minmax(90px,.45fr) minmax(160px,.7fr) minmax(150px,.55fr);align-items:center;gap:9px;padding:9px;border-top:1px solid var(--shd-border-soft)}.moderation-rule-row:first-child{border-top:0}.moderation-rule-row.heading{min-height:34px;background:#0d1011;color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);font-weight:900;letter-spacing:.8px}.moderation-rule-row.archived{opacity:.65}.moderation-rule-row>div:first-child,.moderation-rule-row>span:nth-child(4){min-width:0;display:grid;gap:3px}.moderation-rule-row>div:first-child small{color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);overflow-wrap:anywhere}.moderation-rule-row>span{font-size:var(--shd-type-telemetry);font-weight:900}.moderation-rule-row>span:nth-child(4) small{color:var(--shd-text-muted);font-weight:400}.moderation-rule-scopes{display:flex;flex-wrap:wrap;gap:4px}.moderation-rule-scopes b{padding:3px 5px;border:1px solid var(--shd-border-strong);color:var(--shd-text-muted);font-size:var(--shd-type-telemetry);letter-spacing:.5px}.moderation-rule-actions{display:grid;grid-template-columns:1fr 1fr;gap:5px}.moderation-rule-actions button{min-height:36px;padding:0 8px}.moderation-rule-empty{min-height:130px;display:grid;place-content:center;gap:5px;padding:20px;text-align:center}.moderation-rule-empty strong{font-size:var(--shd-type-telemetry);letter-spacing:.8px}.moderation-rule-empty span{color:var(--shd-text-muted);font-size:var(--shd-type-supporting)}
|
||||
.moderation-policy-review{width:min(930px,calc(100vw - 28px));max-height:calc(100vh - 28px);overflow:auto}.moderation-policy-review>p{margin:0;padding:12px;color:var(--shd-text-muted)}.moderation-change-list{max-height:48vh;overflow:auto}.moderation-policy-review :global(.form-actions),.moderation-archive-review :global(.form-actions){padding:12px;border-top:1px solid var(--shd-border)}.moderation-policy-review :global(form>.error),.moderation-archive-review :global(form>.error){margin:0 12px 12px}.moderation-archive-review{width:min(620px,calc(100vw - 28px))}.moderation-archive-consequence{display:grid;gap:5px;padding:14px}.moderation-archive-consequence b{color:var(--shd-danger);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.moderation-archive-consequence p{margin:0;color:var(--shd-text-muted);line-height:1.45}
|
||||
.moderation-archive-review :global(.form-actions){padding:12px;border-top:1px solid var(--shd-border)}.moderation-archive-review :global(form>.error){margin:0 12px 12px}.moderation-archive-review{width:min(620px,calc(100vw - 28px))}.moderation-archive-consequence{display:grid;gap:5px;padding:14px}.moderation-archive-consequence b{color:var(--shd-danger);font-size:var(--shd-type-telemetry);letter-spacing:.8px}.moderation-archive-consequence p{margin:0;color:var(--shd-text-muted);line-height:1.45}
|
||||
@media(max-width:1100px){.moderation-term-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.moderation-term-grid>.moderation-term-value{grid-row:auto}.moderation-term-grid>:global(.segmented-choice):nth-of-type(4),.moderation-term-note{grid-column:auto;grid-row:auto}.moderation-rule-registry{overflow-x:auto}.moderation-rule-row{min-width:900px}}
|
||||
@media(max-width:900px){.site-control-grid,.moderation-policy-grid{grid-template-columns:1fr}.public-sections{grid-column:auto}.section-control-list{grid-template-columns:1fr}.community-safety-head{display:grid}.moderation-network-state{min-width:0}.moderation-threshold-panel>header{grid-template-columns:1fr}.moderation-provider-warning{align-items:start;flex-direction:column}}
|
||||
@media(max-width:650px){.site-control-state{min-width:0}.section-control-list article,.moderation-scope-list>div,.moderation-limit-grid,.moderation-term-grid{grid-template-columns:1fr}.site-control-actions,.moderation-policy-actions,.moderation-rule-toolbar{display:grid}.site-control-action-buttons{display:grid;grid-template-columns:1fr}.site-control-action-buttons>*,.moderation-policy-actions>button,.moderation-rule-toolbar>button{width:100%;justify-content:center}.site-control-change-list>div{grid-template-columns:1fr auto 1fr}.site-control-change-list strong{grid-column:1/-1}.site-control-change-list span:last-child{text-align:right}.moderation-scope-list :global(.segmented-choice span),.moderation-policy-panel :global(.segmented-choice span){min-height:44px}.moderation-policy-review :global(.form-actions),.moderation-archive-review :global(.form-actions){display:grid;grid-template-columns:1fr 1fr}.moderation-term-note{grid-column:auto}.moderation-term-editor :global(.form-actions){display:grid;grid-template-columns:1fr 1fr}}
|
||||
@media(prefers-reduced-motion:reduce){.moderation-rule-row,.moderation-policy-panel button,.moderation-rule-toolbar button{transition:none}}
|
||||
@media(max-width:900px){.site-control-grid,.moderation-policy-grid,.moderation-term-bulk-grid{grid-template-columns:1fr}:global(.public-sections){grid-column:auto}.section-control-list{grid-template-columns:1fr}.community-safety-head{display:grid}.moderation-network-state{min-width:0}.moderation-provider-warning{align-items:start;flex-direction:column}}
|
||||
@media(max-width:650px){.site-control-state{min-width:0}.section-control-list article,.moderation-scope-list>div,.moderation-limit-grid,.moderation-term-grid{grid-template-columns:1fr}.moderation-rule-toolbar,.section-save-row{display:grid}.moderation-rule-toolbar-actions{display:grid;grid-template-columns:1fr}.moderation-rule-toolbar-actions>button,.section-save-row>*{width:100%;justify-content:center}.section-save-row>span{text-align:center}.moderation-scope-list :global(.segmented-choice span),:global(.moderation-policy-panel) :global(.segmented-choice span),.moderation-term-bulk-options :global(.segmented-choice span){min-height:44px}.moderation-term-bulk-options :global(.segmented-choice.multi>div){height:auto;grid-template-columns:1fr}.moderation-archive-review :global(.form-actions){display:grid;grid-template-columns:1fr 1fr}.moderation-term-note{grid-column:auto}.moderation-term-editor :global(.form-actions){display:grid;grid-template-columns:1fr 1fr}}
|
||||
@media(prefers-reduced-motion:reduce){.moderation-rule-row,:global(.moderation-policy-panel) button,.moderation-rule-toolbar button{transition:none}}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
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>
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/session', () => ({
|
||||
getSessionUser: vi.fn(async () => ({ id: 'operator-1' }))
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/auth/authorization', () => ({
|
||||
requirePermission: vi.fn(async (user) => user)
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/audit', () => ({
|
||||
recordIsacAudit: vi.fn(async (event) => {
|
||||
mocks.audits.push(event);
|
||||
})
|
||||
}));
|
||||
|
||||
vi.mock('$lib/server/site-access', () => ({
|
||||
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 };
|
||||
});
|
||||
|
||||
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 };
|
||||
}
|
||||
})
|
||||
}))
|
||||
}));
|
||||
|
||||
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
|
||||
};
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
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');
|
||||
|
||||
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 })
|
||||
})
|
||||
]);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
expect(result).toMatchObject({ status: 400 });
|
||||
expect(mocks.moderationSettings).toMatchObject({ policy_version: 4 });
|
||||
expect(mocks.audits).toHaveLength(0);
|
||||
expect(mocks.invalidatePolicy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user