Compare commits
1
Commits
48653af3dc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7f1c65d17 |
+17
-13
@@ -132,18 +132,22 @@ priority and assign it to an eligible administrator or moderator. Filter the cas
|
|||||||
queue by status, priority, or assignment; active cases appear first, ordered by
|
queue by status, priority, or assignment; active cases appear first, ordered by
|
||||||
priority and most recent update.
|
priority and most recent update.
|
||||||
|
|
||||||
Move active cases between **Open**, **In review**, and **Escalated**, recording a
|
Use **Change status** to move a case between **Open**, **In progress**, and
|
||||||
reason for workflow changes. Close a case with one of these outcomes: **Resolved**,
|
**Closed**, recording a reason for each transition. Assignment is separate:
|
||||||
**Dismissed**, **Warning issued**, **Restriction applied**, **No action needed**,
|
a case can have an owner at any stage. To resume a closed case, change its status
|
||||||
**Duplicate**, **Appeal upheld**, or **Appeal denied**. Every decision requires a
|
to Open or In progress. Evidence, assessments, actions, and earlier decisions
|
||||||
reason. Reopen a closed case when new evidence or an appeal needs review; earlier
|
remain in its history. Editing case details does not change its status.
|
||||||
decisions remain in its history. Editing case details does not reopen a case.
|
|
||||||
|
Existing closed outcomes are migrated to Closed. Previously escalated cases
|
||||||
|
become In progress. Their previous statuses are recorded in history; existing
|
||||||
|
decision reasons, action references, assignments, and follow-ups are preserved.
|
||||||
|
|
||||||
Use **Edit assessment** to keep a concise current summary, list the applicable
|
Use **Edit assessment** to keep a concise current summary, list the applicable
|
||||||
rules, and record a finding for each concern: hate speech, harassment, threats,
|
rules, and record a finding for each concern: hate speech, harassment, threats,
|
||||||
safeguarding, spam, cheating, or another concern. Each finding is **Substantiated**,
|
safeguarding, spam, cheating, or another concern. Each finding is **Substantiated**,
|
||||||
**Inconclusive**, or **Unsubstantiated**, with a short rationale. Findings describe
|
**Inconclusive**, or **Unsubstantiated**, with a short rationale. Findings describe
|
||||||
the evidence; the case's workflow status and final outcome remain separate.
|
the evidence; case status tracks whether work remains. Record the decision and
|
||||||
|
its rationale in the assessment summary.
|
||||||
Every saved assessment retains its author, timestamp, and complete previous
|
Every saved assessment retains its author, timestamp, and complete previous
|
||||||
revision. Assessments can be revised after closure without reopening the case.
|
revision. Assessments can be revised after closure without reopening the case.
|
||||||
|
|
||||||
@@ -174,9 +178,9 @@ external messaging delivery.
|
|||||||
Apply a ban or mute directly from an active case. The case fixes the target game
|
Apply a ban or mute directly from an active case. The case fixes the target game
|
||||||
and GUID and records the resulting restriction alongside the evidence. The same
|
and GUID and records the resulting restriction alongside the evidence. The same
|
||||||
role, scope, and duration limits apply as elsewhere in Moderation. Applying a
|
role, scope, and duration limits apply as elsewhere in Moderation. Applying a
|
||||||
restriction leaves the case active so staff can finish reviewing it. Selecting a
|
restriction leaves the case active so staff can finish reviewing it. Changing case status
|
||||||
closure outcome records a decision; it does not itself send a warning, create a
|
records workflow progress; it does not send a warning, create a restriction, or
|
||||||
restriction, or revoke one. Linked restrictions can be revoked separately with
|
revoke one. Linked restrictions can be revoked separately with
|
||||||
the required permission and reason, including after a case closes. Reopening a
|
the required permission and reason, including after a case closes. Reopening a
|
||||||
case also leaves its restrictions in place. Check enforcement coverage after
|
case also leaves its restrictions in place. Check enforcement coverage after
|
||||||
applying or revoking a restriction.
|
applying or revoking a restriction.
|
||||||
@@ -186,9 +190,9 @@ mute already recorded for this player with the case. This preserves the original
|
|||||||
action, author, time, delivery state, and expiry; it does not issue another action
|
action, author, time, delivery state, and expiry; it does not issue another action
|
||||||
or extend a restriction. The same action can support more than one related case.
|
or extend a restriction. The same action can support more than one related case.
|
||||||
Linking requires the current case revision and a reason, and appears in its timeline.
|
Linking requires the current case revision and a reason, and appears in its timeline.
|
||||||
When closing with **Warning issued** or **Restriction applied**, optionally reference
|
Linked records show what actually happened, including failed or uncertain delivery.
|
||||||
one of the linked records. Warning references require a confirmed warning; failed
|
They remain visible when a case closes; status changes do not require choosing
|
||||||
or uncertain delivery remains visible without being presented as a confirmed warning.
|
or re-entering an action.
|
||||||
|
|
||||||
Staff notes are explicitly labeled as notes. In match chat, select up to 100
|
Staff notes are explicitly labeled as notes. In match chat, select up to 100
|
||||||
messages and choose **Attach to case** to open a case or add them to an existing
|
messages and choose **Attach to case** to open a case or add them to an existing
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export type ModerationActionKind = 'warn' | 'message' | 'kick' | 'announce';
|
|||||||
export type ModerationState =
|
export type ModerationState =
|
||||||
'queued' | 'delivered' | 'confirmed' | 'failed' | 'expired' | 'unknown';
|
'queued' | 'delivered' | 'confirmed' | 'failed' | 'expired' | 'unknown';
|
||||||
|
|
||||||
|
/** Legacy decisions retained for historical events and older API clients, never lifecycle states. */
|
||||||
export const moderationCaseOutcomes = [
|
export const moderationCaseOutcomes = [
|
||||||
'resolved',
|
'resolved',
|
||||||
'dismissed',
|
'dismissed',
|
||||||
@@ -16,12 +17,7 @@ export const moderationCaseOutcomes = [
|
|||||||
'appeal_upheld',
|
'appeal_upheld',
|
||||||
'appeal_denied'
|
'appeal_denied'
|
||||||
] as const;
|
] as const;
|
||||||
export const moderationCaseStatuses = [
|
export const moderationCaseStatuses = ['open', 'in_review', 'closed'] as const;
|
||||||
'open',
|
|
||||||
'in_review',
|
|
||||||
'escalated',
|
|
||||||
...moderationCaseOutcomes
|
|
||||||
] as const;
|
|
||||||
export type ModerationCaseStatus = (typeof moderationCaseStatuses)[number];
|
export type ModerationCaseStatus = (typeof moderationCaseStatuses)[number];
|
||||||
export type ModerationCasePriority = 'low' | 'normal' | 'high' | 'urgent';
|
export type ModerationCasePriority = 'low' | 'normal' | 'high' | 'urgent';
|
||||||
export const moderationCaseRelations = ['same_incident', 'appeal_of', 'duplicate_of'] as const;
|
export const moderationCaseRelations = ['same_incident', 'appeal_of', 'duplicate_of'] as const;
|
||||||
@@ -31,9 +27,13 @@ export const moderationCaseRelationLabels: Record<ModerationCaseRelation, string
|
|||||||
appeal_of: 'Appeal of',
|
appeal_of: 'Appeal of',
|
||||||
duplicate_of: 'Duplicate of'
|
duplicate_of: 'Duplicate of'
|
||||||
};
|
};
|
||||||
export const moderationCaseStatusLabels: Record<ModerationCaseStatus, string> = {
|
export const moderationCaseStatusLabels: Record<
|
||||||
|
ModerationCaseStatus | (typeof moderationCaseOutcomes)[number] | 'escalated',
|
||||||
|
string
|
||||||
|
> = {
|
||||||
open: 'Open',
|
open: 'Open',
|
||||||
in_review: 'In review',
|
in_review: 'In progress',
|
||||||
|
closed: 'Closed',
|
||||||
escalated: 'Escalated',
|
escalated: 'Escalated',
|
||||||
resolved: 'Resolved',
|
resolved: 'Resolved',
|
||||||
dismissed: 'Dismissed',
|
dismissed: 'Dismissed',
|
||||||
@@ -45,7 +45,7 @@ export const moderationCaseStatusLabels: Record<ModerationCaseStatus, string> =
|
|||||||
appeal_denied: 'Appeal denied'
|
appeal_denied: 'Appeal denied'
|
||||||
};
|
};
|
||||||
export function isModerationCaseOpen(status: ModerationCaseStatus): boolean {
|
export function isModerationCaseOpen(status: ModerationCaseStatus): boolean {
|
||||||
return status === 'open' || status === 'in_review' || status === 'escalated';
|
return status === 'open' || status === 'in_review';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const moderationConcernCategories = [
|
export const moderationConcernCategories = [
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ import {
|
|||||||
migrateModerationCaseAssessments,
|
migrateModerationCaseAssessments,
|
||||||
migrateModerationCaseRelationships,
|
migrateModerationCaseRelationships,
|
||||||
migrateModerationCaseFollowups,
|
migrateModerationCaseFollowups,
|
||||||
migrateModerationCaseReferenceIndexes
|
migrateModerationCaseReferenceIndexes,
|
||||||
|
migrateModerationCaseStates
|
||||||
} from '../moderation/schema.js';
|
} from '../moderation/schema.js';
|
||||||
import type { Migration } from './migration-runner.js';
|
import type { Migration } from './migration-runner.js';
|
||||||
|
|
||||||
@@ -203,6 +204,12 @@ export const migrations: readonly Migration[] = [
|
|||||||
version: 35,
|
version: 35,
|
||||||
name: 'moderation case foreign key reference indexes',
|
name: 'moderation case foreign key reference indexes',
|
||||||
run: migrateModerationCaseReferenceIndexes
|
run: migrateModerationCaseReferenceIndexes
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 36,
|
||||||
|
name: 'moderation case lifecycle states',
|
||||||
|
mode: 'foreign-keys-off',
|
||||||
|
run: migrateModerationCaseStates
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
canRevokeModerationRestriction,
|
canRevokeModerationRestriction,
|
||||||
isModerationCaseOpen,
|
isModerationCaseOpen,
|
||||||
moderationCaseOutcomes,
|
moderationCaseOutcomes,
|
||||||
|
moderationCaseStatuses,
|
||||||
moderationCaseStatusLabels,
|
moderationCaseStatusLabels,
|
||||||
type ModerationCaseStatus,
|
type ModerationCaseStatus,
|
||||||
type ModerationCasePriority,
|
type ModerationCasePriority,
|
||||||
@@ -438,13 +439,9 @@ function updateCase(actor: AuthUser, input: Record<string, unknown>, now: string
|
|||||||
const status = input.status === undefined ? row.status : input.status;
|
const status = input.status === undefined ? row.status : input.status;
|
||||||
if (
|
if (
|
||||||
input.status !== undefined &&
|
input.status !== undefined &&
|
||||||
status !== 'open' &&
|
!moderationCaseStatuses.includes(status as ModerationCaseStatus)
|
||||||
status !== 'in_review' &&
|
|
||||||
status !== 'escalated'
|
|
||||||
)
|
)
|
||||||
throw new AuthError('Choose an active case status, or close the case with an outcome.');
|
throw new AuthError('Choose Open, In progress, or Closed.');
|
||||||
if (input.status !== undefined && !isModerationCaseOpen(row.status))
|
|
||||||
throw new AuthError('Use reopen to reactivate a closed case.', 409);
|
|
||||||
const reason = textField(input.reason, 'Status change reason', 4000, status === row.status);
|
const reason = textField(input.reason, 'Status change reason', 4000, status === row.status);
|
||||||
const changes: Record<string, unknown> = {};
|
const changes: Record<string, unknown> = {};
|
||||||
if (title !== row.title) changes.title = { from: row.title, to: title };
|
if (title !== row.title) changes.title = { from: row.title, to: title };
|
||||||
@@ -585,6 +582,7 @@ export function mutateModeration(actorId: string, input: Record<string, unknown>
|
|||||||
notifyCase(actor.id, row.id, 'evidence', now);
|
notifyCase(actor.id, row.id, 'evidence', now);
|
||||||
return { id: row.id };
|
return { id: row.id };
|
||||||
}
|
}
|
||||||
|
// Older clients may still record decisions, but every closure now stores only Closed.
|
||||||
if (input.op === 'resolve-case') {
|
if (input.op === 'resolve-case') {
|
||||||
const row = caseRow(input.id);
|
const row = caseRow(input.id);
|
||||||
if (row.revision !== input.revision || !isModerationCaseOpen(row.status))
|
if (row.revision !== input.revision || !isModerationCaseOpen(row.status))
|
||||||
@@ -620,7 +618,7 @@ export function mutateModeration(actorId: string, input: Record<string, unknown>
|
|||||||
.prepare(
|
.prepare(
|
||||||
'UPDATE moderation_cases SET status=?,updated_at=?,revision=revision+1 WHERE id=?'
|
'UPDATE moderation_cases SET status=?,updated_at=?,revision=revision+1 WHERE id=?'
|
||||||
)
|
)
|
||||||
.run(outcome, now, row.id);
|
.run('closed', now, row.id);
|
||||||
moderationEvent(
|
moderationEvent(
|
||||||
actor,
|
actor,
|
||||||
`case.${outcome}`,
|
`case.${outcome}`,
|
||||||
|
|||||||
@@ -114,7 +114,7 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
|
|||||||
if (query.view === 'cases') {
|
if (query.view === 'cases') {
|
||||||
if (query.caseStatus === 'active' || query.caseStatus === 'closed') {
|
if (query.caseStatus === 'active' || query.caseStatus === 'closed') {
|
||||||
where.push(
|
where.push(
|
||||||
`p.status ${query.caseStatus === 'closed' ? 'NOT ' : ''}IN ('open','in_review','escalated')`
|
`p.status ${query.caseStatus === 'closed' ? 'NOT ' : ''}IN ('open','in_review')`
|
||||||
);
|
);
|
||||||
} else if (query.caseStatus) {
|
} else if (query.caseStatus) {
|
||||||
where.push('p.status=?');
|
where.push('p.status=?');
|
||||||
@@ -220,7 +220,7 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
|
|||||||
result.cases = rows<Omit<ModerationCase, 'evidence'>>(
|
result.cases = rows<Omit<ModerationCase, 'evidence'>>(
|
||||||
'moderation_cases',
|
'moderation_cases',
|
||||||
'p.id,p.game,p.guid,p.title,p.status,p.priority,p.assignee_id AS assigneeId,(SELECT username FROM auth_users WHERE id=p.assignee_id) AS assignee,p.actor,p.created_at AS createdAt,p.updated_at AS updatedAt,p.revision,p.review_at AS reviewAt,p.review_reason AS reviewReason,p.review_sanction_id AS reviewSanctionId',
|
'p.id,p.game,p.guid,p.title,p.status,p.priority,p.assignee_id AS assigneeId,(SELECT username FROM auth_users WHERE id=p.assignee_id) AS assignee,p.actor,p.created_at AS createdAt,p.updated_at AS updatedAt,p.revision,p.review_at AS reviewAt,p.review_reason AS reviewReason,p.review_sanction_id AS reviewSanctionId',
|
||||||
"CASE WHEN p.review_at<=? THEN 0 ELSE 1 END,CASE WHEN p.review_at<=? THEN p.review_at END,CASE WHEN p.status IN ('open','in_review','escalated') THEN 0 ELSE 1 END,CASE p.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 ELSE 3 END,p.updated_at DESC,p.id",
|
"CASE WHEN p.review_at<=? THEN 0 ELSE 1 END,CASE WHEN p.review_at<=? THEN p.review_at END,CASE WHEN p.status IN ('open','in_review') THEN 0 ELSE 1 END,CASE p.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 ELSE 3 END,p.updated_at DESC,p.id",
|
||||||
[now, now]
|
[now, now]
|
||||||
).map((c) => ({
|
).map((c) => ({
|
||||||
...c,
|
...c,
|
||||||
|
|||||||
@@ -235,3 +235,42 @@ export function migrateModerationCaseReferenceIndexes(handle: Database.Database)
|
|||||||
ON moderation_case_notifications(case_id);
|
ON moderation_case_notifications(case_id);
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Normalize lifecycle state while retaining every prior decision and child record. */
|
||||||
|
export function migrateModerationCaseStates(handle: Database.Database): void {
|
||||||
|
if (handle.pragma('foreign_keys', { simple: true }))
|
||||||
|
throw new Error('The case status migration requires foreign keys to be disabled');
|
||||||
|
const objects = handle
|
||||||
|
.prepare(
|
||||||
|
`SELECT sql FROM sqlite_master
|
||||||
|
WHERE tbl_name='moderation_cases' AND type IN ('index','trigger') AND sql IS NOT NULL`
|
||||||
|
)
|
||||||
|
.all() as { sql: string }[];
|
||||||
|
// Capture old statuses even for imported cases without a corresponding decision event.
|
||||||
|
handle.exec(`INSERT INTO moderation_events(game,guid,actor,action,subject_id,detail,created_at)
|
||||||
|
SELECT game,guid,'Maxis','case.status_migrated',id,
|
||||||
|
json_object('from',status,'to',CASE WHEN status='escalated' THEN 'in_review' ELSE 'closed' END),
|
||||||
|
strftime('%Y-%m-%dT%H:%M:%fZ','now') FROM moderation_cases
|
||||||
|
WHERE status NOT IN ('open','in_review');
|
||||||
|
CREATE TABLE moderation_cases_state (
|
||||||
|
id TEXT PRIMARY KEY, game TEXT NOT NULL, guid TEXT NOT NULL, title TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK(status IN ('open','in_review','closed')),
|
||||||
|
actor_id TEXT NOT NULL, actor TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, revision INTEGER NOT NULL DEFAULT 1,
|
||||||
|
priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('low','normal','high','urgent')),
|
||||||
|
assignee_id TEXT REFERENCES auth_users(id) ON DELETE SET NULL,
|
||||||
|
review_at TEXT, review_reason TEXT NOT NULL DEFAULT '',
|
||||||
|
review_sanction_id TEXT REFERENCES moderation_sanctions(id) ON DELETE SET NULL
|
||||||
|
);
|
||||||
|
INSERT INTO moderation_cases_state
|
||||||
|
SELECT id,game,guid,title,
|
||||||
|
CASE WHEN status IN ('open','in_review') THEN status
|
||||||
|
WHEN status='escalated' THEN 'in_review' ELSE 'closed' END,
|
||||||
|
actor_id,actor,created_at,updated_at,
|
||||||
|
revision+CASE WHEN status IN ('open','in_review') THEN 0 ELSE 1 END,
|
||||||
|
priority,assignee_id,review_at,review_reason,review_sanction_id FROM moderation_cases;
|
||||||
|
DROP TABLE moderation_cases;
|
||||||
|
ALTER TABLE moderation_cases_state RENAME TO moderation_cases;
|
||||||
|
`);
|
||||||
|
for (const object of objects) handle.exec(object.sql);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import {
|
import {
|
||||||
isModerationCaseOpen,
|
moderationCaseStatuses,
|
||||||
moderationCaseOutcomes,
|
|
||||||
moderationCaseStatusLabels,
|
moderationCaseStatusLabels,
|
||||||
type ModerationCoverage,
|
type ModerationCoverage,
|
||||||
type ModerationGame,
|
type ModerationGame,
|
||||||
type ModerationScope,
|
type ModerationScope,
|
||||||
type ModerationCase,
|
type ModerationCase
|
||||||
type ModerationCaseAction
|
|
||||||
} from '$lib/moderation.js';
|
} from '$lib/moderation.js';
|
||||||
import { moderationActionName, moderationGameName } from '$ui/moderation.js';
|
import { moderationGameName } from '$ui/moderation.js';
|
||||||
import { timeFormat } from '$ui/stores/time-zone.js';
|
|
||||||
import { serverTitle } from '$ui/server-labels.js';
|
import { serverTitle } from '$ui/server-labels.js';
|
||||||
import { confirmAction } from '$ui/confirm.js';
|
import { confirmAction } from '$ui/confirm.js';
|
||||||
import Dialog from '../primitives/Dialog.svelte';
|
import Dialog from '../primitives/Dialog.svelte';
|
||||||
@@ -29,7 +26,6 @@
|
|||||||
admin,
|
admin,
|
||||||
seed = {},
|
seed = {},
|
||||||
assignees = [],
|
assignees = [],
|
||||||
linkedActions = [],
|
|
||||||
onsubmitted
|
onsubmitted
|
||||||
}: {
|
}: {
|
||||||
open?: boolean;
|
open?: boolean;
|
||||||
@@ -40,14 +36,12 @@
|
|||||||
| 'note'
|
| 'note'
|
||||||
| 'revoke'
|
| 'revoke'
|
||||||
| 'case-note'
|
| 'case-note'
|
||||||
| 'resolve'
|
| 'case-status'
|
||||||
| 'reopen'
|
|
||||||
| 'edit-case'
|
| 'edit-case'
|
||||||
| 'evidence';
|
| 'evidence';
|
||||||
coverage: ModerationCoverage[];
|
coverage: ModerationCoverage[];
|
||||||
admin: boolean;
|
admin: boolean;
|
||||||
assignees?: { id: string; username: string }[];
|
assignees?: { id: string; username: string }[];
|
||||||
linkedActions?: ModerationCaseAction[];
|
|
||||||
seed?: {
|
seed?: {
|
||||||
game?: ModerationGame;
|
game?: ModerationGame;
|
||||||
guid?: string;
|
guid?: string;
|
||||||
@@ -81,26 +75,10 @@
|
|||||||
let status = $state(untrack(() => seed.status ?? 'open'));
|
let status = $state(untrack(() => seed.status ?? 'open'));
|
||||||
let matchId = $state<number | undefined>(untrack(() => seed.matchId));
|
let matchId = $state<number | undefined>(untrack(() => seed.matchId));
|
||||||
let message = $state(untrack(() => seed.message ?? ''));
|
let message = $state(untrack(() => seed.message ?? ''));
|
||||||
|
const statusUnchanged = $derived(mode === 'case-status' && status === seed.status);
|
||||||
const lockedTarget = $derived(mode === 'restriction' && !!seed.caseId);
|
const lockedTarget = $derived(mode === 'restriction' && !!seed.caseId);
|
||||||
const stateChanged = $derived(mode === 'edit-case' && status !== seed.status);
|
|
||||||
const reasonBytes = $derived(new TextEncoder().encode(reason.trim()).length);
|
const reasonBytes = $derived(new TextEncoder().encode(reason.trim()).length);
|
||||||
const gameText = $derived(mode === 'restriction' || mode === 'action');
|
const gameText = $derived(mode === 'restriction' || mode === 'action');
|
||||||
let resolution = $state('resolved');
|
|
||||||
let actionReference = $state('');
|
|
||||||
const referenceActions = $derived(
|
|
||||||
linkedActions.filter((item) =>
|
|
||||||
resolution === 'warning_issued'
|
|
||||||
? item.type === 'action' && item.kind === 'warn' && item.state === 'confirmed'
|
|
||||||
: resolution === 'restriction_applied' && item.type === 'restriction'
|
|
||||||
)
|
|
||||||
);
|
|
||||||
const referencedAction = $derived(
|
|
||||||
referenceActions.find((item) => `${item.type}:${item.id}` === actionReference)
|
|
||||||
);
|
|
||||||
$effect(() => {
|
|
||||||
void resolution;
|
|
||||||
actionReference = '';
|
|
||||||
});
|
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let uncertain = $state(false);
|
let uncertain = $state(false);
|
||||||
@@ -113,8 +91,7 @@
|
|||||||
note: 'Add private note',
|
note: 'Add private note',
|
||||||
revoke: 'Revoke restriction',
|
revoke: 'Revoke restriction',
|
||||||
'case-note': 'Add case note',
|
'case-note': 'Add case note',
|
||||||
resolve: 'Review case',
|
'case-status': 'Change status',
|
||||||
reopen: 'Reopen case',
|
|
||||||
'edit-case': 'Edit case',
|
'edit-case': 'Edit case',
|
||||||
evidence: 'Attach archived evidence'
|
evidence: 'Attach archived evidence'
|
||||||
}[mode]
|
}[mode]
|
||||||
@@ -184,13 +161,6 @@
|
|||||||
if (mode === 'note') return { op: 'note', game, guid: guid.trim(), text: note.trim() };
|
if (mode === 'note') return { op: 'note', game, guid: guid.trim(), text: note.trim() };
|
||||||
if (mode === 'case-note')
|
if (mode === 'case-note')
|
||||||
return { op: 'case-note', id: seed.id, revision: seed.revision, text: note.trim() };
|
return { op: 'case-note', id: seed.id, revision: seed.revision, text: note.trim() };
|
||||||
if (mode === 'reopen')
|
|
||||||
return {
|
|
||||||
op: 'reopen-case',
|
|
||||||
id: seed.id,
|
|
||||||
revision: seed.revision,
|
|
||||||
reason: reason.trim()
|
|
||||||
};
|
|
||||||
if (mode === 'edit-case')
|
if (mode === 'edit-case')
|
||||||
return {
|
return {
|
||||||
op: 'update-case',
|
op: 'update-case',
|
||||||
@@ -201,7 +171,6 @@
|
|||||||
...(assigneeId !== (seed.assigneeId ?? '')
|
...(assigneeId !== (seed.assigneeId ?? '')
|
||||||
? { assigneeId: assigneeId || null }
|
? { assigneeId: assigneeId || null }
|
||||||
: {}),
|
: {}),
|
||||||
...(isModerationCaseOpen(status) ? { status } : {}),
|
|
||||||
reason: reason.trim()
|
reason: reason.trim()
|
||||||
};
|
};
|
||||||
if (mode === 'evidence')
|
if (mode === 'evidence')
|
||||||
@@ -219,14 +188,11 @@
|
|||||||
reason: reason.trim()
|
reason: reason.trim()
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
op: 'resolve-case',
|
op: 'update-case',
|
||||||
id: seed.id,
|
id: seed.id,
|
||||||
revision: seed.revision,
|
revision: seed.revision,
|
||||||
status: resolution,
|
status,
|
||||||
reason: reason.trim(),
|
reason: reason.trim()
|
||||||
...(referencedAction
|
|
||||||
? { actionReference: { type: referencedAction.type, id: referencedAction.id } }
|
|
||||||
: {})
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -392,7 +358,7 @@
|
|||||||
</p>{/if}
|
</p>{/if}
|
||||||
{#if lockedTarget}<p class="hint">
|
{#if lockedTarget}<p class="hint">
|
||||||
This restriction will be linked to the case and its recorded player identity.
|
This restriction will be linked to the case and its recorded player identity.
|
||||||
Applying it does not close the case; record the outcome when your review is
|
Applying it does not close the case; change its status when your work is
|
||||||
complete.
|
complete.
|
||||||
</p>{/if}
|
</p>{/if}
|
||||||
{#if mode === 'case' || mode === 'edit-case'}
|
{#if mode === 'case' || mode === 'edit-case'}
|
||||||
@@ -447,26 +413,8 @@
|
|||||||
/></Field
|
/></Field
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
{#if isModerationCaseOpen(seed.status ?? 'open')}
|
<Field label="Change reason" hint="Optional note about these edits.">
|
||||||
<Field label="Workflow status"
|
<Textarea bind:value={reason} rows={3} />
|
||||||
><Select
|
|
||||||
bind:value={status}
|
|
||||||
options={[
|
|
||||||
{ value: 'open', label: moderationCaseStatusLabels.open },
|
|
||||||
{ value: 'in_review', label: moderationCaseStatusLabels.in_review },
|
|
||||||
{ value: 'escalated', label: moderationCaseStatusLabels.escalated }
|
|
||||||
]}
|
|
||||||
/></Field
|
|
||||||
>
|
|
||||||
{:else}<p class="hint">
|
|
||||||
The recorded outcome is preserved. Reopen the case to resume review.
|
|
||||||
</p>{/if}
|
|
||||||
<Field
|
|
||||||
label="Change reason"
|
|
||||||
required={stateChanged}
|
|
||||||
hint="Required when changing workflow status."
|
|
||||||
>
|
|
||||||
<Textarea bind:value={reason} required={stateChanged} rows={3} />
|
|
||||||
</Field>
|
</Field>
|
||||||
{/if}
|
{/if}
|
||||||
{#if mode === 'evidence'}
|
{#if mode === 'evidence'}
|
||||||
@@ -481,55 +429,29 @@
|
|||||||
><Textarea bind:value={message} required rows={4} maxlength={4096} /></Field
|
><Textarea bind:value={message} required rows={4} maxlength={4096} /></Field
|
||||||
>
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{#if mode === 'resolve'}<Field label="Outcome"
|
{#if mode === 'case-status'}
|
||||||
|
<Field label="Status"
|
||||||
><Select
|
><Select
|
||||||
bind:value={resolution}
|
bind:value={status}
|
||||||
options={moderationCaseOutcomes.map((value) => ({
|
options={moderationCaseStatuses.map((value) => ({
|
||||||
value,
|
value,
|
||||||
label: moderationCaseStatusLabels[value]
|
label: moderationCaseStatusLabels[value]
|
||||||
}))}
|
}))}
|
||||||
/></Field
|
/></Field
|
||||||
>{/if}
|
|
||||||
{#if mode === 'resolve'}<p class="hint">
|
|
||||||
This records the decision and closes the case. Apply or revoke restrictions
|
|
||||||
separately, and send warnings through Live action. Choosing an outcome does not
|
|
||||||
change enforcement or send a warning.
|
|
||||||
</p>{/if}
|
|
||||||
{#if mode === 'resolve' && ['warning_issued', 'restriction_applied'].includes(resolution)}
|
|
||||||
<Field
|
|
||||||
label="Supporting action"
|
|
||||||
hint="Optional. Reference an existing linked action as the basis for this outcome."
|
|
||||||
>
|
>
|
||||||
<Select
|
<p class="hint">
|
||||||
bind:value={actionReference}
|
Track whether this case is open, in progress, or closed. Findings and recorded
|
||||||
options={[
|
actions stay in the case history.
|
||||||
{ value: '', label: 'No action reference' },
|
</p>
|
||||||
...referenceActions.map((item) => ({
|
|
||||||
value: `${item.type}:${item.id}`,
|
|
||||||
label: `${moderationActionName(item.kind)} · ${$timeFormat.dateTime(item.createdAt)} · ${item.reason}`
|
|
||||||
}))
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
{#if !referenceActions.length}<p class="hint">
|
|
||||||
{resolution === 'warning_issued'
|
|
||||||
? 'No confirmed warnings are linked to this case.'
|
|
||||||
: 'No restrictions are linked to this case.'} Use “Link existing action” on
|
|
||||||
the case to add a prior action.
|
|
||||||
</p>{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
{#if mode === 'reopen'}<p class="hint">
|
{#if ['restriction', 'action', 'revoke', 'case-status'].includes(mode)}
|
||||||
Reopening returns the case to Open and preserves its evidence, restrictions, and
|
|
||||||
earlier decisions.
|
|
||||||
</p>{/if}
|
|
||||||
{#if ['restriction', 'action', 'revoke', 'resolve', 'reopen'].includes(mode)}
|
|
||||||
<Field
|
<Field
|
||||||
label={mode === 'action' && ['message', 'announce'].includes(action)
|
label={mode === 'action' && ['message', 'announce'].includes(action)
|
||||||
? 'Message'
|
? 'Message'
|
||||||
: mode === 'restriction' || mode === 'action'
|
: mode === 'restriction' || mode === 'action'
|
||||||
? 'Player-visible reason'
|
? 'Player-visible reason'
|
||||||
: mode === 'reopen'
|
: mode === 'case-status'
|
||||||
? 'Reopening reason'
|
? 'Change reason'
|
||||||
: 'Review reason'}
|
: 'Review reason'}
|
||||||
hint={gameText
|
hint={gameText
|
||||||
? `${reasonBytes} / 240 bytes. Some characters use more than one byte.`
|
? `${reasonBytes} / 240 bytes. Some characters use more than one byte.`
|
||||||
@@ -561,7 +483,7 @@
|
|||||||
<div class="actions">
|
<div class="actions">
|
||||||
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button><Button
|
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button><Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={!uncertain && actionUnavailable}
|
disabled={!uncertain && (actionUnavailable || statusUnchanged)}
|
||||||
variant={mode === 'restriction' || mode === 'revoke' ? 'danger' : 'primary'}
|
variant={mode === 'restriction' || mode === 'revoke' ? 'danger' : 'primary'}
|
||||||
loading={busy}>{uncertain ? 'Retry same request' : 'Save'}</Button
|
loading={busy}>{uncertain ? 'Retry same request' : 'Save'}</Button
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export const moderationActionLabels: Record<string, string> = {
|
|||||||
'case.appeal_denied': 'Appeal denied',
|
'case.appeal_denied': 'Appeal denied',
|
||||||
'case.reopened': 'Case reopened',
|
'case.reopened': 'Case reopened',
|
||||||
'case.updated': 'Case updated',
|
'case.updated': 'Case updated',
|
||||||
|
'case.status_migrated': 'Case status migrated',
|
||||||
'case.assessed': 'Assessment revised',
|
'case.assessed': 'Assessment revised',
|
||||||
'case.review_scheduled': 'Follow-up updated',
|
'case.review_scheduled': 'Follow-up updated',
|
||||||
'schedule-case-review': 'Follow-up updated',
|
'schedule-case-review': 'Follow-up updated',
|
||||||
@@ -137,8 +138,10 @@ export function moderationEventDetail(detail: string, action: string): string {
|
|||||||
if (typeof value.reason === 'string') changes.push(value.reason);
|
if (typeof value.reason === 'string') changes.push(value.reason);
|
||||||
return changes.join('\n') || detail;
|
return changes.join('\n') || detail;
|
||||||
}
|
}
|
||||||
|
if (action === 'case.status_migrated')
|
||||||
|
return `Previous status: ${display(value.from, true)} → ${display(value.to, true)}. Earlier decisions and actions are preserved.`;
|
||||||
if (action === 'case.reopened' && typeof value.reason === 'string')
|
if (action === 'case.reopened' && typeof value.reason === 'string')
|
||||||
return `Previous outcome: ${display(value.from, true)}\n${value.reason}`;
|
return `Previous status: ${display(value.from, true)}\n${value.reason}`;
|
||||||
if (
|
if (
|
||||||
action === 'case.evidence_attached' &&
|
action === 'case.evidence_attached' &&
|
||||||
typeof value.count === 'number' &&
|
typeof value.count === 'number' &&
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ const states: Record<string, [string, StatusTone]> = {
|
|||||||
'moderation-active': ['Active', 'warning'],
|
'moderation-active': ['Active', 'warning'],
|
||||||
'moderation-revoked': ['Revoked', 'neutral'],
|
'moderation-revoked': ['Revoked', 'neutral'],
|
||||||
'moderation-open': ['Open', 'info'],
|
'moderation-open': ['Open', 'info'],
|
||||||
'moderation-in_review': ['In review', 'info'],
|
'moderation-in_review': ['In progress', 'info'],
|
||||||
|
'moderation-closed': ['Closed', 'neutral'],
|
||||||
'moderation-escalated': ['Escalated', 'warning'],
|
'moderation-escalated': ['Escalated', 'warning'],
|
||||||
'moderation-resolved': ['Resolved', 'online'],
|
'moderation-resolved': ['Resolved', 'online'],
|
||||||
'moderation-dismissed': ['Dismissed', 'neutral'],
|
'moderation-dismissed': ['Dismissed', 'neutral'],
|
||||||
|
|||||||
@@ -388,7 +388,6 @@
|
|||||||
options={[
|
options={[
|
||||||
{ value: '', label: 'All statuses' },
|
{ value: '', label: 'All statuses' },
|
||||||
{ value: 'active', label: 'Active cases' },
|
{ value: 'active', label: 'Active cases' },
|
||||||
{ value: 'closed', label: 'Closed cases' },
|
|
||||||
...moderationCaseStatuses.map((status) => ({
|
...moderationCaseStatuses.map((status) => ({
|
||||||
value: status,
|
value: status,
|
||||||
label: moderationCaseStatusLabels[status]
|
label: moderationCaseStatusLabels[status]
|
||||||
|
|||||||
@@ -150,7 +150,7 @@
|
|||||||
function submitted() {
|
function submitted() {
|
||||||
notice =
|
notice =
|
||||||
mode === 'restriction'
|
mode === 'restriction'
|
||||||
? 'Restriction saved and linked to this case. Review enforcement coverage below. The case remains open until you record an outcome.'
|
? 'Restriction saved and linked to this case. Review enforcement coverage below. The case remains open until you change its status.'
|
||||||
: mode === 'revoke'
|
: mode === 'revoke'
|
||||||
? 'Revocation saved. Servers apply it when they receive the latest policy.'
|
? 'Revocation saved. Servers apply it when they receive the latest policy.'
|
||||||
: 'Case updated. The change is recorded in the timeline.';
|
: 'Case updated. The change is recorded in the timeline.';
|
||||||
@@ -226,12 +226,9 @@
|
|||||||
/>
|
/>
|
||||||
<Button onclick={() => refresh()} loading={refreshing} disabled={busy}>Refresh</Button>
|
<Button onclick={() => refresh()} loading={refreshing} disabled={busy}>Refresh</Button>
|
||||||
{#if permitted}
|
{#if permitted}
|
||||||
{#if active}<Button variant="primary" onclick={() => openDialog('resolve')}
|
<Button variant="primary" onclick={() => openDialog('case-status')}
|
||||||
>Review case</Button
|
>Change status</Button
|
||||||
>
|
>
|
||||||
{:else}<Button variant="primary" onclick={() => openDialog('reopen')}
|
|
||||||
>Reopen case</Button
|
|
||||||
>{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</SectionHeader>
|
</SectionHeader>
|
||||||
@@ -568,7 +565,7 @@
|
|||||||
</li>{/each}
|
</li>{/each}
|
||||||
</ol>{:else}<p class="hint">No restrictions are linked to this case.</p>{/if}
|
</ol>{:else}<p class="hint">No restrictions are linked to this case.</p>{/if}
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
Applying or revoking a restriction does not change the case outcome.
|
Applying or revoking a restriction does not change the case status.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -604,7 +601,6 @@
|
|||||||
{admin}
|
{admin}
|
||||||
coverage={report.coverage}
|
coverage={report.coverage}
|
||||||
assignees={report.assignees}
|
assignees={report.assignees}
|
||||||
linkedActions={report.linkedActions}
|
|
||||||
onsubmitted={submitted}
|
onsubmitted={submitted}
|
||||||
/>{/key}{/if}
|
/>{/key}{/if}
|
||||||
|
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ it('preserves original evidence context, linked restrictions, and staff history
|
|||||||
expect(document.querySelector('a[href^="/matches/17"]')).not.toBeNull();
|
expect(document.querySelector('a[href^="/matches/17"]')).not.toBeNull();
|
||||||
for (const label of [
|
for (const label of [
|
||||||
'Edit case',
|
'Edit case',
|
||||||
'Review case',
|
'Change status',
|
||||||
'Reopen case',
|
'Reopen case',
|
||||||
'Restrict player',
|
'Restrict player',
|
||||||
'Revoke restriction',
|
'Revoke restriction',
|
||||||
@@ -251,12 +251,12 @@ it('locks case restrictions to the recorded identity and revision without closin
|
|||||||
durationMinutes: 60
|
durationMinutes: 60
|
||||||
});
|
});
|
||||||
expect(fetch.mock.calls[1][0]).toBe('/api/moderation/cases/case-1');
|
expect(fetch.mock.calls[1][0]).toBe('/api/moderation/cases/case-1');
|
||||||
expect(button('Review case')).toBeDefined();
|
expect(button('Change status')).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('records specific outcomes and reopens a closed case with a reason and the latest revision', async () => {
|
it('closes and reopens through lifecycle status with a reason and the latest revision', async () => {
|
||||||
const closed = detail();
|
const closed = detail();
|
||||||
closed.case.status = 'warning_issued';
|
closed.case.status = 'closed';
|
||||||
closed.case.revision = 5;
|
closed.case.revision = 5;
|
||||||
const reopened = detail();
|
const reopened = detail();
|
||||||
reopened.case.status = 'open';
|
reopened.case.status = 'open';
|
||||||
@@ -269,37 +269,34 @@ it('records specific outcomes and reopens a closed case with a reason and the la
|
|||||||
.mockResolvedValueOnce(response(reopened));
|
.mockResolvedValueOnce(response(reopened));
|
||||||
vi.stubGlobal('fetch', fetch);
|
vi.stubGlobal('fetch', fetch);
|
||||||
show();
|
show();
|
||||||
button('Review case').click();
|
button('Change status').click();
|
||||||
await tick();
|
await tick();
|
||||||
expect([...field<HTMLSelectElement>('Outcome').options].map((option) => option.value)).toEqual([
|
expect([...field<HTMLSelectElement>('Status').options].map((option) => option.value)).toEqual([
|
||||||
'resolved',
|
'open',
|
||||||
'dismissed',
|
'in_review',
|
||||||
'warning_issued',
|
'closed'
|
||||||
'restriction_applied',
|
|
||||||
'no_action',
|
|
||||||
'duplicate',
|
|
||||||
'appeal_upheld',
|
|
||||||
'appeal_denied'
|
|
||||||
]);
|
]);
|
||||||
select('Outcome', 'warning_issued');
|
select('Status', 'closed');
|
||||||
enter(field('Review reason'), 'Warning was sufficient');
|
enter(field('Change reason'), 'Warning was sufficient');
|
||||||
submit();
|
submit();
|
||||||
await vi.waitFor(() => expect(button('Reopen case')).toBeDefined());
|
await vi.waitFor(() => expect(document.querySelector('dialog')?.open).not.toBe(true));
|
||||||
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
|
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
|
||||||
op: 'resolve-case',
|
op: 'update-case',
|
||||||
id: 'case-1',
|
id: 'case-1',
|
||||||
revision: 4,
|
revision: 4,
|
||||||
status: 'warning_issued',
|
status: 'closed',
|
||||||
reason: 'Warning was sufficient'
|
reason: 'Warning was sufficient'
|
||||||
});
|
});
|
||||||
expect(button('Restrict player')).toBeUndefined();
|
expect(button('Restrict player')).toBeUndefined();
|
||||||
button('Reopen case').click();
|
button('Change status').click();
|
||||||
await tick();
|
await tick();
|
||||||
enter(field('Reopening reason'), 'New evidence received');
|
select('Status', 'open');
|
||||||
|
enter(field('Change reason'), 'New evidence received');
|
||||||
submit();
|
submit();
|
||||||
await vi.waitFor(() => expect(button('Review case')).toBeDefined());
|
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(4));
|
||||||
expect(JSON.parse(fetch.mock.calls[2][1].body)).toMatchObject({
|
expect(JSON.parse(fetch.mock.calls[2][1].body)).toMatchObject({
|
||||||
op: 'reopen-case',
|
op: 'update-case',
|
||||||
|
status: 'open',
|
||||||
id: 'case-1',
|
id: 'case-1',
|
||||||
revision: 5,
|
revision: 5,
|
||||||
reason: 'New evidence received'
|
reason: 'New evidence received'
|
||||||
@@ -307,7 +304,7 @@ it('records specific outcomes and reopens a closed case with a reason and the la
|
|||||||
expect(document.body.textContent).toContain('Check the earlier warning.');
|
expect(document.body.textContent).toContain('Check the earlier warning.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('edits priority, assignment, and review workflow with a required transition reason', async () => {
|
it('edits priority and assignment separately from lifecycle status', async () => {
|
||||||
const fetch = vi
|
const fetch = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
|
.mockResolvedValueOnce(response({ ok: true, id: 'case-1' }))
|
||||||
@@ -319,8 +316,8 @@ it('edits priority, assignment, and review workflow with a required transition r
|
|||||||
enter(field('Case title'), 'Urgent report');
|
enter(field('Case title'), 'Urgent report');
|
||||||
select('Priority', 'urgent');
|
select('Priority', 'urgent');
|
||||||
select('Assigned to', 'staff-2');
|
select('Assigned to', 'staff-2');
|
||||||
select('Workflow status', 'escalated');
|
expect(document.querySelector('dialog')?.textContent).not.toContain('Workflow status');
|
||||||
expect(field<HTMLTextAreaElement>('Change reason').required).toBe(true);
|
expect(field<HTMLTextAreaElement>('Change reason').required).toBe(false);
|
||||||
enter(field('Change reason'), 'Needs administrator review');
|
enter(field('Change reason'), 'Needs administrator review');
|
||||||
submit();
|
submit();
|
||||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
|
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
|
||||||
@@ -331,14 +328,13 @@ it('edits priority, assignment, and review workflow with a required transition r
|
|||||||
title: 'Urgent report',
|
title: 'Urgent report',
|
||||||
priority: 'urgent',
|
priority: 'urgent',
|
||||||
assigneeId: 'staff-2',
|
assigneeId: 'staff-2',
|
||||||
status: 'escalated',
|
|
||||||
reason: 'Needs administrator review'
|
reason: 'Needs administrator review'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves an unavailable assignee when editing metadata on a closed case', async () => {
|
it('preserves an unavailable assignee when editing metadata on a closed case', async () => {
|
||||||
const initial = detail();
|
const initial = detail();
|
||||||
initial.case.status = 'no_action';
|
initial.case.status = 'closed';
|
||||||
initial.case.assigneeId = 'disabled-staff';
|
initial.case.assigneeId = 'disabled-staff';
|
||||||
initial.case.assignee = 'Former staff';
|
initial.case.assignee = 'Former staff';
|
||||||
const fetch = vi
|
const fetch = vi
|
||||||
@@ -431,13 +427,11 @@ it('links a prior mute with its recorded outcome without issuing another restric
|
|||||||
expect(button('Link existing action').disabled).toBe(true);
|
expect(button('Link existing action').disabled).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it.each(['in_review', 'closed'] as const)(
|
||||||
['warning_issued', 'action:warning-1', 'action', 'warning-1'],
|
'changes status to %s without replacing findings or issuing an action',
|
||||||
['restriction_applied', 'restriction:restriction-1', 'restriction', 'restriction-1']
|
async (status) => {
|
||||||
] as const)(
|
|
||||||
'references a linked action when recording %s',
|
|
||||||
async (outcome, selection, type, id) => {
|
|
||||||
const initial = detail();
|
const initial = detail();
|
||||||
|
initial.case.status = 'open';
|
||||||
initial.linkedActions = [
|
initial.linkedActions = [
|
||||||
{
|
{
|
||||||
id: 'warning-1',
|
id: 'warning-1',
|
||||||
@@ -476,31 +470,34 @@ it.each([
|
|||||||
.mockResolvedValue(response(initial));
|
.mockResolvedValue(response(initial));
|
||||||
vi.stubGlobal('fetch', fetch);
|
vi.stubGlobal('fetch', fetch);
|
||||||
show(initial);
|
show(initial);
|
||||||
button('Review case').click();
|
button('Change status').click();
|
||||||
await tick();
|
await tick();
|
||||||
select('Outcome', outcome);
|
expect(button('Save').disabled).toBe(true);
|
||||||
const options = [...field<HTMLSelectElement>('Supporting action').options].map(
|
select('Status', status);
|
||||||
(option) => option.value
|
await tick();
|
||||||
);
|
expect(button('Save').disabled).toBe(false);
|
||||||
expect(options).toEqual(['', selection]);
|
expect(document.querySelector('dialog')?.textContent).not.toContain('Supporting action');
|
||||||
select('Supporting action', selection);
|
enter(field('Change reason'), 'Reviewed the recorded action');
|
||||||
enter(field('Review reason'), 'Reviewed the recorded action');
|
|
||||||
submit();
|
submit();
|
||||||
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
|
await vi.waitFor(() => expect(fetch).toHaveBeenCalledTimes(2));
|
||||||
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
|
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
|
||||||
op: 'resolve-case',
|
op: 'update-case',
|
||||||
id: 'case-1',
|
id: 'case-1',
|
||||||
revision: 4,
|
revision: 4,
|
||||||
status: outcome,
|
status
|
||||||
actionReference: { type, id }
|
|
||||||
});
|
});
|
||||||
|
expect(JSON.parse(fetch.mock.calls[0][1].body)).not.toHaveProperty('actionReference');
|
||||||
|
expect(document.querySelector('[aria-labelledby="actions-title"]')?.textContent).toContain(
|
||||||
|
'Confirmed warning'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
it('retains a conflicted review and lets staff refresh before making another decision', async () => {
|
it('retains a conflicted review and lets staff refresh before making another decision', async () => {
|
||||||
const updated = detail();
|
const updated = detail();
|
||||||
updated.case.revision = 8;
|
updated.case.revision = 8;
|
||||||
updated.case.status = 'escalated';
|
updated.case.title = 'Updated case after concurrent edit';
|
||||||
|
updated.case.status = 'in_review';
|
||||||
const fetch = vi
|
const fetch = vi
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValueOnce(
|
.mockResolvedValueOnce(
|
||||||
@@ -509,28 +506,29 @@ it('retains a conflicted review and lets staff refresh before making another dec
|
|||||||
.mockResolvedValue(response(updated));
|
.mockResolvedValue(response(updated));
|
||||||
vi.stubGlobal('fetch', fetch);
|
vi.stubGlobal('fetch', fetch);
|
||||||
show();
|
show();
|
||||||
button('Review case').click();
|
button('Change status').click();
|
||||||
await tick();
|
await tick();
|
||||||
select('Outcome', 'appeal_denied');
|
select('Status', 'closed');
|
||||||
enter(field('Review reason'), 'Evidence supports the restriction');
|
enter(field('Change reason'), 'Evidence supports the restriction');
|
||||||
submit();
|
submit();
|
||||||
await vi.waitFor(() =>
|
await vi.waitFor(() =>
|
||||||
expect(document.querySelector('dialog [role="alert"]')?.textContent).toContain(
|
expect(document.querySelector('dialog [role="alert"]')?.textContent).toContain(
|
||||||
'This case changed.'
|
'This case changed.'
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
expect(field<HTMLSelectElement>('Outcome').value).toBe('appeal_denied');
|
expect(field<HTMLSelectElement>('Status').value).toBe('closed');
|
||||||
expect(document.querySelector('dialog')!.open).toBe(true);
|
expect(document.querySelector('dialog')!.open).toBe(true);
|
||||||
expect(button('Reopen case')).toBeUndefined();
|
expect(button('Reopen case')).toBeUndefined();
|
||||||
button('Cancel').click();
|
button('Cancel').click();
|
||||||
await tick();
|
await tick();
|
||||||
button('Refresh').click();
|
button('Refresh').click();
|
||||||
await vi.waitFor(() =>
|
await vi.waitFor(() =>
|
||||||
expect(document.querySelector('.case-summary')?.textContent).toContain('Escalated')
|
expect(document.querySelector('h1')?.textContent).toBe('Updated case after concurrent edit')
|
||||||
);
|
);
|
||||||
button('Review case').click();
|
button('Change status').click();
|
||||||
await tick();
|
await tick();
|
||||||
enter(field('Review reason'), 'Reviewed new context');
|
select('Status', 'closed');
|
||||||
|
enter(field('Change reason'), 'Reviewed new context');
|
||||||
submit();
|
submit();
|
||||||
await vi.waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(3));
|
await vi.waitFor(() => expect(fetch.mock.calls.length).toBeGreaterThanOrEqual(3));
|
||||||
expect(JSON.parse(fetch.mock.calls[2][1].body).revision).toBe(8);
|
expect(JSON.parse(fetch.mock.calls[2][1].body).revision).toBe(8);
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const item: ModerationCase = {
|
|||||||
game: 't5',
|
game: 't5',
|
||||||
guid: '123',
|
guid: '123',
|
||||||
title: 'Chat review',
|
title: 'Chat review',
|
||||||
status: 'resolved',
|
status: 'closed',
|
||||||
priority: 'normal',
|
priority: 'normal',
|
||||||
assignee: null,
|
assignee: null,
|
||||||
assigneeId: null,
|
assigneeId: null,
|
||||||
|
|||||||
@@ -353,7 +353,7 @@ it('links case records to workspaces and preserves case filters while paging', (
|
|||||||
actor: 'Moderator',
|
actor: 'Moderator',
|
||||||
createdAt: '2026-10-16T12:00:00Z'
|
createdAt: '2026-10-16T12:00:00Z'
|
||||||
},
|
},
|
||||||
status: 'escalated',
|
status: 'in_review',
|
||||||
priority: 'high',
|
priority: 'high',
|
||||||
assigneeId: 'staff-1',
|
assigneeId: 'staff-1',
|
||||||
assignee: 'Moderator',
|
assignee: 'Moderator',
|
||||||
@@ -369,7 +369,7 @@ it('links case records to workspaces and preserves case filters while paging', (
|
|||||||
expect(document.querySelector('a[href="/moderation/cases/case-1"]')?.textContent).toBe(
|
expect(document.querySelector('a[href="/moderation/cases/case-1"]')?.textContent).toBe(
|
||||||
'Chat investigation'
|
'Chat investigation'
|
||||||
);
|
);
|
||||||
expect(document.querySelector('#moderation-results')?.textContent).toContain('Escalated');
|
expect(document.querySelector('#moderation-results')?.textContent).toContain('In progress');
|
||||||
expect(document.querySelector('#moderation-results')?.textContent).toContain('Moderator');
|
expect(document.querySelector('#moderation-results')?.textContent).toContain('Moderator');
|
||||||
expect(document.querySelector('#moderation-results')?.textContent).toContain(
|
expect(document.querySelector('#moderation-results')?.textContent).toContain(
|
||||||
'The threat remains ambiguous.'
|
'The threat remains ambiguous.'
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ it('preserves current and historical assessments with case revisions, including
|
|||||||
};
|
};
|
||||||
await mutate(assess(id, 3, updated), 'admin');
|
await mutate(assess(id, 3, updated), 'admin');
|
||||||
const data = await detail(id, 'super_viewer');
|
const data = await detail(id, 'super_viewer');
|
||||||
expect(data.case).toMatchObject({ revision: 4, status: 'no_action' });
|
expect(data.case).toMatchObject({ revision: 4, status: 'closed' });
|
||||||
expect(data.assessment).toEqual({
|
expect(data.assessment).toEqual({
|
||||||
...updated,
|
...updated,
|
||||||
revision: 4,
|
revision: 4,
|
||||||
@@ -253,8 +253,8 @@ it('adds assessment storage to an existing database without altering case record
|
|||||||
)
|
)
|
||||||
.run(clock, clock);
|
.run(clock, clock);
|
||||||
const before = legacy.prepare('SELECT * FROM moderation_cases').all();
|
const before = legacy.prepare('SELECT * FROM moderation_cases').all();
|
||||||
runMigrations(legacy, migrations);
|
runMigrations(legacy, migrations.slice(0, 32));
|
||||||
runMigrations(legacy, migrations);
|
runMigrations(legacy, migrations.slice(0, 32));
|
||||||
expect(legacy.prepare('SELECT * FROM moderation_cases').all()).toMatchObject(before);
|
expect(legacy.prepare('SELECT * FROM moderation_cases').all()).toMatchObject(before);
|
||||||
const insert = legacy.prepare(
|
const insert = legacy.prepare(
|
||||||
'INSERT INTO moderation_case_assessments VALUES(?,?,?,?,?,?)'
|
'INSERT INTO moderation_case_assessments VALUES(?,?,?,?,?,?)'
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ it('links historical live actions to closed cases and exposes their real deliver
|
|||||||
const before = enforcement();
|
const before = enforcement();
|
||||||
await mutate(link(id, 2, 'action', warningId));
|
await mutate(link(id, 2, 'action', warningId));
|
||||||
const data = await detail(id, 'super_viewer');
|
const data = await detail(id, 'super_viewer');
|
||||||
expect(data.case).toMatchObject({ status: 'no_action', revision: 3 });
|
expect(data.case).toMatchObject({ status: 'closed', revision: 3 });
|
||||||
expect(data.linkedActions).toMatchObject([{ id: warningId, kind: 'warn', state: 'confirmed' }]);
|
expect(data.linkedActions).toMatchObject([{ id: warningId, kind: 'warn', state: 'confirmed' }]);
|
||||||
expect(data.availableActions).toMatchObject([{ id: failedId, state: 'failed' }]);
|
expect(data.availableActions).toMatchObject([{ id: failedId, state: 'failed' }]);
|
||||||
expect(data.events.map((event) => event.action)).toContain('action.confirmed');
|
expect(data.events.map((event) => event.action)).toContain('action.confirmed');
|
||||||
@@ -246,7 +246,7 @@ it('requires a linked confirmed warning for a cited warning outcome and preserve
|
|||||||
const before = enforcement();
|
const before = enforcement();
|
||||||
await mutate(resolve(5, 'action', confirmed));
|
await mutate(resolve(5, 'action', confirmed));
|
||||||
const data = await detail(id);
|
const data = await detail(id);
|
||||||
expect(data.case).toMatchObject({ status: 'warning_issued', revision: 6 });
|
expect(data.case).toMatchObject({ status: 'closed', revision: 6 });
|
||||||
expect(JSON.parse(data.events[0].detail)).toEqual({
|
expect(JSON.parse(data.events[0].detail)).toEqual({
|
||||||
reason: 'Decision after evidence review',
|
reason: 'Decision after evidence review',
|
||||||
actionReference: { type: 'action', id: confirmed }
|
actionReference: { type: 'action', id: confirmed }
|
||||||
@@ -268,7 +268,7 @@ it('allows a historical expired restriction created from the original case to su
|
|||||||
reason: 'Earlier mute addressed the incident',
|
reason: 'Earlier mute addressed the incident',
|
||||||
actionReference: { type: 'restriction', id: sanction.id }
|
actionReference: { type: 'restriction', id: sanction.id }
|
||||||
});
|
});
|
||||||
expect((await detail(id)).case.status).toBe('restriction_applied');
|
expect((await detail(id)).case.status).toBe('closed');
|
||||||
expect(enforcement()).toEqual(before);
|
expect(enforcement()).toEqual(before);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,8 @@ it('filters case queues by workflow, priority and ownership while prioritizing a
|
|||||||
for (const [title, priority, status, owner] of [
|
for (const [title, priority, status, owner] of [
|
||||||
['Normal report', 'normal', 'open', null],
|
['Normal report', 'normal', 'open', null],
|
||||||
['Urgent review', 'urgent', 'in_review', 'first'],
|
['Urgent review', 'urgent', 'in_review', 'first'],
|
||||||
['High escalation', 'high', 'escalated', 'second'],
|
['High escalation', 'high', 'in_review', 'second'],
|
||||||
['Closed urgent appeal', 'urgent', 'appeal_upheld', 'first']
|
['Closed urgent appeal', 'urgent', 'closed', 'first']
|
||||||
]) {
|
]) {
|
||||||
const { id } = mutateModeration('first', {
|
const { id } = mutateModeration('first', {
|
||||||
op: 'create-case',
|
op: 'create-case',
|
||||||
@@ -46,7 +46,7 @@ it('filters case queues by workflow, priority and ownership while prioritizing a
|
|||||||
expect(read().cases.map((item) => item.id)).toEqual([ids[1], ids[2], ids[0], ids[3]]);
|
expect(read().cases.map((item) => item.id)).toEqual([ids[1], ids[2], ids[0], ids[3]]);
|
||||||
expect(read('caseStatus=active').total).toBe(3);
|
expect(read('caseStatus=active').total).toBe(3);
|
||||||
expect(read('caseStatus=closed').cases.map((item) => item.id)).toEqual([ids[3]]);
|
expect(read('caseStatus=closed').cases.map((item) => item.id)).toEqual([ids[3]]);
|
||||||
expect(read('caseStatus=escalated&priority=high').cases.map((item) => item.id)).toEqual([
|
expect(read('caseStatus=in_review&priority=high').cases.map((item) => item.id)).toEqual([
|
||||||
ids[2]
|
ids[2]
|
||||||
]);
|
]);
|
||||||
expect(read('assignedTo=me').cases.map((item) => item.id)).toEqual([ids[1], ids[3]]);
|
expect(read('assignedTo=me').cases.map((item) => item.id)).toEqual([ids[1], ids[3]]);
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ it('enforces case existence, staff access, revisions, self-link rejection and su
|
|||||||
reason: 'Review complete'
|
reason: 'Review complete'
|
||||||
});
|
});
|
||||||
await mutate(link(a.id, 2, b.id));
|
await mutate(link(a.id, 2, b.id));
|
||||||
expect((await detail(a.id)).case).toMatchObject({ status: 'no_action', revision: 3 });
|
expect((await detail(a.id)).case).toMatchObject({ status: 'closed', revision: 3 });
|
||||||
await expect(detail(a.id, 'viewer')).rejects.toMatchObject({ status: 403 });
|
await expect(detail(a.id, 'viewer')).rejects.toMatchObject({ status: 403 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import { moderationCaseOutcomes, moderationCaseStatuses } from '../../src/lib/moderation.js';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
fixture = await newFixture([]);
|
||||||
|
});
|
||||||
|
afterEach(async () => fixture.cleanup());
|
||||||
|
|
||||||
|
it('migrates every legacy state while preserving assignments, decisions, evidence, actions and relationships', async () => {
|
||||||
|
const { migrations } = await import('../../src/lib/server/db/migrations.js');
|
||||||
|
const { runMigrations } = await import('../../src/lib/server/db/migration-runner.js');
|
||||||
|
const handle = new Database(':memory:');
|
||||||
|
handle.pragma('foreign_keys = ON');
|
||||||
|
try {
|
||||||
|
runMigrations(handle, migrations.slice(0, 35));
|
||||||
|
handle.exec(`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
|
||||||
|
VALUES('staff','staff','Staff','unused','moderator',0,0)`);
|
||||||
|
const statuses = ['open', 'in_review', 'escalated', ...moderationCaseOutcomes];
|
||||||
|
for (const status of statuses) {
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at,revision,assignee_id,review_at)
|
||||||
|
VALUES(?,'t5','123','Existing case',?,'staff','Staff','2026-10-17','2026-10-17',7,'staff','2026-11-01')`
|
||||||
|
)
|
||||||
|
.run(status, status);
|
||||||
|
}
|
||||||
|
handle.exec(`
|
||||||
|
INSERT INTO moderation_events(game,guid,actor,action,subject_id,detail,created_at)
|
||||||
|
VALUES('t5','123','Staff','case.warning_issued','warning_issued','Original decision','2026-10-17');
|
||||||
|
INSERT INTO moderation_evidence VALUES('e','warning_issued','Original excerpt','Archived chat','{"entry":"unchanged"}','staff','Staff','2026-10-17');
|
||||||
|
INSERT INTO moderation_sanctions(id,game,guid,kind,scope,reason,note,actor_id,actor,created_at,case_id)
|
||||||
|
VALUES('s','t5','123','mute','game','Original action','','staff','Staff','2026-10-17','warning_issued');
|
||||||
|
UPDATE moderation_cases SET review_sanction_id='s' WHERE id='warning_issued';
|
||||||
|
INSERT INTO moderation_case_action_links VALUES('warning_issued','restriction','s','Staff','2026-10-17');
|
||||||
|
INSERT INTO moderation_case_relationships VALUES('r','open','warning_issued','same_incident','staff','Staff','2026-10-17',NULL);
|
||||||
|
INSERT INTO moderation_case_assessments VALUES('warning_issued',6,'{"summary":"Existing finding"}','staff','Staff','2026-10-17');
|
||||||
|
INSERT INTO moderation_case_notifications(user_id,case_id,kind,created_at) VALUES('staff','warning_issued','evidence','2026-10-17');
|
||||||
|
`);
|
||||||
|
const children = [
|
||||||
|
'moderation_evidence',
|
||||||
|
'moderation_sanctions',
|
||||||
|
'moderation_case_action_links',
|
||||||
|
'moderation_case_relationships',
|
||||||
|
'moderation_case_assessments',
|
||||||
|
'moderation_case_notifications'
|
||||||
|
];
|
||||||
|
const before = children.map((table) => handle.prepare(`SELECT * FROM ${table}`).all());
|
||||||
|
runMigrations(handle, migrations);
|
||||||
|
runMigrations(handle, migrations);
|
||||||
|
expect(children.map((table) => handle.prepare(`SELECT * FROM ${table}`).all())).toEqual(
|
||||||
|
before
|
||||||
|
);
|
||||||
|
for (const old of statuses) {
|
||||||
|
const expected =
|
||||||
|
old === 'open'
|
||||||
|
? 'open'
|
||||||
|
: ['in_review', 'escalated'].includes(old)
|
||||||
|
? 'in_review'
|
||||||
|
: 'closed';
|
||||||
|
expect(
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
'SELECT status,revision,assignee_id,review_at FROM moderation_cases WHERE id=?'
|
||||||
|
)
|
||||||
|
.get(old)
|
||||||
|
).toEqual({
|
||||||
|
status: expected,
|
||||||
|
revision: old === expected ? 7 : 8,
|
||||||
|
assignee_id: 'staff',
|
||||||
|
review_at: '2026-11-01'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const events = handle
|
||||||
|
.prepare("SELECT detail FROM moderation_events WHERE action='case.status_migrated'")
|
||||||
|
.all() as { detail: string }[];
|
||||||
|
expect(events.map((event) => JSON.parse(event.detail).from).sort()).toEqual(
|
||||||
|
['escalated', ...moderationCaseOutcomes].sort()
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
handle
|
||||||
|
.prepare("SELECT detail FROM moderation_events WHERE action='case.warning_issued'")
|
||||||
|
.get()
|
||||||
|
).toEqual({ detail: 'Original decision' });
|
||||||
|
expect(() =>
|
||||||
|
handle.exec("UPDATE moderation_cases SET status='warning_issued' WHERE id='open'")
|
||||||
|
).toThrow('CHECK');
|
||||||
|
expect(handle.pragma('foreign_key_check')).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
handle.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('changes lifecycle independently of ownership, findings and enforcement with revision and retry protection', async () => {
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
|
||||||
|
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
|
||||||
|
const handle = db();
|
||||||
|
for (const role of ['moderator', 'super_viewer'])
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,'unused',?,0,0)`
|
||||||
|
)
|
||||||
|
.run(role, role, role, role);
|
||||||
|
const mutate = (input: Record<string, unknown>, actor = 'moderator') =>
|
||||||
|
mutateModeration(actor, { idempotencyKey: randomUUID(), ...input });
|
||||||
|
const { id } = mutate({
|
||||||
|
op: 'create-case',
|
||||||
|
game: 't5',
|
||||||
|
guid: '123',
|
||||||
|
title: 'Reported conduct',
|
||||||
|
assigneeId: 'moderator'
|
||||||
|
});
|
||||||
|
mutate({
|
||||||
|
op: 'assess-case',
|
||||||
|
id,
|
||||||
|
revision: 1,
|
||||||
|
summary: 'Ambiguous context',
|
||||||
|
rules: [],
|
||||||
|
findings: [{ category: 'threats', finding: 'inconclusive', detail: 'Need more context' }]
|
||||||
|
});
|
||||||
|
const original = moderationCaseDetail('moderator', id);
|
||||||
|
let revision = 2;
|
||||||
|
for (const status of ['in_review', 'closed', 'open', 'closed', 'in_review']) {
|
||||||
|
const request = {
|
||||||
|
op: 'update-case',
|
||||||
|
id,
|
||||||
|
revision,
|
||||||
|
status,
|
||||||
|
reason: 'Workflow changed',
|
||||||
|
idempotencyKey: randomUUID()
|
||||||
|
};
|
||||||
|
expect(() => mutate({ ...request, reason: '' })).toThrow('Status change reason');
|
||||||
|
expect(() => mutate({ ...request, revision: revision - 1 })).toThrow('changed');
|
||||||
|
expect(() => mutate(request, 'super_viewer')).toThrow('Moderation access');
|
||||||
|
mutate(request);
|
||||||
|
mutate(request);
|
||||||
|
revision++;
|
||||||
|
const data = moderationCaseDetail('moderator', id);
|
||||||
|
expect(data.case).toMatchObject({ status, revision, assigneeId: 'moderator' });
|
||||||
|
expect(data.assessmentHistory).toEqual(original.assessmentHistory);
|
||||||
|
expect(data.case.evidence).toEqual(original.case.evidence);
|
||||||
|
expect(data.sanctions).toEqual(original.sanctions);
|
||||||
|
expect(data.events[0]).toMatchObject({ action: 'case.updated' });
|
||||||
|
}
|
||||||
|
for (const status of [...moderationCaseOutcomes, 'assigned', 'escalated'])
|
||||||
|
expect(() =>
|
||||||
|
mutate({ op: 'update-case', id, revision, status, reason: 'Invalid state' })
|
||||||
|
).toThrow('Choose Open');
|
||||||
|
expect(moderationCaseStatuses).toEqual(['open', 'in_review', 'closed']);
|
||||||
|
expect(handle.prepare('SELECT COUNT(*) AS n FROM moderation_actions').get()).toEqual({ n: 0 });
|
||||||
|
});
|
||||||
@@ -134,8 +134,8 @@ it('upgrades legacy cases without changing evidence, events, requests, or restri
|
|||||||
.all()
|
.all()
|
||||||
).toEqual(
|
).toEqual(
|
||||||
['dismissed', 'open', 'resolved'].map((status) => ({
|
['dismissed', 'open', 'resolved'].map((status) => ({
|
||||||
status,
|
status: status === 'open' ? 'open' : 'closed',
|
||||||
revision: 7,
|
revision: status === 'open' ? 7 : 8,
|
||||||
priority: 'normal',
|
priority: 'normal',
|
||||||
assignee_id: null
|
assignee_id: null
|
||||||
}))
|
}))
|
||||||
@@ -146,7 +146,7 @@ it('upgrades legacy cases without changing evidence, events, requests, or restri
|
|||||||
.get('{"original":true}')
|
.get('{"original":true}')
|
||||||
).toEqual({ count: 3 });
|
).toEqual({ count: 3 });
|
||||||
expect(legacy.prepare('SELECT COUNT(*) AS count FROM moderation_events').get()).toEqual({
|
expect(legacy.prepare('SELECT COUNT(*) AS count FROM moderation_events').get()).toEqual({
|
||||||
count: 3
|
count: 5
|
||||||
});
|
});
|
||||||
expect(legacy.prepare('SELECT result FROM moderation_requests').get()).toEqual({
|
expect(legacy.prepare('SELECT result FROM moderation_requests').get()).toEqual({
|
||||||
result: '{"ok":true,"id":"open"}'
|
result: '{"ok":true,"id":"open"}'
|
||||||
@@ -245,12 +245,12 @@ it('audits title, ownership, priority and active state updates with optimistic r
|
|||||||
id,
|
id,
|
||||||
revision: 3,
|
revision: 3,
|
||||||
assigneeId: null,
|
assigneeId: null,
|
||||||
status: 'escalated',
|
status: 'open',
|
||||||
reason: 'Needs administrator review'
|
reason: 'Needs administrator review'
|
||||||
});
|
});
|
||||||
expect((await detail(id)).case).toMatchObject({
|
expect((await detail(id)).case).toMatchObject({
|
||||||
revision: 4,
|
revision: 4,
|
||||||
status: 'escalated',
|
status: 'open',
|
||||||
assigneeId: null
|
assigneeId: null
|
||||||
});
|
});
|
||||||
await expect(
|
await expect(
|
||||||
@@ -267,9 +267,9 @@ it('supports every outcome and reopening while preserving each prior decision',
|
|||||||
);
|
);
|
||||||
await mutate({ op: 'resolve-case', id, revision, status, reason: `Decision ${status}` });
|
await mutate({ op: 'resolve-case', id, revision, status, reason: `Decision ${status}` });
|
||||||
revision++;
|
revision++;
|
||||||
expect((await detail(id)).case.status).toBe(status);
|
expect((await detail(id)).case.status).toBe('closed');
|
||||||
await expect(mutate({ op: 'update-case', id, revision, status: 'open' })).rejects.toThrow(
|
await expect(mutate({ op: 'update-case', id, revision, status: 'open' })).rejects.toThrow(
|
||||||
'Use reopen'
|
'Status change reason'
|
||||||
);
|
);
|
||||||
await expect(
|
await expect(
|
||||||
mutate({ op: 'reopen-case', id, revision: revision - 1, reason: 'Old version' })
|
mutate({ op: 'reopen-case', id, revision: revision - 1, reason: 'Old version' })
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ it('prioritizes overdue closed cases and recalculates due and scheduled queues u
|
|||||||
closed.id,
|
closed.id,
|
||||||
upcoming.id
|
upcoming.id
|
||||||
]);
|
]);
|
||||||
expect((await overview()).cases[0].status).toBe('no_action');
|
expect((await overview()).cases[0].status).toBe('closed');
|
||||||
await mutate(schedule(closed.id, 3, null));
|
await mutate(schedule(closed.id, 3, null));
|
||||||
expect((await overview('creator', 'review=due')).cases.map((item) => item.id)).toEqual([
|
expect((await overview('creator', 'review=due')).cases.map((item) => item.id)).toEqual([
|
||||||
upcoming.id
|
upcoming.id
|
||||||
|
|||||||
@@ -95,14 +95,14 @@ it('presents case decisions and ownership changes as readable history while pres
|
|||||||
'case.updated'
|
'case.updated'
|
||||||
)
|
)
|
||||||
).toBe(
|
).toBe(
|
||||||
'Title: Report → Appeal review\nAssigned to: Unassigned → Moderator\nStatus: Open → In review\nReviewing additional evidence'
|
'Title: Report → Appeal review\nAssigned to: Unassigned → Moderator\nStatus: Open → In progress\nReviewing additional evidence'
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
moderationEventDetail(
|
moderationEventDetail(
|
||||||
JSON.stringify({ from: 'appeal_denied', reason: 'New evidence' }),
|
JSON.stringify({ from: 'appeal_denied', reason: 'New evidence' }),
|
||||||
'case.reopened'
|
'case.reopened'
|
||||||
)
|
)
|
||||||
).toBe('Previous outcome: Appeal denied\nNew evidence');
|
).toBe('Previous status: Appeal denied\nNew evidence');
|
||||||
expect(moderationEventDetail('Decision with {unusual text}', 'case.resolved')).toBe(
|
expect(moderationEventDetail('Decision with {unusual text}', 'case.resolved')).toBe(
|
||||||
'Decision with {unusual text}'
|
'Decision with {unusual text}'
|
||||||
);
|
);
|
||||||
@@ -110,3 +110,14 @@ it('presents case decisions and ownership changes as readable history while pres
|
|||||||
moderationEventDetail(JSON.stringify({ count: 2, matchId: 42 }), 'case.evidence_attached')
|
moderationEventDetail(JSON.stringify({ count: 2, matchId: 42 }), 'case.evidence_attached')
|
||||||
).toBe('2 archived chat entries attached from match #42.');
|
).toBe('2 archived chat entries attached from match #42.');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('explains migrated status without hiding the historical decision', () => {
|
||||||
|
expect(
|
||||||
|
moderationEventDetail(
|
||||||
|
JSON.stringify({ from: 'warning_issued', to: 'closed' }),
|
||||||
|
'case.status_migrated'
|
||||||
|
)
|
||||||
|
).toBe(
|
||||||
|
'Previous status: Warning issued → Closed. Earlier decisions and actions are preserved.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user