Add case follow-up queues and personal update notifications
This commit is contained in:
@@ -157,6 +157,20 @@ direction made explicit. Self-links and circular appeal/duplicate chains are
|
|||||||
rejected. Removing a mistaken relationship requires a reason and preserves its
|
rejected. Removing a mistaken relationship requires a reason and preserves its
|
||||||
history. Relationship changes do not close or reopen either case.
|
history. Relationship changes do not close or reopen either case.
|
||||||
|
|
||||||
|
Schedule a follow-up using a date and time, or **Review when a linked mute expires**.
|
||||||
|
Custom date entry uses your browser's local time; displayed dates follow your time
|
||||||
|
zone preference. Reviews remain scheduled after a case closes or reopens. Use
|
||||||
|
**Overdue reviews** to find due work across open and closed cases, and clear the
|
||||||
|
follow-up with a reason when the review is complete.
|
||||||
|
|
||||||
|
In-app case updates notify the assignee and case creator when another staff member
|
||||||
|
adds evidence or a staff note. Assignment changes notify the previous and new assignees
|
||||||
|
and the creator, excluding the person making the change. **Your unread updates**
|
||||||
|
is personal to your account. Open a case to review and mark existing updates
|
||||||
|
read; notifications arriving afterward stay unread. Queue and case pages refresh
|
||||||
|
every 15 seconds while visible. These are in-app notifications, with no email or
|
||||||
|
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
|
||||||
|
|||||||
@@ -163,6 +163,10 @@ export interface ModerationCase {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
revision: number;
|
revision: number;
|
||||||
assessment?: ModerationCaseAssessment | null;
|
assessment?: ModerationCaseAssessment | null;
|
||||||
|
reviewAt?: string | null;
|
||||||
|
reviewReason?: string;
|
||||||
|
reviewSanctionId?: string | null;
|
||||||
|
unreadUpdates?: number;
|
||||||
evidence: {
|
evidence: {
|
||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
@@ -182,6 +186,7 @@ export interface ModerationCaseDetail {
|
|||||||
assessmentHistory?: ModerationCaseAssessment[];
|
assessmentHistory?: ModerationCaseAssessment[];
|
||||||
relatedCases?: ModerationCaseRelationship[];
|
relatedCases?: ModerationCaseRelationship[];
|
||||||
candidateCases?: ModerationCaseCandidate[];
|
candidateCases?: ModerationCaseCandidate[];
|
||||||
|
notifications?: ModerationCaseNotification[];
|
||||||
events: ModerationEvent[];
|
events: ModerationEvent[];
|
||||||
coverage: ModerationCoverage[];
|
coverage: ModerationCoverage[];
|
||||||
assignees: { id: string; username: string }[];
|
assignees: { id: string; username: string }[];
|
||||||
@@ -202,6 +207,14 @@ export interface ModerationCaseRelationship {
|
|||||||
export interface ModerationCaseCandidate extends ModerationCaseReference {
|
export interface ModerationCaseCandidate extends ModerationCaseReference {
|
||||||
sharedMatch: boolean;
|
sharedMatch: boolean;
|
||||||
}
|
}
|
||||||
|
export interface ModerationCaseNotification {
|
||||||
|
id: number;
|
||||||
|
caseId: string;
|
||||||
|
caseTitle: string;
|
||||||
|
kind: 'evidence' | 'assignment';
|
||||||
|
createdAt: string;
|
||||||
|
readAt: string | null;
|
||||||
|
}
|
||||||
export interface ModerationCaseAction {
|
export interface ModerationCaseAction {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'restriction' | 'action';
|
type: 'restriction' | 'action';
|
||||||
@@ -240,6 +253,7 @@ export interface ModerationQuery {
|
|||||||
caseStatus?: '' | 'active' | 'closed' | ModerationCaseStatus;
|
caseStatus?: '' | 'active' | 'closed' | ModerationCaseStatus;
|
||||||
priority?: '' | ModerationCasePriority;
|
priority?: '' | ModerationCasePriority;
|
||||||
assignedTo?: string;
|
assignedTo?: string;
|
||||||
|
review?: '' | 'due' | 'scheduled' | 'updates';
|
||||||
}
|
}
|
||||||
export interface ModerationOverview {
|
export interface ModerationOverview {
|
||||||
query: ModerationQuery;
|
query: ModerationQuery;
|
||||||
@@ -252,6 +266,8 @@ export interface ModerationOverview {
|
|||||||
total: number;
|
total: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
checkedAt: string;
|
checkedAt: string;
|
||||||
|
notifications?: ModerationCaseNotification[];
|
||||||
|
unreadCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** RCON T6 IDs are hexadecimal, while the game scripts send decimal strings. */
|
/** RCON T6 IDs are hexadecimal, while the game scripts send decimal strings. */
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ import {
|
|||||||
migrateModerationCases,
|
migrateModerationCases,
|
||||||
migrateModerationCaseActions,
|
migrateModerationCaseActions,
|
||||||
migrateModerationCaseAssessments,
|
migrateModerationCaseAssessments,
|
||||||
migrateModerationCaseRelationships
|
migrateModerationCaseRelationships,
|
||||||
|
migrateModerationCaseFollowups
|
||||||
} from '../moderation/schema.js';
|
} from '../moderation/schema.js';
|
||||||
import type { Migration } from './migration-runner.js';
|
import type { Migration } from './migration-runner.js';
|
||||||
|
|
||||||
@@ -191,6 +192,11 @@ export const migrations: readonly Migration[] = [
|
|||||||
version: 33,
|
version: 33,
|
||||||
name: 'moderation case relationships and duplicate review',
|
name: 'moderation case relationships and duplicate review',
|
||||||
run: migrateModerationCaseRelationships
|
run: migrateModerationCaseRelationships
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 34,
|
||||||
|
name: 'moderation follow-up dates and personal updates',
|
||||||
|
run: migrateModerationCaseFollowups
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { archivedEvidenceSnapshots } from './evidence.js';
|
|||||||
import { caseActionReference, recordedCaseAction, isCaseActionLinked } from './case-actions.js';
|
import { caseActionReference, recordedCaseAction, isCaseActionLinked } from './case-actions.js';
|
||||||
import { assessmentDraft } from './assessments.js';
|
import { assessmentDraft } from './assessments.js';
|
||||||
import { linkCases, unlinkCases } from './relationships.js';
|
import { linkCases, unlinkCases } from './relationships.js';
|
||||||
|
import { notifyCase, caseReviewSchedule, readCaseUpdates } from './followups.js';
|
||||||
import { AuthError } from '../auth/index.js';
|
import { AuthError } from '../auth/index.js';
|
||||||
import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js';
|
import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js';
|
||||||
import {
|
import {
|
||||||
@@ -53,6 +54,9 @@ type CaseRow = {
|
|||||||
title: string;
|
title: string;
|
||||||
priority: ModerationCasePriority;
|
priority: ModerationCasePriority;
|
||||||
assignee_id: string | null;
|
assignee_id: string | null;
|
||||||
|
review_at: string | null;
|
||||||
|
review_reason: string;
|
||||||
|
review_sanction_id: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function serverTarget(name: unknown, game?: ModerationGame) {
|
function serverTarget(name: unknown, game?: ModerationGame) {
|
||||||
@@ -390,7 +394,8 @@ function createCase(actor: AuthUser, input: Record<string, unknown>, now: string
|
|||||||
assignee.id
|
assignee.id
|
||||||
);
|
);
|
||||||
if (note) evidence(actor, id, note, 'Staff note', null, now);
|
if (note) evidence(actor, id, note, 'Staff note', null, now);
|
||||||
if (input.evidence !== undefined) archivedEvidence(actor, id, target, input.evidence, now);
|
const attached =
|
||||||
|
input.evidence === undefined ? 0 : archivedEvidence(actor, id, target, input.evidence, now);
|
||||||
moderationEvent(actor, 'case.opened', target, id, title, now);
|
moderationEvent(actor, 'case.opened', target, id, title, now);
|
||||||
if (priority !== 'normal' || assignee.id)
|
if (priority !== 'normal' || assignee.id)
|
||||||
moderationEvent(
|
moderationEvent(
|
||||||
@@ -404,6 +409,8 @@ function createCase(actor: AuthUser, input: Record<string, unknown>, now: string
|
|||||||
}),
|
}),
|
||||||
now
|
now
|
||||||
);
|
);
|
||||||
|
if (note || attached) notifyCase(actor.id, id, 'evidence', now);
|
||||||
|
if (assignee.id) notifyCase(actor.id, id, 'assignment', now);
|
||||||
return { id };
|
return { id };
|
||||||
}
|
}
|
||||||
function archivedEvidence(
|
function archivedEvidence(
|
||||||
@@ -465,6 +472,8 @@ function updateCase(actor: AuthUser, input: Record<string, unknown>, now: string
|
|||||||
JSON.stringify({ ...changes, ...(reason ? { reason } : {}) }),
|
JSON.stringify({ ...changes, ...(reason ? { reason } : {}) }),
|
||||||
now
|
now
|
||||||
);
|
);
|
||||||
|
if (assigneeId !== row.assignee_id)
|
||||||
|
notifyCase(actor.id, row.id, 'assignment', now, row.assignee_id);
|
||||||
return { id: row.id };
|
return { id: row.id };
|
||||||
}
|
}
|
||||||
export function mutateModeration(actorId: string, input: Record<string, unknown>) {
|
export function mutateModeration(actorId: string, input: Record<string, unknown>) {
|
||||||
@@ -474,6 +483,37 @@ export function mutateModeration(actorId: string, input: Record<string, unknown>
|
|||||||
if (input.op === 'action') return liveAction(actor, input, now);
|
if (input.op === 'action') return liveAction(actor, input, now);
|
||||||
if (input.op === 'create-case') return createCase(actor, input, now);
|
if (input.op === 'create-case') return createCase(actor, input, now);
|
||||||
if (input.op === 'update-case') return updateCase(actor, input, now);
|
if (input.op === 'update-case') return updateCase(actor, input, now);
|
||||||
|
if (input.op === 'schedule-case-review') {
|
||||||
|
const row = caseRow(input.id);
|
||||||
|
checkCaseRevision(row, input.revision);
|
||||||
|
const schedule = caseReviewSchedule(row, input, now);
|
||||||
|
if (
|
||||||
|
row.review_at === schedule.reviewAt &&
|
||||||
|
row.review_reason === schedule.reason &&
|
||||||
|
row.review_sanction_id === schedule.restrictionId
|
||||||
|
)
|
||||||
|
return { id: row.id };
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`UPDATE moderation_cases SET review_at=?,review_reason=?,review_sanction_id=?,
|
||||||
|
updated_at=?,revision=revision+1 WHERE id=?`
|
||||||
|
)
|
||||||
|
.run(schedule.reviewAt, schedule.reason, schedule.restrictionId, now, row.id);
|
||||||
|
moderationEvent(
|
||||||
|
actor,
|
||||||
|
'case.review_scheduled',
|
||||||
|
row,
|
||||||
|
row.id,
|
||||||
|
JSON.stringify(schedule),
|
||||||
|
now
|
||||||
|
);
|
||||||
|
return { id: row.id };
|
||||||
|
}
|
||||||
|
if (input.op === 'read-case-updates') {
|
||||||
|
const row = caseRow(input.id);
|
||||||
|
readCaseUpdates(actor.id, row.id, input.throughId, now);
|
||||||
|
return { id: row.id };
|
||||||
|
}
|
||||||
if (input.op === 'link-case' || input.op === 'unlink-case') {
|
if (input.op === 'link-case' || input.op === 'unlink-case') {
|
||||||
const row = caseRow(input.id);
|
const row = caseRow(input.id);
|
||||||
checkCaseRevision(row, input.revision);
|
checkCaseRevision(row, input.revision);
|
||||||
@@ -542,6 +582,7 @@ export function mutateModeration(actorId: string, input: Record<string, unknown>
|
|||||||
evidence(actor, row.id, text, 'Staff note', null, now);
|
evidence(actor, row.id, text, 'Staff note', null, now);
|
||||||
touchCase(row.id, now);
|
touchCase(row.id, now);
|
||||||
moderationEvent(actor, 'case.note', row, row.id, text, now);
|
moderationEvent(actor, 'case.note', row, row.id, text, now);
|
||||||
|
notifyCase(actor.id, row.id, 'evidence', now);
|
||||||
return { id: row.id };
|
return { id: row.id };
|
||||||
}
|
}
|
||||||
if (input.op === 'resolve-case') {
|
if (input.op === 'resolve-case') {
|
||||||
@@ -626,6 +667,7 @@ export function mutateModeration(actorId: string, input: Record<string, unknown>
|
|||||||
JSON.stringify({ count, matchId: (input.evidence as { matchId: number }).matchId }),
|
JSON.stringify({ count, matchId: (input.evidence as { matchId: number }).matchId }),
|
||||||
now
|
now
|
||||||
);
|
);
|
||||||
|
notifyCase(actor.id, row.id, 'evidence', now);
|
||||||
return { id: row.id };
|
return { id: row.id };
|
||||||
}
|
}
|
||||||
if (input.op === 'note') {
|
if (input.op === 'note') {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { moderationOverview } from './overview.js';
|
|||||||
import { caseActionLists } from './case-actions.js';
|
import { caseActionLists } from './case-actions.js';
|
||||||
import { caseAssessmentHistory } from './assessments.js';
|
import { caseAssessmentHistory } from './assessments.js';
|
||||||
import { relatedCases, candidateCases } from './relationships.js';
|
import { relatedCases, candidateCases } from './relationships.js';
|
||||||
|
import { caseNotifications, unreadCaseUpdates } from './followups.js';
|
||||||
import type {
|
import type {
|
||||||
ModerationCase,
|
ModerationCase,
|
||||||
ModerationCaseDetail,
|
ModerationCaseDetail,
|
||||||
@@ -22,7 +23,8 @@ export function moderationCaseDetail(actorId: string, id: string): ModerationCas
|
|||||||
.prepare(
|
.prepare(
|
||||||
`SELECT c.id,c.game,c.guid,c.title,c.status,c.priority,
|
`SELECT c.id,c.game,c.guid,c.title,c.status,c.priority,
|
||||||
c.assignee_id AS assigneeId,u.username AS assignee,c.actor,c.created_at AS createdAt,
|
c.assignee_id AS assigneeId,u.username AS assignee,c.actor,c.created_at AS createdAt,
|
||||||
c.updated_at AS updatedAt,c.revision FROM moderation_cases c
|
c.updated_at AS updatedAt,c.revision,c.review_at AS reviewAt,
|
||||||
|
c.review_reason AS reviewReason,c.review_sanction_id AS reviewSanctionId FROM moderation_cases c
|
||||||
LEFT JOIN auth_users u ON u.id=c.assignee_id WHERE c.id=?`
|
LEFT JOIN auth_users u ON u.id=c.assignee_id WHERE c.id=?`
|
||||||
)
|
)
|
||||||
.get(caseId) as Omit<ModerationCase, 'evidence'> | undefined;
|
.get(caseId) as Omit<ModerationCase, 'evidence'> | undefined;
|
||||||
@@ -72,7 +74,7 @@ export function moderationCaseDetail(actorId: string, id: string): ModerationCas
|
|||||||
.all() as ModerationCaseDetail['assignees'];
|
.all() as ModerationCaseDetail['assignees'];
|
||||||
const assessmentHistory = caseAssessmentHistory(caseId);
|
const assessmentHistory = caseAssessmentHistory(caseId);
|
||||||
return {
|
return {
|
||||||
case: { ...row, evidence },
|
case: { ...row, evidence, unreadUpdates: unreadCaseUpdates(actorId, caseId) },
|
||||||
player: overview.players[0] ?? {
|
player: overview.players[0] ?? {
|
||||||
game: row.game,
|
game: row.game,
|
||||||
guid: row.guid,
|
guid: row.guid,
|
||||||
@@ -89,6 +91,7 @@ export function moderationCaseDetail(actorId: string, id: string): ModerationCas
|
|||||||
assessmentHistory,
|
assessmentHistory,
|
||||||
relatedCases: relatedCases(caseId),
|
relatedCases: relatedCases(caseId),
|
||||||
candidateCases: candidateCases(row),
|
candidateCases: candidateCases(row),
|
||||||
|
notifications: caseNotifications(actorId, caseId),
|
||||||
events,
|
events,
|
||||||
coverage: overview.coverage,
|
coverage: overview.coverage,
|
||||||
assignees,
|
assignees,
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { db } from '../db/index.js';
|
||||||
|
import { AuthError } from '../auth/index.js';
|
||||||
|
import type { ModerationCaseNotification, ModerationGame } from '../../moderation.js';
|
||||||
|
import { textField } from './core.js';
|
||||||
|
import { recordedCaseAction, isCaseActionLinked } from './case-actions.js';
|
||||||
|
|
||||||
|
/** Called once per accepted mutation, within its existing audit transaction. */
|
||||||
|
export function notifyCase(
|
||||||
|
actorId: string,
|
||||||
|
caseId: string,
|
||||||
|
kind: ModerationCaseNotification['kind'],
|
||||||
|
now: string,
|
||||||
|
previousAssignee: string | null = null
|
||||||
|
) {
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO moderation_case_notifications(user_id,case_id,kind,created_at)
|
||||||
|
SELECT u.id,?,?,? FROM auth_users u JOIN moderation_cases c ON c.id=?
|
||||||
|
WHERE (u.id=c.actor_id OR u.id=c.assignee_id OR u.id=?) AND u.id<>?
|
||||||
|
AND u.role IN ('admin','moderator') AND u.disabled=0 AND u.must_change_password=0`
|
||||||
|
)
|
||||||
|
.run(caseId, kind, now, caseId, previousAssignee, actorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function caseNotifications(actorId: string, caseId?: string): ModerationCaseNotification[] {
|
||||||
|
return db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT n.id,n.case_id AS caseId,c.title AS caseTitle,n.kind,
|
||||||
|
n.created_at AS createdAt,n.read_at AS readAt FROM moderation_case_notifications n
|
||||||
|
JOIN moderation_cases c ON c.id=n.case_id WHERE n.user_id=?
|
||||||
|
${caseId === undefined ? 'AND n.read_at IS NULL' : 'AND n.case_id=?'} ORDER BY n.id DESC LIMIT 25`
|
||||||
|
)
|
||||||
|
.all(
|
||||||
|
...(caseId === undefined ? [actorId] : [actorId, caseId])
|
||||||
|
) as ModerationCaseNotification[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unreadCaseUpdates(actorId: string, caseId?: string): number {
|
||||||
|
return (
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT COUNT(*) AS count FROM moderation_case_notifications
|
||||||
|
WHERE user_id=? AND read_at IS NULL${caseId === undefined ? '' : ' AND case_id=?'}`
|
||||||
|
)
|
||||||
|
.get(...(caseId === undefined ? [actorId] : [actorId, caseId])) as { count: number }
|
||||||
|
).count;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readCaseUpdates(actorId: string, caseId: string, throughId: unknown, now: string) {
|
||||||
|
if (!Number.isSafeInteger(throughId) || (throughId as number) < 0)
|
||||||
|
throw new AuthError('Choose the last update you have reviewed.');
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`UPDATE moderation_case_notifications SET read_at=?
|
||||||
|
WHERE user_id=? AND case_id=? AND id<=? AND read_at IS NULL`
|
||||||
|
)
|
||||||
|
.run(now, actorId, caseId, throughId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canonicalDate(value: unknown): string {
|
||||||
|
if (
|
||||||
|
typeof value !== 'string' ||
|
||||||
|
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value) ||
|
||||||
|
!Number.isFinite(Date.parse(value)) ||
|
||||||
|
new Date(value).toISOString() !== value
|
||||||
|
)
|
||||||
|
throw new AuthError('Choose a valid review date and time.');
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function caseReviewSchedule(
|
||||||
|
subject: { id: string; game: ModerationGame; guid: string },
|
||||||
|
input: Record<string, unknown>,
|
||||||
|
now: string
|
||||||
|
) {
|
||||||
|
const reason = textField(input.reason, 'Review reason', 4000);
|
||||||
|
if ((input.reviewAt === undefined) === (input.restrictionId === undefined))
|
||||||
|
throw new AuthError('Choose either a review date or a linked mute.');
|
||||||
|
if (input.restrictionId !== undefined) {
|
||||||
|
const id = textField(input.restrictionId, 'Restriction ID', 128);
|
||||||
|
const reference = { type: 'restriction' as const, id };
|
||||||
|
const restriction = recordedCaseAction(subject, reference, now);
|
||||||
|
if (!isCaseActionLinked(subject.id, reference))
|
||||||
|
throw new AuthError('Link the mute to this case before scheduling its review.', 409);
|
||||||
|
if (restriction.kind !== 'mute' || !restriction.expiresAt)
|
||||||
|
throw new AuthError('Choose a linked mute with a recorded expiry time.');
|
||||||
|
return { reviewAt: canonicalDate(restriction.expiresAt), reason, restrictionId: id };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
reviewAt: input.reviewAt === null ? null : canonicalDate(input.reviewAt),
|
||||||
|
reason,
|
||||||
|
restrictionId: null
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
import { currentCaseAssessment } from './assessments.js';
|
import { currentCaseAssessment } from './assessments.js';
|
||||||
|
import { caseNotifications, unreadCaseUpdates } from './followups.js';
|
||||||
import { moderationActor, moderationNow, policyRevision } from './core.js';
|
import { moderationActor, moderationNow, policyRevision } from './core.js';
|
||||||
import { runtimeFreshMs } from './actions.js';
|
import { runtimeFreshMs } from './actions.js';
|
||||||
import { refreshActionStates } from './runtime.js';
|
import { refreshActionStates } from './runtime.js';
|
||||||
@@ -23,6 +24,7 @@ export function moderationQuery(params: URLSearchParams): ModerationQuery {
|
|||||||
const offset = Number(params.get('offset') ?? 0);
|
const offset = Number(params.get('offset') ?? 0);
|
||||||
const caseStatus = params.get('caseStatus') ?? '';
|
const caseStatus = params.get('caseStatus') ?? '';
|
||||||
const priority = params.get('priority') ?? '';
|
const priority = params.get('priority') ?? '';
|
||||||
|
const review = params.get('review') ?? '';
|
||||||
return {
|
return {
|
||||||
view: view === 'restrictions' || view === 'cases' || view === 'history' ? view : 'players',
|
view: view === 'restrictions' || view === 'cases' || view === 'history' ? view : 'players',
|
||||||
q: (params.get('q') ?? '').trim().slice(0, 200),
|
q: (params.get('q') ?? '').trim().slice(0, 200),
|
||||||
@@ -35,7 +37,10 @@ export function moderationQuery(params: URLSearchParams): ModerationQuery {
|
|||||||
priority: ['low', 'normal', 'high', 'urgent'].includes(priority)
|
priority: ['low', 'normal', 'high', 'urgent'].includes(priority)
|
||||||
? (priority as ModerationQuery['priority'])
|
? (priority as ModerationQuery['priority'])
|
||||||
: '',
|
: '',
|
||||||
assignedTo: (params.get('assignedTo') ?? '').slice(0, 128)
|
assignedTo: (params.get('assignedTo') ?? '').slice(0, 128),
|
||||||
|
review: ['due', 'scheduled', 'updates'].includes(review)
|
||||||
|
? (review as ModerationQuery['review'])
|
||||||
|
: ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export function moderationOverview(actorId: string, query: ModerationQuery): ModerationOverview {
|
export function moderationOverview(actorId: string, query: ModerationQuery): ModerationOverview {
|
||||||
@@ -90,7 +95,9 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
|
|||||||
coverage,
|
coverage,
|
||||||
total: 0,
|
total: 0,
|
||||||
limit,
|
limit,
|
||||||
checkedAt: now
|
checkedAt: now,
|
||||||
|
notifications: caseNotifications(actorId),
|
||||||
|
unreadCount: unreadCaseUpdates(actorId)
|
||||||
};
|
};
|
||||||
const where: string[] = [],
|
const where: string[] = [],
|
||||||
values: (string | number)[] = [];
|
values: (string | number)[] = [];
|
||||||
@@ -122,6 +129,16 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
|
|||||||
where.push('p.assignee_id=?');
|
where.push('p.assignee_id=?');
|
||||||
values.push(query.assignedTo === 'me' ? actorId : query.assignedTo);
|
values.push(query.assignedTo === 'me' ? actorId : query.assignedTo);
|
||||||
}
|
}
|
||||||
|
if (query.review === 'due') {
|
||||||
|
where.push('p.review_at IS NOT NULL AND p.review_at<=?');
|
||||||
|
values.push(now);
|
||||||
|
} else if (query.review === 'scheduled') where.push('p.review_at IS NOT NULL');
|
||||||
|
else if (query.review === 'updates') {
|
||||||
|
where.push(
|
||||||
|
'EXISTS (SELECT 1 FROM moderation_case_notifications n WHERE n.case_id=p.id AND n.user_id=? AND n.read_at IS NULL)'
|
||||||
|
);
|
||||||
|
values.push(actorId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (query.q) {
|
if (query.q) {
|
||||||
const extra =
|
const extra =
|
||||||
@@ -138,7 +155,12 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
|
|||||||
values.push(query.q, query.q, query.q);
|
values.push(query.q, query.q, query.q);
|
||||||
}
|
}
|
||||||
const clause = where.length ? ' WHERE ' + where.join(' AND ') : '';
|
const clause = where.length ? ' WHERE ' + where.join(' AND ') : '';
|
||||||
function rows<T>(table: string, select: string, order: string): T[] {
|
function rows<T>(
|
||||||
|
table: string,
|
||||||
|
select: string,
|
||||||
|
order: string,
|
||||||
|
orderValues: string[] = []
|
||||||
|
): T[] {
|
||||||
result.total = (
|
result.total = (
|
||||||
handle
|
handle
|
||||||
.prepare(`SELECT COUNT(*) AS count FROM ${table} p${clause}`)
|
.prepare(`SELECT COUNT(*) AS count FROM ${table} p${clause}`)
|
||||||
@@ -152,7 +174,7 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
|
|||||||
.prepare(
|
.prepare(
|
||||||
`SELECT ${select} FROM ${table} p${clause} ORDER BY ${order} LIMIT ? OFFSET ?`
|
`SELECT ${select} FROM ${table} p${clause} ORDER BY ${order} LIMIT ? OFFSET ?`
|
||||||
)
|
)
|
||||||
.all(...values, limit, result.query.offset) as T[];
|
.all(...values, ...orderValues, limit, result.query.offset) as T[];
|
||||||
}
|
}
|
||||||
if (query.view === 'players') {
|
if (query.view === 'players') {
|
||||||
result.players = rows<Omit<ModerationPlayer, 'aliases' | 'servers' | 'restrictions'>>(
|
result.players = rows<Omit<ModerationPlayer, 'aliases' | 'servers' | 'restrictions'>>(
|
||||||
@@ -197,11 +219,13 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod
|
|||||||
} else if (query.view === 'cases') {
|
} else if (query.view === 'cases') {
|
||||||
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.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.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','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",
|
||||||
|
[now, now]
|
||||||
).map((c) => ({
|
).map((c) => ({
|
||||||
...c,
|
...c,
|
||||||
assessment: currentCaseAssessment(c.id),
|
assessment: currentCaseAssessment(c.id),
|
||||||
|
unreadUpdates: unreadCaseUpdates(actorId, c.id),
|
||||||
evidence: handle
|
evidence: handle
|
||||||
.prepare(
|
.prepare(
|
||||||
'SELECT id,text,source,created_at AS createdAt,actor FROM moderation_evidence WHERE case_id=? ORDER BY created_at,id'
|
'SELECT id,text,source,created_at AS createdAt,actor FROM moderation_evidence WHERE case_id=? ORDER BY created_at,id'
|
||||||
|
|||||||
@@ -202,3 +202,24 @@ export function migrateModerationCaseRelationships(handle: Database.Database): v
|
|||||||
ON moderation_cases(game,guid,updated_at DESC,id);
|
ON moderation_cases(game,guid,updated_at DESC,id);
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function migrateModerationCaseFollowups(handle: Database.Database): void {
|
||||||
|
const columns = handle.pragma('table_info(moderation_cases)') as { name: string }[];
|
||||||
|
if (!columns.some((column) => column.name === 'review_at'))
|
||||||
|
handle.exec(`ALTER TABLE moderation_cases ADD COLUMN review_at TEXT;
|
||||||
|
ALTER TABLE moderation_cases ADD COLUMN review_reason TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE moderation_cases ADD COLUMN review_sanction_id TEXT REFERENCES moderation_sanctions(id) ON DELETE SET NULL;`);
|
||||||
|
handle.exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS moderation_case_review_due ON moderation_cases(review_at) WHERE review_at IS NOT NULL;
|
||||||
|
CREATE TABLE IF NOT EXISTS moderation_case_notifications (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL REFERENCES auth_users(id) ON DELETE CASCADE,
|
||||||
|
case_id TEXT NOT NULL REFERENCES moderation_cases(id),
|
||||||
|
kind TEXT NOT NULL CHECK(kind IN ('evidence','assignment')),
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
read_at TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS moderation_case_notifications_unread ON moderation_case_notifications(user_id,read_at,id DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS moderation_case_notifications_case ON moderation_case_notifications(user_id,case_id,read_at,id DESC);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ModerationCase, ModerationCaseAction } from '$lib/moderation.js';
|
||||||
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
|
import Badge from '../primitives/Badge.svelte';
|
||||||
|
import Button from '../primitives/Button.svelte';
|
||||||
|
import CaseReviewDialog from './CaseReviewDialog.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
item,
|
||||||
|
actions = [],
|
||||||
|
checkedAt,
|
||||||
|
permitted,
|
||||||
|
onsubmitted
|
||||||
|
}: {
|
||||||
|
item: ModerationCase;
|
||||||
|
actions?: ModerationCaseAction[];
|
||||||
|
checkedAt: string;
|
||||||
|
permitted: boolean;
|
||||||
|
onsubmitted: () => void;
|
||||||
|
} = $props();
|
||||||
|
let open = $state(false);
|
||||||
|
let clearing = $state(false);
|
||||||
|
const overdue = $derived(!!item.reviewAt && Date.parse(item.reviewAt) <= Date.parse(checkedAt));
|
||||||
|
$effect(() => {
|
||||||
|
void item.id;
|
||||||
|
open = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section aria-label="Follow-up review">
|
||||||
|
<div class="heading">
|
||||||
|
<h2>Follow-up review</h2>
|
||||||
|
{#if overdue}<Badge variant="warning">Overdue review</Badge>{/if}
|
||||||
|
</div>
|
||||||
|
{#if item.reviewAt}<p>
|
||||||
|
<time datetime={item.reviewAt}>{$timeFormat.dateTime(item.reviewAt)}</time>
|
||||||
|
</p>
|
||||||
|
{#if item.reviewSanctionId}<p class="hint">Review after linked mute expires.</p>{/if}
|
||||||
|
{:else}<p class="hint">No review scheduled.</p>{/if}
|
||||||
|
{#if item.reviewReason}<p class="reason">{item.reviewReason}</p>{/if}
|
||||||
|
{#if permitted}<div class="actions">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onclick={() => {
|
||||||
|
clearing = false;
|
||||||
|
open = true;
|
||||||
|
}}>{item.reviewAt ? 'Reschedule review' : 'Schedule review'}</Button
|
||||||
|
>
|
||||||
|
{#if item.reviewAt}<Button
|
||||||
|
size="sm"
|
||||||
|
onclick={() => {
|
||||||
|
clearing = true;
|
||||||
|
open = true;
|
||||||
|
}}>Complete or clear review</Button
|
||||||
|
>{/if}
|
||||||
|
</div>{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if permitted && open}<CaseReviewDialog bind:open {item} {actions} {clearing} {onsubmitted} />{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
section {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-5);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--surface);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.heading,
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.heading {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.reason {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { untrack } from 'svelte';
|
||||||
|
import type { ModerationCase, ModerationCaseAction } from '$lib/moderation.js';
|
||||||
|
import { timeFormat } from '$ui/stores/time-zone.js';
|
||||||
|
import Dialog from '../primitives/Dialog.svelte';
|
||||||
|
import Field from '../primitives/Field.svelte';
|
||||||
|
import Input from '../primitives/Input.svelte';
|
||||||
|
import Select from '../primitives/Select.svelte';
|
||||||
|
import Textarea from '../primitives/Textarea.svelte';
|
||||||
|
import Button from '../primitives/Button.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(false),
|
||||||
|
item,
|
||||||
|
actions,
|
||||||
|
clearing = false,
|
||||||
|
onsubmitted
|
||||||
|
}: {
|
||||||
|
open?: boolean;
|
||||||
|
item: ModerationCase;
|
||||||
|
actions: ModerationCaseAction[];
|
||||||
|
clearing?: boolean;
|
||||||
|
onsubmitted: () => void;
|
||||||
|
} = $props();
|
||||||
|
const openedRevision = untrack(() => item.revision);
|
||||||
|
const caseId = untrack(() => item.id);
|
||||||
|
const mutes = $derived(
|
||||||
|
actions.filter(
|
||||||
|
(action) => action.type === 'restriction' && action.kind === 'mute' && action.expiresAt
|
||||||
|
)
|
||||||
|
);
|
||||||
|
let source = $state(untrack(() => item.reviewSanctionId ?? 'custom'));
|
||||||
|
function browserDate(iso?: string | null): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
const date = new Date(iso);
|
||||||
|
if (!Number.isFinite(date.getTime())) return '';
|
||||||
|
const pad = (value: number) => String(value).padStart(2, '0');
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||||
|
}
|
||||||
|
let date = $state(untrack(() => browserDate(item.reviewAt)));
|
||||||
|
let reason = $state(untrack(() => (clearing ? '' : (item.reviewReason ?? ''))));
|
||||||
|
let busy = $state(false);
|
||||||
|
let uncertain = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
let pending: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
|
async function submit(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (busy) return;
|
||||||
|
if (!pending) {
|
||||||
|
const chosen = new Date(date);
|
||||||
|
if (
|
||||||
|
!reason.trim() ||
|
||||||
|
(!clearing && source === 'custom' && (!date || !Number.isFinite(chosen.getTime())))
|
||||||
|
) {
|
||||||
|
error = 'Choose a review date and record a reason for this follow-up change.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!clearing && source !== 'custom' && !mutes.some((mute) => mute.id === source)) {
|
||||||
|
error = 'Choose an available linked mute or a custom review date.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pending = {
|
||||||
|
op: 'schedule-case-review',
|
||||||
|
id: caseId,
|
||||||
|
revision: openedRevision,
|
||||||
|
reason: reason.trim(),
|
||||||
|
idempotencyKey: crypto.randomUUID(),
|
||||||
|
...(clearing
|
||||||
|
? { reviewAt: null }
|
||||||
|
: source === 'custom'
|
||||||
|
? { reviewAt: chosen.toISOString() }
|
||||||
|
: { restrictionId: source })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/moderation', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(pending)
|
||||||
|
});
|
||||||
|
const result = await response.json().catch(() => null);
|
||||||
|
if (!response.ok || !result?.ok) {
|
||||||
|
uncertain = response.status >= 500 || response.ok;
|
||||||
|
if (!uncertain) pending = null;
|
||||||
|
throw new Error(result?.error || 'Could not update the follow-up review.');
|
||||||
|
}
|
||||||
|
open = false;
|
||||||
|
onsubmitted();
|
||||||
|
} catch (cause) {
|
||||||
|
if (pending) uncertain = true;
|
||||||
|
error =
|
||||||
|
cause instanceof Error ? cause.message : 'Could not update the follow-up review.';
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
bind:open
|
||||||
|
title={clearing ? 'Complete or clear review' : 'Schedule follow-up review'}
|
||||||
|
{busy}
|
||||||
|
description={clearing
|
||||||
|
? 'Record the review result or why the follow-up is no longer needed. This clears its due date.'
|
||||||
|
: 'Follow-up reviews remain scheduled when a case is closed.'}
|
||||||
|
>
|
||||||
|
<form onsubmit={submit}>
|
||||||
|
<fieldset disabled={busy || uncertain}>
|
||||||
|
{#if !clearing}
|
||||||
|
<Field label="Review timing"
|
||||||
|
><Select
|
||||||
|
bind:value={source}
|
||||||
|
options={[
|
||||||
|
{ value: 'custom', label: 'Choose a date and time' },
|
||||||
|
...mutes.map((mute) => ({
|
||||||
|
value: mute.id,
|
||||||
|
label: `After mute expires · ${$timeFormat.dateTime(mute.expiresAt!)} · ${mute.reason}`
|
||||||
|
}))
|
||||||
|
]}
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
|
{#if source === 'custom'}<Field label="Review date (browser local time)" required
|
||||||
|
><Input type="datetime-local" bind:value={date} required /></Field
|
||||||
|
>
|
||||||
|
{:else}<p class="hint">
|
||||||
|
The review will be due at the linked mute's recorded expiration time.
|
||||||
|
</p>{/if}
|
||||||
|
{/if}
|
||||||
|
<Field
|
||||||
|
label={clearing ? 'Review result or clearing reason' : 'Follow-up reason'}
|
||||||
|
required><Textarea bind:value={reason} required rows={3} maxlength={4000} /></Field
|
||||||
|
>
|
||||||
|
</fieldset>
|
||||||
|
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||||
|
{#if uncertain}<p class="hint">
|
||||||
|
The result is uncertain. Retry sends the same request safely.
|
||||||
|
</p>{/if}
|
||||||
|
<div class="actions">
|
||||||
|
<Button disabled={busy} onclick={() => (open = false)}>Cancel</Button><Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
loading={busy}
|
||||||
|
>{uncertain
|
||||||
|
? 'Retry same request'
|
||||||
|
: clearing
|
||||||
|
? 'Complete or clear review'
|
||||||
|
: 'Save follow-up'}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
form,
|
||||||
|
fieldset {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-4);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
fieldset {
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { ModerationCaseNotification } from '$lib/moderation.js';
|
||||||
|
import Button from '../primitives/Button.svelte';
|
||||||
|
import Badge from '../primitives/Badge.svelte';
|
||||||
|
import RelativeTime from '../RelativeTime.svelte';
|
||||||
|
|
||||||
|
let {
|
||||||
|
caseId,
|
||||||
|
notifications = [],
|
||||||
|
unread = 0,
|
||||||
|
permitted,
|
||||||
|
onsubmitted
|
||||||
|
}: {
|
||||||
|
caseId: string;
|
||||||
|
notifications?: ModerationCaseNotification[];
|
||||||
|
unread?: number;
|
||||||
|
permitted: boolean;
|
||||||
|
onsubmitted: () => void;
|
||||||
|
} = $props();
|
||||||
|
let busy = $state(false);
|
||||||
|
let error = $state('');
|
||||||
|
let pending: Record<string, unknown> | null = $state(null);
|
||||||
|
const throughId = $derived(Math.max(0, ...notifications.map((item) => item.id)));
|
||||||
|
$effect(() => {
|
||||||
|
void caseId;
|
||||||
|
pending = null;
|
||||||
|
error = '';
|
||||||
|
});
|
||||||
|
async function markSeen() {
|
||||||
|
if (busy || !permitted || (!pending && !throughId)) return;
|
||||||
|
pending ??= {
|
||||||
|
op: 'read-case-updates',
|
||||||
|
id: caseId,
|
||||||
|
throughId,
|
||||||
|
idempotencyKey: crypto.randomUUID()
|
||||||
|
};
|
||||||
|
busy = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/moderation', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(pending)
|
||||||
|
});
|
||||||
|
const result = await response.json().catch(() => null);
|
||||||
|
if (!response.ok || !result?.ok) {
|
||||||
|
if (response.status < 500 && !response.ok) pending = null;
|
||||||
|
throw new Error(result?.error || 'Could not mark these updates as seen.');
|
||||||
|
}
|
||||||
|
pending = null;
|
||||||
|
onsubmitted();
|
||||||
|
} catch (cause) {
|
||||||
|
error =
|
||||||
|
cause instanceof Error ? cause.message : 'Could not mark these updates as seen.';
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section aria-label="Your case updates">
|
||||||
|
<div class="heading">
|
||||||
|
<h2>Your updates</h2>
|
||||||
|
{#if unread}<Badge variant="warning">{unread} unread</Badge>{/if}
|
||||||
|
</div>
|
||||||
|
{#if notifications.length}<ol>
|
||||||
|
{#each notifications as update (update.id)}<li>
|
||||||
|
<div class="heading">
|
||||||
|
<strong
|
||||||
|
>{update.kind === 'evidence'
|
||||||
|
? 'Evidence or note added'
|
||||||
|
: 'Assignment changed'}</strong
|
||||||
|
>{#if !update.readAt}<Badge>Unread</Badge>{/if}
|
||||||
|
</div>
|
||||||
|
<RelativeTime value={update.createdAt} />
|
||||||
|
</li>{/each}
|
||||||
|
</ol>{:else}<p class="hint">No personal case updates.</p>{/if}
|
||||||
|
{#if notifications.length >= 25}<p class="hint">
|
||||||
|
Showing your latest 25 updates for this case.
|
||||||
|
</p>{/if}
|
||||||
|
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||||
|
{#if permitted && (unread || pending)}<p class="hint">
|
||||||
|
Marks your updates through the newest shown entry as read.
|
||||||
|
</p>
|
||||||
|
<Button size="sm" disabled={!throughId} loading={busy} onclick={markSeen}
|
||||||
|
>{pending && error ? 'Retry same request' : 'Mark updates as seen'}</Button
|
||||||
|
>{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
section {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
padding: var(--space-5);
|
||||||
|
border: 1px solid var(--border-subtle);
|
||||||
|
border-radius: var(--radius-lg);
|
||||||
|
background: var(--surface);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.heading {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: var(--text-lg);
|
||||||
|
}
|
||||||
|
ol {
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
li {
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
border-top: 1px solid var(--border-subtle);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
line-height: var(--leading-body);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -71,6 +71,8 @@ export const moderationActionLabels: Record<string, string> = {
|
|||||||
'case.reopened': 'Case reopened',
|
'case.reopened': 'Case reopened',
|
||||||
'case.updated': 'Case updated',
|
'case.updated': 'Case updated',
|
||||||
'case.assessed': 'Assessment revised',
|
'case.assessed': 'Assessment revised',
|
||||||
|
'case.review_scheduled': 'Follow-up updated',
|
||||||
|
'schedule-case-review': 'Follow-up updated',
|
||||||
'case.linked': 'Case relationship added',
|
'case.linked': 'Case relationship added',
|
||||||
'case.unlinked': 'Case relationship removed',
|
'case.unlinked': 'Case relationship removed',
|
||||||
'link-case': 'Case relationship added',
|
'link-case': 'Case relationship added',
|
||||||
@@ -119,6 +121,8 @@ export function moderationEventDetail(detail: string, action: string): string {
|
|||||||
: moderationCaseRelationLabels[value.relation as ModerationCaseRelation];
|
: moderationCaseRelationLabels[value.relation as ModerationCaseRelation];
|
||||||
return `${label}: ${value.relatedCaseId}\n${value.reason}`;
|
return `${label}: ${value.relatedCaseId}\n${value.reason}`;
|
||||||
}
|
}
|
||||||
|
if (action === 'case.review_scheduled' && typeof value.reason === 'string')
|
||||||
|
return `${typeof value.reviewAt === 'string' ? `Review scheduled for ${value.reviewAt}` : 'Follow-up cleared'}\n${value.reason}`;
|
||||||
if (action === 'case.assessed' && typeof value.summary === 'string')
|
if (action === 'case.assessed' && typeof value.summary === 'string')
|
||||||
return `Assessment revision ${value.assessmentRevision}: ${value.summary}`;
|
return `Assessment revision ${value.assessmentRevision}: ${value.summary}`;
|
||||||
if (action === 'case.updated') {
|
if (action === 'case.updated') {
|
||||||
|
|||||||
@@ -249,6 +249,9 @@
|
|||||||
error={refreshError ? 'Updates paused' : ''}
|
error={refreshError ? 'Updates paused' : ''}
|
||||||
/>
|
/>
|
||||||
<Button onclick={() => refresh()} loading={refreshing}>Refresh</Button>
|
<Button onclick={() => refresh()} loading={refreshing}>Refresh</Button>
|
||||||
|
{#if report.unreadCount}<Button href="/moderation?view=cases&review=updates"
|
||||||
|
>Your case updates ({report.unreadCount})</Button
|
||||||
|
>{/if}
|
||||||
{#if permitted}<Button variant="primary" onclick={() => openDialog('restriction')}
|
{#if permitted}<Button variant="primary" onclick={() => openDialog('restriction')}
|
||||||
>Restrict player</Button
|
>Restrict player</Button
|
||||||
><Button onclick={() => openDialog('action')}>Live action</Button>{/if}
|
><Button onclick={() => openDialog('action')}>Live action</Button>{/if}
|
||||||
@@ -339,6 +342,7 @@
|
|||||||
caseStatus: filters.caseStatus ?? null,
|
caseStatus: filters.caseStatus ?? null,
|
||||||
priority: filters.priority ?? null,
|
priority: filters.priority ?? null,
|
||||||
assignedTo: filters.assignedTo ?? null,
|
assignedTo: filters.assignedTo ?? null,
|
||||||
|
review: filters.review ?? null,
|
||||||
offset: null
|
offset: null
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -364,6 +368,18 @@
|
|||||||
>
|
>
|
||||||
<Field label="Player GUID"><Input name="guid" bind:value={filters.guid} mono /></Field>
|
<Field label="Player GUID"><Input name="guid" bind:value={filters.guid} mono /></Field>
|
||||||
{#if report.query.view === 'cases'}
|
{#if report.query.view === 'cases'}
|
||||||
|
<Field label="Review queue"
|
||||||
|
><Select
|
||||||
|
name="review"
|
||||||
|
bind:value={filters.review}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: 'All cases' },
|
||||||
|
{ value: 'due', label: 'Overdue reviews' },
|
||||||
|
{ value: 'scheduled', label: 'Scheduled reviews' },
|
||||||
|
{ value: 'updates', label: 'Your unread updates' }
|
||||||
|
]}
|
||||||
|
/></Field
|
||||||
|
>
|
||||||
<Field label="Case status"
|
<Field label="Case status"
|
||||||
><Select
|
><Select
|
||||||
name="caseStatus"
|
name="caseStatus"
|
||||||
@@ -418,7 +434,8 @@
|
|||||||
matchId: null,
|
matchId: null,
|
||||||
caseStatus: null,
|
caseStatus: null,
|
||||||
priority: null,
|
priority: null,
|
||||||
assignedTo: null
|
assignedTo: null,
|
||||||
|
review: null
|
||||||
})}>Reset</Button
|
})}>Reset</Button
|
||||||
>
|
>
|
||||||
</form>
|
</form>
|
||||||
@@ -597,6 +614,31 @@
|
|||||||
description="Restrictions matching these filters will appear here, including expiry and revocation history."
|
description="Restrictions matching these filters will appear here, including expiry and revocation history."
|
||||||
/>{/if}
|
/>{/if}
|
||||||
{:else if report.query.view === 'cases'}
|
{:else if report.query.view === 'cases'}
|
||||||
|
{#if report.notifications?.length}
|
||||||
|
<section class="record" aria-label="Your case notifications">
|
||||||
|
<h2>Your case updates ({report.unreadCount ?? report.notifications.length})</h2>
|
||||||
|
<ul class="notification-list">
|
||||||
|
{#each report.notifications as notification (notification.id)}
|
||||||
|
<li>
|
||||||
|
<a href={moderationCaseHref(notification.caseId)}
|
||||||
|
>{notification.caseTitle}</a
|
||||||
|
>
|
||||||
|
· {notification.kind === 'evidence'
|
||||||
|
? 'Evidence or note added'
|
||||||
|
: 'Assignment changed'} · <RelativeTime
|
||||||
|
value={notification.createdAt}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
<p class="hint">Open a case to review and mark its updates read.</p>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
<div class="view-actions">
|
||||||
|
<Button href="/moderation?view=cases&review=due">Overdue reviews</Button><Button
|
||||||
|
href="/moderation?view=cases&review=updates">Your unread updates</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
{#if permitted}<div class="view-actions">
|
{#if permitted}<div class="view-actions">
|
||||||
<Button onclick={() => openDialog('case')}>Open case</Button>
|
<Button onclick={() => openDialog('case')}>Open case</Button>
|
||||||
</div>{/if}
|
</div>{/if}
|
||||||
@@ -628,6 +670,27 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}<p class="hint">Not assessed</p>{/if}
|
{:else}<p class="hint">Not assessed</p>{/if}
|
||||||
|
{#if item.reviewAt}<div class="record-heading">
|
||||||
|
<Badge
|
||||||
|
variant={Date.parse(item.reviewAt) <=
|
||||||
|
Date.parse(report.checkedAt)
|
||||||
|
? 'warning'
|
||||||
|
: 'info'}
|
||||||
|
>{Date.parse(item.reviewAt) <= Date.parse(report.checkedAt)
|
||||||
|
? 'Overdue review'
|
||||||
|
: 'Review scheduled'}</Badge
|
||||||
|
>
|
||||||
|
<time datetime={item.reviewAt}
|
||||||
|
>{$timeFormat.dateTime(item.reviewAt)}</time
|
||||||
|
>
|
||||||
|
</div>{/if}
|
||||||
|
{#if item.unreadUpdates}<p>
|
||||||
|
<Badge variant="info"
|
||||||
|
>{item.unreadUpdates} unread {item.unreadUpdates === 1
|
||||||
|
? 'update'
|
||||||
|
: 'updates'}</Badge
|
||||||
|
>
|
||||||
|
</p>{/if}
|
||||||
<dl>
|
<dl>
|
||||||
<div>
|
<div>
|
||||||
<dt>Priority</dt>
|
<dt>Priority</dt>
|
||||||
@@ -900,4 +963,10 @@
|
|||||||
line-clamp: 3;
|
line-clamp: 3;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.notification-list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: var(--space-5);
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -33,6 +33,8 @@
|
|||||||
import LinkCaseActionDialog from '$ui/components/moderation/LinkCaseActionDialog.svelte';
|
import LinkCaseActionDialog from '$ui/components/moderation/LinkCaseActionDialog.svelte';
|
||||||
import CaseAssessment from '$ui/components/moderation/CaseAssessment.svelte';
|
import CaseAssessment from '$ui/components/moderation/CaseAssessment.svelte';
|
||||||
import CaseRelationships from '$ui/components/moderation/CaseRelationships.svelte';
|
import CaseRelationships from '$ui/components/moderation/CaseRelationships.svelte';
|
||||||
|
import CaseFollowUp from '$ui/components/moderation/CaseFollowUp.svelte';
|
||||||
|
import CaseUpdates from '$ui/components/moderation/CaseUpdates.svelte';
|
||||||
import { preservedConversation } from '$ui/moderation-evidence.js';
|
import { preservedConversation } from '$ui/moderation-evidence.js';
|
||||||
|
|
||||||
let { data }: { data: { moderationCase: ModerationCaseDetail } } = $props();
|
let { data }: { data: { moderationCase: ModerationCaseDetail } } = $props();
|
||||||
@@ -396,6 +398,27 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="case-sidebar">
|
<aside class="case-sidebar">
|
||||||
|
<CaseFollowUp
|
||||||
|
{item}
|
||||||
|
actions={report.linkedActions}
|
||||||
|
checkedAt={report.checkedAt}
|
||||||
|
{permitted}
|
||||||
|
onsubmitted={() => {
|
||||||
|
notice = 'Follow-up review updated.';
|
||||||
|
cancelRefresh();
|
||||||
|
void refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<CaseUpdates
|
||||||
|
caseId={item.id}
|
||||||
|
notifications={report.notifications}
|
||||||
|
unread={item.unreadUpdates}
|
||||||
|
{permitted}
|
||||||
|
onsubmitted={() => {
|
||||||
|
cancelRefresh();
|
||||||
|
void refresh();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<CaseRelationships
|
<CaseRelationships
|
||||||
caseId={item.id}
|
caseId={item.id}
|
||||||
revision={item.revision}
|
revision={item.revision}
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { afterEach, expect, it, vi } from 'vitest';
|
||||||
|
import { flushSync, mount, tick, unmount } from 'svelte';
|
||||||
|
import { fromStore, writable } from 'svelte/store';
|
||||||
|
import CaseFollowUp from '../../src/lib/ui/components/moderation/CaseFollowUp.svelte';
|
||||||
|
import CaseUpdates from '../../src/lib/ui/components/moderation/CaseUpdates.svelte';
|
||||||
|
import type {
|
||||||
|
ModerationCase,
|
||||||
|
ModerationCaseAction,
|
||||||
|
ModerationCaseNotification
|
||||||
|
} from '../../src/lib/moderation.js';
|
||||||
|
|
||||||
|
let component: ReturnType<typeof mount> | undefined;
|
||||||
|
afterEach(async () => {
|
||||||
|
if (component) await unmount(component);
|
||||||
|
component = undefined;
|
||||||
|
document.body.replaceChildren();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
const item: ModerationCase = {
|
||||||
|
id: 'case-1',
|
||||||
|
game: 't5',
|
||||||
|
guid: '123',
|
||||||
|
title: 'Chat review',
|
||||||
|
status: 'resolved',
|
||||||
|
priority: 'normal',
|
||||||
|
assignee: null,
|
||||||
|
assigneeId: null,
|
||||||
|
actor: 'Moderator One',
|
||||||
|
createdAt: '2026-10-16T10:00:00Z',
|
||||||
|
updatedAt: '2026-10-16T10:00:00Z',
|
||||||
|
revision: 6,
|
||||||
|
evidence: []
|
||||||
|
};
|
||||||
|
const mute: ModerationCaseAction = {
|
||||||
|
id: 'mute-1',
|
||||||
|
type: 'restriction',
|
||||||
|
kind: 'mute',
|
||||||
|
reason: 'Earlier chat abuse',
|
||||||
|
actor: 'Moderator One',
|
||||||
|
createdAt: '2026-10-16T10:00:00Z',
|
||||||
|
state: 'active',
|
||||||
|
expiresAt: '2026-10-17T10:00:00Z'
|
||||||
|
};
|
||||||
|
function show(initial = item, permitted = true) {
|
||||||
|
const saved = vi.fn();
|
||||||
|
component = mount(CaseFollowUp, {
|
||||||
|
target: document.body,
|
||||||
|
props: {
|
||||||
|
item: initial,
|
||||||
|
actions: [mute, { ...mute, id: 'permanent', expiresAt: null }],
|
||||||
|
checkedAt: '2026-10-17T12:00:00Z',
|
||||||
|
permitted,
|
||||||
|
onsubmitted: saved
|
||||||
|
}
|
||||||
|
});
|
||||||
|
flushSync();
|
||||||
|
return saved;
|
||||||
|
}
|
||||||
|
const button = (label: string, dialog = false) =>
|
||||||
|
[...document.querySelectorAll(dialog ? 'dialog button' : 'button')].find(
|
||||||
|
(node) => node.textContent?.trim() === label
|
||||||
|
)! as HTMLButtonElement;
|
||||||
|
function field<T extends HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>(
|
||||||
|
label: string
|
||||||
|
): T {
|
||||||
|
const node = [...document.querySelectorAll('dialog label')].find(
|
||||||
|
(node) => node.textContent?.replace('*', '').trim() === label
|
||||||
|
)!;
|
||||||
|
return document.querySelector(`[aria-labelledby="${node.id}"]`)! as T;
|
||||||
|
}
|
||||||
|
function enter(label: string, value: string) {
|
||||||
|
const node = field<HTMLInputElement | HTMLTextAreaElement>(label);
|
||||||
|
node.value = value;
|
||||||
|
node.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
flushSync();
|
||||||
|
}
|
||||||
|
function select(label: string, value: string) {
|
||||||
|
const node = field<HTMLSelectElement>(label);
|
||||||
|
const query = node.querySelector.bind(node);
|
||||||
|
node.querySelector = ((selector: string) =>
|
||||||
|
selector === ':checked'
|
||||||
|
? (node.selectedOptions[0] ?? null)
|
||||||
|
: query(selector)) as typeof node.querySelector;
|
||||||
|
node.value = value;
|
||||||
|
node.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
flushSync();
|
||||||
|
}
|
||||||
|
const response = (value: unknown, status = 200) => new Response(JSON.stringify(value), { status });
|
||||||
|
|
||||||
|
it('schedules a closed case using a browser-local date converted to canonical ISO', async () => {
|
||||||
|
const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
const saved = show();
|
||||||
|
button('Schedule review').click();
|
||||||
|
await tick();
|
||||||
|
enter('Review date (browser local time)', '2026-10-19T14:30');
|
||||||
|
enter('Follow-up reason', 'Check for additional reports');
|
||||||
|
button('Save follow-up').click();
|
||||||
|
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
|
||||||
|
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
|
||||||
|
op: 'schedule-case-review',
|
||||||
|
id: 'case-1',
|
||||||
|
revision: 6,
|
||||||
|
reviewAt: new Date('2026-10-19T14:30').toISOString(),
|
||||||
|
reason: 'Check for additional reports',
|
||||||
|
idempotencyKey: expect.any(String)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the linked finite mute ID to schedule an exact expiration review', async () => {
|
||||||
|
const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
const saved = show();
|
||||||
|
button('Schedule review').click();
|
||||||
|
await tick();
|
||||||
|
expect(
|
||||||
|
[...field<HTMLSelectElement>('Review timing').options].map((option) => option.value)
|
||||||
|
).toEqual(['custom', 'mute-1']);
|
||||||
|
select('Review timing', 'mute-1');
|
||||||
|
enter('Follow-up reason', 'Review after mute expires');
|
||||||
|
button('Save follow-up').click();
|
||||||
|
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
|
||||||
|
const payload = JSON.parse(fetch.mock.calls[0][1].body);
|
||||||
|
expect(payload).toMatchObject({
|
||||||
|
op: 'schedule-case-review',
|
||||||
|
restrictionId: 'mute-1',
|
||||||
|
revision: 6
|
||||||
|
});
|
||||||
|
expect(payload).not.toHaveProperty('reviewAt');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows overdue reviews and clears the date with a recorded result', async () => {
|
||||||
|
const fetch = vi.fn().mockResolvedValue(response({ ok: true, id: 'case-1' }));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
const saved = show({
|
||||||
|
...item,
|
||||||
|
reviewAt: '2026-10-17T10:00:00Z',
|
||||||
|
reviewReason: 'Check once the mute ends',
|
||||||
|
reviewSanctionId: 'mute-1'
|
||||||
|
});
|
||||||
|
expect(document.body.textContent).toContain('Overdue review');
|
||||||
|
expect(document.body.textContent).toContain('Review after linked mute expires.');
|
||||||
|
button('Complete or clear review').click();
|
||||||
|
await tick();
|
||||||
|
enter('Review result or clearing reason', 'Review complete; no further reports');
|
||||||
|
button('Complete or clear review', true).click();
|
||||||
|
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
|
||||||
|
expect(JSON.parse(fetch.mock.calls[0][1].body)).toMatchObject({
|
||||||
|
op: 'schedule-case-review',
|
||||||
|
id: 'case-1',
|
||||||
|
revision: 6,
|
||||||
|
reviewAt: null,
|
||||||
|
reason: 'Review complete; no further reports'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows overdue follow-up details without edit controls for read-only viewers', () => {
|
||||||
|
show({ ...item, reviewAt: '2026-10-17T10:00:00Z', reviewReason: 'Review reports' }, false);
|
||||||
|
expect(document.body.textContent).toContain('Overdue review');
|
||||||
|
expect(document.body.textContent).toContain('Review reports');
|
||||||
|
expect(button('Reschedule review')).toBeUndefined();
|
||||||
|
expect(button('Complete or clear review')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
const notification = (id: number): ModerationCaseNotification => ({
|
||||||
|
id,
|
||||||
|
caseId: 'case-1',
|
||||||
|
caseTitle: item.title,
|
||||||
|
kind: id === 3 ? 'assignment' : 'evidence',
|
||||||
|
createdAt: '2026-10-17T10:00:00Z',
|
||||||
|
readAt: null
|
||||||
|
});
|
||||||
|
it('acknowledges only loaded updates and preserves the read-through ID on an uncertain retry', async () => {
|
||||||
|
const fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(new Error('Connection lost'))
|
||||||
|
.mockResolvedValue(response({ ok: true, id: 'case-1' }));
|
||||||
|
vi.stubGlobal('fetch', fetch);
|
||||||
|
const values = writable([notification(3), notification(9)]);
|
||||||
|
const state = fromStore(values);
|
||||||
|
const saved = vi.fn();
|
||||||
|
component = mount(CaseUpdates, {
|
||||||
|
target: document.body,
|
||||||
|
props: {
|
||||||
|
caseId: 'case-1',
|
||||||
|
get notifications() {
|
||||||
|
return state.current;
|
||||||
|
},
|
||||||
|
unread: 2,
|
||||||
|
permitted: true,
|
||||||
|
onsubmitted: saved
|
||||||
|
}
|
||||||
|
});
|
||||||
|
flushSync();
|
||||||
|
expect(document.body.textContent).toContain('Assignment changed');
|
||||||
|
expect(document.body.textContent).toContain('Evidence or note added');
|
||||||
|
button('Mark updates as seen').click();
|
||||||
|
await vi.waitFor(() => expect(button('Retry same request')).toBeDefined());
|
||||||
|
values.set([notification(12), notification(9), notification(3)]);
|
||||||
|
flushSync();
|
||||||
|
button('Retry same request').click();
|
||||||
|
await vi.waitFor(() => expect(saved).toHaveBeenCalledOnce());
|
||||||
|
expect(fetch.mock.calls[1][1].body).toBe(fetch.mock.calls[0][1].body);
|
||||||
|
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({
|
||||||
|
op: 'read-case-updates',
|
||||||
|
id: 'case-1',
|
||||||
|
throughId: 9,
|
||||||
|
idempotencyKey: expect.any(String)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not offer notification acknowledgement to read-only viewers', () => {
|
||||||
|
component = mount(CaseUpdates, {
|
||||||
|
target: document.body,
|
||||||
|
props: {
|
||||||
|
caseId: 'case-1',
|
||||||
|
notifications: [notification(3)],
|
||||||
|
unread: 1,
|
||||||
|
permitted: false,
|
||||||
|
onsubmitted: vi.fn()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
flushSync();
|
||||||
|
expect(document.body.textContent).toContain('1 unread');
|
||||||
|
expect(button('Mark updates as seen')).toBeUndefined();
|
||||||
|
});
|
||||||
@@ -324,13 +324,21 @@ it('opens a newly created case page after preserving selected evidence', async (
|
|||||||
|
|
||||||
it('links case records to workspaces and preserves case filters while paging', () => {
|
it('links case records to workspaces and preserves case filters while paging', () => {
|
||||||
const data = overview('cases');
|
const data = overview('cases');
|
||||||
data.query = { ...data.query, caseStatus: 'active', priority: 'high', assignedTo: 'me' };
|
data.query = {
|
||||||
|
...data.query,
|
||||||
|
caseStatus: 'active',
|
||||||
|
priority: 'high',
|
||||||
|
assignedTo: 'me',
|
||||||
|
review: 'due'
|
||||||
|
};
|
||||||
data.cases = [
|
data.cases = [
|
||||||
{
|
{
|
||||||
id: 'case-1',
|
id: 'case-1',
|
||||||
game: 't5',
|
game: 't5',
|
||||||
guid,
|
guid,
|
||||||
title: 'Chat investigation',
|
title: 'Chat investigation',
|
||||||
|
reviewAt: '2026-10-14T12:00:00Z',
|
||||||
|
unreadUpdates: 2,
|
||||||
assessment: {
|
assessment: {
|
||||||
revision: 3,
|
revision: 3,
|
||||||
summary: 'The threat remains ambiguous.',
|
summary: 'The threat remains ambiguous.',
|
||||||
@@ -357,7 +365,7 @@ it('links case records to workspaces and preserves case filters while paging', (
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
data.total = 40;
|
data.total = 40;
|
||||||
show(data, 'moderator', '&caseStatus=active&priority=high&assignedTo=me');
|
show(data, 'moderator', '&caseStatus=active&priority=high&assignedTo=me&review=due');
|
||||||
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'
|
||||||
);
|
);
|
||||||
@@ -374,9 +382,46 @@ it('links case records to workspaces and preserves case filters while paging', (
|
|||||||
expect(destination.searchParams.get('caseStatus')).toBe('active');
|
expect(destination.searchParams.get('caseStatus')).toBe('active');
|
||||||
expect(destination.searchParams.get('priority')).toBe('high');
|
expect(destination.searchParams.get('priority')).toBe('high');
|
||||||
expect(destination.searchParams.get('assignedTo')).toBe('me');
|
expect(destination.searchParams.get('assignedTo')).toBe('me');
|
||||||
|
expect(destination.searchParams.get('review')).toBe('due');
|
||||||
|
expect(document.querySelector('#moderation-results')?.textContent).toContain('Overdue review');
|
||||||
|
expect(document.querySelector('#moderation-results')?.textContent).toContain(
|
||||||
|
'2 unread updates'
|
||||||
|
);
|
||||||
expect(destination.searchParams.get('offset')).toBe('25');
|
expect(destination.searchParams.get('offset')).toBe('25');
|
||||||
button('Reset').click();
|
button('Reset').click();
|
||||||
const reset = new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost');
|
const reset = new URL(navigation.goto.mock.calls.at(-1)![0], 'http://localhost');
|
||||||
for (const filter of ['caseStatus', 'priority', 'assignedTo', 'offset'])
|
for (const filter of ['caseStatus', 'priority', 'assignedTo', 'review', 'offset'])
|
||||||
expect(reset.searchParams.has(filter)).toBe(false);
|
expect(reset.searchParams.has(filter)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('shows personal evidence and assignment notifications with links into the actionable queue', () => {
|
||||||
|
const data = overview('cases');
|
||||||
|
data.unreadCount = 2;
|
||||||
|
data.notifications = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
caseId: 'follow-up',
|
||||||
|
caseTitle: 'Review context',
|
||||||
|
kind: 'evidence',
|
||||||
|
createdAt: data.checkedAt,
|
||||||
|
readAt: null
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
caseId: 'appeal',
|
||||||
|
caseTitle: 'Review appeal',
|
||||||
|
kind: 'assignment',
|
||||||
|
createdAt: data.checkedAt,
|
||||||
|
readAt: null
|
||||||
|
}
|
||||||
|
];
|
||||||
|
show(data);
|
||||||
|
const notifications = document.querySelector('[aria-label="Your case notifications"]')!;
|
||||||
|
expect(notifications.textContent).toContain('Evidence or note added');
|
||||||
|
expect(notifications.textContent).toContain('Assignment changed');
|
||||||
|
expect(notifications.querySelector('a[href="/moderation/cases/follow-up"]')).not.toBeNull();
|
||||||
|
expect(
|
||||||
|
document.querySelector('a[href="/moderation?view=cases&review=updates"]')
|
||||||
|
).not.toBeNull();
|
||||||
|
expect(document.querySelector('a[href="/moderation?view=cases&review=due"]')).not.toBeNull();
|
||||||
|
});
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ it('adds assessment storage to an existing database without altering case record
|
|||||||
const before = legacy.prepare('SELECT * FROM moderation_cases').all();
|
const before = legacy.prepare('SELECT * FROM moderation_cases').all();
|
||||||
runMigrations(legacy, migrations);
|
runMigrations(legacy, migrations);
|
||||||
runMigrations(legacy, migrations);
|
runMigrations(legacy, migrations);
|
||||||
expect(legacy.prepare('SELECT * FROM moderation_cases').all()).toEqual(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(?,?,?,?,?,?)'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -287,7 +287,9 @@ it('upgrades relationship storage with foreign keys, symmetric identity and acti
|
|||||||
const before = legacy.prepare('SELECT * FROM moderation_cases ORDER BY id').all();
|
const before = legacy.prepare('SELECT * FROM moderation_cases ORDER BY id').all();
|
||||||
runMigrations(legacy, migrations);
|
runMigrations(legacy, migrations);
|
||||||
runMigrations(legacy, migrations);
|
runMigrations(legacy, migrations);
|
||||||
expect(legacy.prepare('SELECT * FROM moderation_cases ORDER BY id').all()).toEqual(before);
|
expect(legacy.prepare('SELECT * FROM moderation_cases ORDER BY id').all()).toMatchObject(
|
||||||
|
before
|
||||||
|
);
|
||||||
const insert = legacy.prepare(
|
const insert = legacy.prepare(
|
||||||
'INSERT INTO moderation_case_relationships VALUES(?,?,?,?,?,?,?,NULL)'
|
'INSERT INTO moderation_case_relationships VALUES(?,?,?,?,?,?,?,NULL)'
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,395 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
let handle: Database.Database;
|
||||||
|
const clock = '2026-10-17T12:00:00.000Z';
|
||||||
|
const future = '2026-10-17T13:00:00.000Z';
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
vi.useFakeTimers({ toFake: ['Date'] });
|
||||||
|
vi.setSystemTime(new Date(clock));
|
||||||
|
fixture = await newFixture([]);
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
handle = db();
|
||||||
|
for (const [id, role] of [
|
||||||
|
['creator', 'moderator'],
|
||||||
|
['owner', 'moderator'],
|
||||||
|
['next', 'moderator'],
|
||||||
|
['admin', 'admin'],
|
||||||
|
['viewer', 'viewer'],
|
||||||
|
['reader', 'super_viewer']
|
||||||
|
])
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,'unused',?,0,0)`
|
||||||
|
)
|
||||||
|
.run(id, id, id, role);
|
||||||
|
});
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
await fixture.cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function mutate(input: Record<string, unknown>, actor = 'creator') {
|
||||||
|
const { mutateModeration } = await import('../../src/lib/server/moderation/actions.js');
|
||||||
|
return mutateModeration(actor, { idempotencyKey: randomUUID(), ...input });
|
||||||
|
}
|
||||||
|
async function detail(id: string, actor = 'creator') {
|
||||||
|
const { moderationCaseDetail } = await import('../../src/lib/server/moderation/cases.js');
|
||||||
|
return moderationCaseDetail(actor, id);
|
||||||
|
}
|
||||||
|
async function overview(actor = 'creator', params = '') {
|
||||||
|
const { moderationOverview, moderationQuery } =
|
||||||
|
await import('../../src/lib/server/moderation/overview.js');
|
||||||
|
return moderationOverview(actor, moderationQuery(new URLSearchParams(`view=cases&${params}`)));
|
||||||
|
}
|
||||||
|
const create = (overrides: Record<string, unknown> = {}) =>
|
||||||
|
mutate({
|
||||||
|
op: 'create-case',
|
||||||
|
game: 't5',
|
||||||
|
guid: '123',
|
||||||
|
title: 'Review incident',
|
||||||
|
...overrides
|
||||||
|
});
|
||||||
|
const schedule = (id: string, revision: number, reviewAt: unknown = future) => ({
|
||||||
|
op: 'schedule-case-review',
|
||||||
|
id,
|
||||||
|
revision,
|
||||||
|
reviewAt,
|
||||||
|
reason: 'Review the case after the restriction ends.'
|
||||||
|
});
|
||||||
|
const restriction = (overrides: Record<string, unknown> = {}) =>
|
||||||
|
mutate(
|
||||||
|
{
|
||||||
|
op: 'create-sanction',
|
||||||
|
game: 't5',
|
||||||
|
guid: '123',
|
||||||
|
kind: 'mute',
|
||||||
|
scope: 'game',
|
||||||
|
durationMinutes: 60,
|
||||||
|
reason: 'Earlier moderation action',
|
||||||
|
...overrides
|
||||||
|
},
|
||||||
|
'admin'
|
||||||
|
);
|
||||||
|
|
||||||
|
it('schedules canonical dates, preserves them through closure and reopening, and clears them with an audit', async () => {
|
||||||
|
const { id } = await create();
|
||||||
|
for (const value of [
|
||||||
|
'',
|
||||||
|
'2026-10-17',
|
||||||
|
'2026-02-30T12:00:00.000Z',
|
||||||
|
'2026-10-17T12:00:00Z',
|
||||||
|
'2026-10-17T08:00:00.000-04:00',
|
||||||
|
12,
|
||||||
|
'+010000-01-01T00:00:00.000Z'
|
||||||
|
])
|
||||||
|
await expect(mutate(schedule(id, 1, value))).rejects.toMatchObject({ status: 400 });
|
||||||
|
await expect(mutate({ ...schedule(id, 1), restrictionId: 'another' })).rejects.toMatchObject({
|
||||||
|
status: 400
|
||||||
|
});
|
||||||
|
for (const actor of ['viewer', 'reader'])
|
||||||
|
await expect(mutate(schedule(id, 1), actor)).rejects.toMatchObject({ status: 403 });
|
||||||
|
await mutate(schedule(id, 1));
|
||||||
|
await expect(mutate(schedule(id, 1))).rejects.toMatchObject({ status: 409 });
|
||||||
|
const first = await detail(id);
|
||||||
|
expect(first.case).toMatchObject({
|
||||||
|
revision: 2,
|
||||||
|
reviewAt: future,
|
||||||
|
reviewSanctionId: null,
|
||||||
|
reviewReason: schedule(id, 1).reason
|
||||||
|
});
|
||||||
|
await mutate(schedule(id, 2));
|
||||||
|
expect(await detail(id)).toEqual(first);
|
||||||
|
await mutate({
|
||||||
|
op: 'resolve-case',
|
||||||
|
id,
|
||||||
|
revision: 2,
|
||||||
|
status: 'no_action',
|
||||||
|
reason: 'Review complete for now'
|
||||||
|
});
|
||||||
|
expect((await detail(id)).case.reviewAt).toBe(future);
|
||||||
|
await mutate({ op: 'reopen-case', id, revision: 3, reason: 'New context arrived' });
|
||||||
|
expect((await detail(id)).case.reviewAt).toBe(future);
|
||||||
|
await mutate(schedule(id, 4, null));
|
||||||
|
const cleared = await detail(id);
|
||||||
|
expect(cleared.case).toMatchObject({ revision: 5, reviewAt: null, reviewSanctionId: null });
|
||||||
|
expect(JSON.parse(cleared.events[0].detail)).toEqual({
|
||||||
|
reviewAt: null,
|
||||||
|
reason: schedule(id, 1).reason,
|
||||||
|
restrictionId: null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives dates only from linked finite mutes and supports already expired restrictions without reissuing them', async () => {
|
||||||
|
const { id } = await create();
|
||||||
|
const mute = await restriction({ caseId: id, caseRevision: 1 });
|
||||||
|
const unlinked = await restriction();
|
||||||
|
const permanent = await restriction({ caseId: id, caseRevision: 2, durationMinutes: null });
|
||||||
|
const ban = await restriction({ caseId: id, caseRevision: 3, kind: 'ban' });
|
||||||
|
const other = await restriction({ guid: '456' });
|
||||||
|
const input = (restrictionId: string) => ({
|
||||||
|
op: 'schedule-case-review',
|
||||||
|
id,
|
||||||
|
revision: 4,
|
||||||
|
restrictionId,
|
||||||
|
reason: 'Check after mute expires'
|
||||||
|
});
|
||||||
|
await expect(mutate(input(unlinked.id))).rejects.toThrow('Link the mute');
|
||||||
|
for (const target of [permanent.id, ban.id])
|
||||||
|
await expect(mutate(input(target))).rejects.toThrow('recorded expiry');
|
||||||
|
await expect(mutate(input(other.id))).rejects.toMatchObject({ status: 404 });
|
||||||
|
const before = handle.prepare('SELECT * FROM moderation_sanctions ORDER BY id').all();
|
||||||
|
const policy = handle.prepare('SELECT * FROM moderation_policy').all();
|
||||||
|
vi.setSystemTime(new Date('2026-10-17T14:00:00.000Z'));
|
||||||
|
await mutate(input(mute.id));
|
||||||
|
expect((await detail(id)).case).toMatchObject({
|
||||||
|
reviewAt: future,
|
||||||
|
reviewSanctionId: mute.id,
|
||||||
|
revision: 5
|
||||||
|
});
|
||||||
|
expect(handle.prepare('SELECT * FROM moderation_sanctions ORDER BY id').all()).toEqual(before);
|
||||||
|
expect(handle.prepare('SELECT * FROM moderation_policy').all()).toEqual(policy);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prioritizes overdue closed cases and recalculates due and scheduled queues using the current clock', async () => {
|
||||||
|
const closed = await create({ title: 'Closed follow-up' });
|
||||||
|
const active = await create({ title: 'Urgent active case', priority: 'urgent' });
|
||||||
|
const upcoming = await create({ title: 'Upcoming review' });
|
||||||
|
await mutate({
|
||||||
|
op: 'resolve-case',
|
||||||
|
id: closed.id,
|
||||||
|
revision: 1,
|
||||||
|
status: 'no_action',
|
||||||
|
reason: 'Initial review complete'
|
||||||
|
});
|
||||||
|
await mutate(schedule(closed.id, 2, clock));
|
||||||
|
await mutate(schedule(upcoming.id, 1, future));
|
||||||
|
expect((await overview()).cases.map((item) => item.id)).toEqual([
|
||||||
|
closed.id,
|
||||||
|
active.id,
|
||||||
|
upcoming.id
|
||||||
|
]);
|
||||||
|
expect((await overview('creator', 'review=due')).cases.map((item) => item.id)).toEqual([
|
||||||
|
closed.id
|
||||||
|
]);
|
||||||
|
expect((await overview('creator', 'review=scheduled')).total).toBe(2);
|
||||||
|
expect((await overview('creator', 'review=invalid')).query.review).toBe('');
|
||||||
|
vi.setSystemTime(new Date(future));
|
||||||
|
expect((await overview('creator', 'review=due')).cases.map((item) => item.id)).toEqual([
|
||||||
|
closed.id,
|
||||||
|
upcoming.id
|
||||||
|
]);
|
||||||
|
expect((await overview()).cases[0].status).toBe('no_action');
|
||||||
|
await mutate(schedule(closed.id, 3, null));
|
||||||
|
expect((await overview('creator', 'review=due')).cases.map((item) => item.id)).toEqual([
|
||||||
|
upcoming.id
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notifies distinct active assignees and the creator of assignment changes, excluding the acting staff member', async () => {
|
||||||
|
const request = {
|
||||||
|
op: 'create-case',
|
||||||
|
game: 't5',
|
||||||
|
guid: '123',
|
||||||
|
title: 'Assigned review',
|
||||||
|
assigneeId: 'owner',
|
||||||
|
idempotencyKey: randomUUID()
|
||||||
|
};
|
||||||
|
const { id } = await mutate(request);
|
||||||
|
await mutate(request);
|
||||||
|
expect((await detail(id, 'owner')).notifications).toMatchObject([{ kind: 'assignment' }]);
|
||||||
|
expect((await detail(id, 'creator')).notifications).toEqual([]);
|
||||||
|
await mutate({ op: 'update-case', id, revision: 1, assigneeId: 'next' }, 'admin');
|
||||||
|
const recipients = handle
|
||||||
|
.prepare(
|
||||||
|
'SELECT user_id,COUNT(*) AS count FROM moderation_case_notifications GROUP BY user_id ORDER BY user_id'
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
expect(recipients).toEqual([
|
||||||
|
{ user_id: 'creator', count: 1 },
|
||||||
|
{ user_id: 'next', count: 1 },
|
||||||
|
{ user_id: 'owner', count: 2 }
|
||||||
|
]);
|
||||||
|
expect((await overview('admin')).unreadCount).toBe(0);
|
||||||
|
handle.prepare("UPDATE auth_users SET disabled=1 WHERE id='creator'").run();
|
||||||
|
handle.prepare("UPDATE auth_users SET role='viewer' WHERE id='next'").run();
|
||||||
|
await mutate({ op: 'update-case', id, revision: 2, assigneeId: 'owner' }, 'admin');
|
||||||
|
expect(
|
||||||
|
handle.prepare('SELECT COUNT(*) AS count FROM moderation_case_notifications').get()
|
||||||
|
).toEqual({ count: 5 });
|
||||||
|
await expect(
|
||||||
|
mutate({ op: 'update-case', id, revision: 3, assigneeId: 'owner' }, 'admin')
|
||||||
|
).rejects.toThrow('Change the case');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('notifies once per fresh evidence batch, with no notifications for duplicate attachments or retries', async () => {
|
||||||
|
const { id } = await create({ assigneeId: 'owner' });
|
||||||
|
const matchId = Number(
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO leaderboard_matches(server,game,map,map_key,map_name,started_at,last_received_at,last_event,last_fingerprint)
|
||||||
|
VALUES('removed-server','t5','map','map','Map',?,?,'game_start','start')`
|
||||||
|
)
|
||||||
|
.run(clock, clock).lastInsertRowid
|
||||||
|
);
|
||||||
|
for (let occurrence = 1; occurrence <= 19; occurrence++)
|
||||||
|
handle.prepare('INSERT INTO match_chat VALUES(?,?,?,?)').run(
|
||||||
|
matchId,
|
||||||
|
'repeat',
|
||||||
|
occurrence,
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'message',
|
||||||
|
message: {
|
||||||
|
time: '1:00',
|
||||||
|
guid: '123',
|
||||||
|
player: 'Player',
|
||||||
|
message: 'Repeated message'
|
||||||
|
},
|
||||||
|
context: { realTime: null, round: null, matchElapsedMs: null }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const request = {
|
||||||
|
op: 'attach-evidence',
|
||||||
|
id,
|
||||||
|
revision: 1,
|
||||||
|
evidence: { matchId, message: 'Repeated message' },
|
||||||
|
idempotencyKey: randomUUID()
|
||||||
|
};
|
||||||
|
await mutate(request, 'admin');
|
||||||
|
await mutate(request, 'admin');
|
||||||
|
await mutate({ ...request, revision: 2, idempotencyKey: randomUUID() }, 'admin');
|
||||||
|
expect((await detail(id)).case.evidence).toHaveLength(19);
|
||||||
|
expect(
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
"SELECT user_id,kind FROM moderation_case_notifications WHERE kind='evidence' ORDER BY user_id"
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
).toEqual([
|
||||||
|
{ user_id: 'creator', kind: 'evidence' },
|
||||||
|
{ user_id: 'owner', kind: 'evidence' }
|
||||||
|
]);
|
||||||
|
await mutate({ op: 'case-note', id, revision: 2, text: 'More context' }, 'owner');
|
||||||
|
expect((await detail(id, 'owner')).case.unreadUpdates).toBe(2);
|
||||||
|
expect((await detail(id, 'creator')).case.unreadUpdates).toBe(2);
|
||||||
|
expect((await overview('owner', 'review=updates')).cases.map((item) => item.id)).toEqual([id]);
|
||||||
|
expect((await overview('admin', 'review=updates')).total).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('marks only the reader’s seen case notifications and preserves newer arrivals and other cases', async () => {
|
||||||
|
const { id } = await create({ assigneeId: 'owner' });
|
||||||
|
const other = await create({ assigneeId: 'owner' });
|
||||||
|
await mutate({ op: 'case-note', id, text: 'First new evidence' }, 'admin');
|
||||||
|
const seen = await detail(id, 'owner');
|
||||||
|
const throughId = Math.max(...seen.notifications!.map((entry) => entry.id));
|
||||||
|
await mutate({ op: 'case-note', id, text: 'Later evidence' }, 'admin');
|
||||||
|
const revision = (await detail(id)).case.revision;
|
||||||
|
const read = { op: 'read-case-updates', id, throughId, idempotencyKey: randomUUID() };
|
||||||
|
await mutate(read, 'owner');
|
||||||
|
await mutate(read, 'owner');
|
||||||
|
const data = await detail(id, 'owner');
|
||||||
|
expect(data.case).toMatchObject({ revision, unreadUpdates: 1 });
|
||||||
|
expect(data.notifications!.filter((entry) => !entry.readAt)).toHaveLength(1);
|
||||||
|
expect(data.notifications![0].id).toBeGreaterThan(throughId);
|
||||||
|
expect((await detail(id, 'creator')).case.unreadUpdates).toBe(2);
|
||||||
|
expect((await detail(other.id, 'owner')).case.unreadUpdates).toBe(1);
|
||||||
|
for (const invalid of [-1, 1.5, Number.MAX_SAFE_INTEGER + 1, '2'])
|
||||||
|
await expect(
|
||||||
|
mutate({ ...read, throughId: invalid, idempotencyKey: randomUUID() }, 'owner')
|
||||||
|
).rejects.toMatchObject({ status: 400 });
|
||||||
|
await expect(mutate({ ...read, idempotencyKey: randomUUID() }, 'reader')).rejects.toMatchObject(
|
||||||
|
{ status: 403 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns bounded notification lists alongside complete personal unread counts', async () => {
|
||||||
|
const { id } = await create();
|
||||||
|
for (let index = 0; index < 30; index++)
|
||||||
|
handle
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO moderation_case_notifications(user_id,case_id,kind,created_at) VALUES('owner',?,'evidence',?)"
|
||||||
|
)
|
||||||
|
.run(id, clock);
|
||||||
|
const data = await detail(id, 'owner');
|
||||||
|
const queue = await overview('owner');
|
||||||
|
expect(data.case.unreadUpdates).toBe(30);
|
||||||
|
expect(data.notifications).toHaveLength(25);
|
||||||
|
expect(queue.unreadCount).toBe(30);
|
||||||
|
expect(queue.notifications).toHaveLength(25);
|
||||||
|
expect(queue.notifications![0].id).toBeGreaterThan(queue.notifications![24].id);
|
||||||
|
expect((await detail(id)).notifications).toEqual([]);
|
||||||
|
expect((await overview('creator')).notifications).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolls back schedules and evidence if their audit or notification write fails', async () => {
|
||||||
|
const { id } = await create({ assigneeId: 'owner' });
|
||||||
|
const before = await detail(id);
|
||||||
|
handle.exec(`CREATE TRIGGER fail_review_audit BEFORE INSERT ON moderation_events
|
||||||
|
WHEN NEW.action='case.review_scheduled' BEGIN SELECT RAISE(ABORT,'audit unavailable'); END`);
|
||||||
|
await expect(mutate(schedule(id, 1))).rejects.toThrow('audit unavailable');
|
||||||
|
expect(await detail(id)).toEqual(before);
|
||||||
|
handle.exec('DROP TRIGGER fail_review_audit');
|
||||||
|
handle.exec(`CREATE TRIGGER fail_notification BEFORE INSERT ON moderation_case_notifications
|
||||||
|
BEGIN SELECT RAISE(ABORT,'notification unavailable'); END`);
|
||||||
|
const request = { op: 'case-note', id, text: 'New evidence', idempotencyKey: randomUUID() };
|
||||||
|
await expect(mutate(request, 'admin')).rejects.toThrow('notification unavailable');
|
||||||
|
expect(await detail(id)).toEqual(before);
|
||||||
|
expect(
|
||||||
|
handle
|
||||||
|
.prepare('SELECT * FROM moderation_requests WHERE request_key=?')
|
||||||
|
.all(request.idempotencyKey)
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('upgrades existing cases with unscheduled defaults and durable personal notification constraints', async () => {
|
||||||
|
const { migrations } = await import('../../src/lib/server/db/migrations.js');
|
||||||
|
const { runMigrations } = await import('../../src/lib/server/db/migration-runner.js');
|
||||||
|
const legacy = new Database(':memory:');
|
||||||
|
legacy.pragma('foreign_keys = ON');
|
||||||
|
try {
|
||||||
|
runMigrations(legacy, migrations.slice(0, 33));
|
||||||
|
legacy
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO moderation_cases(id,game,guid,title,status,actor_id,actor,created_at,updated_at)
|
||||||
|
VALUES('case','t5','123','Existing case','open','former','Former staff',?,?)`
|
||||||
|
)
|
||||||
|
.run(clock, clock);
|
||||||
|
legacy
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
|
||||||
|
VALUES('owner','owner','Owner','unused','moderator',0,0)`
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
runMigrations(legacy, migrations);
|
||||||
|
runMigrations(legacy, migrations);
|
||||||
|
expect(
|
||||||
|
legacy
|
||||||
|
.prepare(
|
||||||
|
'SELECT id,revision,review_at,review_reason,review_sanction_id FROM moderation_cases'
|
||||||
|
)
|
||||||
|
.get()
|
||||||
|
).toEqual({
|
||||||
|
id: 'case',
|
||||||
|
revision: 1,
|
||||||
|
review_at: null,
|
||||||
|
review_reason: '',
|
||||||
|
review_sanction_id: null
|
||||||
|
});
|
||||||
|
const insert = legacy.prepare(
|
||||||
|
'INSERT INTO moderation_case_notifications(user_id,case_id,kind,created_at) VALUES(?,?,?,?)'
|
||||||
|
);
|
||||||
|
insert.run('owner', 'case', 'evidence', clock);
|
||||||
|
expect(() => insert.run('missing', 'case', 'evidence', clock)).toThrow('FOREIGN KEY');
|
||||||
|
expect(() => insert.run('owner', 'missing', 'evidence', clock)).toThrow('FOREIGN KEY');
|
||||||
|
expect(() => insert.run('owner', 'case', 'unknown', clock)).toThrow('CHECK');
|
||||||
|
expect(legacy.pragma('foreign_key_check')).toEqual([]);
|
||||||
|
} finally {
|
||||||
|
legacy.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user