diff --git a/docs/guides/moderation.md b/docs/guides/moderation.md index 2a603db..9fb4be9 100644 --- a/docs/guides/moderation.md +++ b/docs/guides/moderation.md @@ -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 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 and GUID and records the resulting restriction alongside the evidence. The same role, scope, and duration limits apply as elsewhere in Moderation. Applying a diff --git a/src/lib/moderation.ts b/src/lib/moderation.ts index 12cf8e6..b493fe6 100644 --- a/src/lib/moderation.ts +++ b/src/lib/moderation.ts @@ -163,6 +163,10 @@ export interface ModerationCase { updatedAt: string; revision: number; assessment?: ModerationCaseAssessment | null; + reviewAt?: string | null; + reviewReason?: string; + reviewSanctionId?: string | null; + unreadUpdates?: number; evidence: { id: string; text: string; @@ -182,6 +186,7 @@ export interface ModerationCaseDetail { assessmentHistory?: ModerationCaseAssessment[]; relatedCases?: ModerationCaseRelationship[]; candidateCases?: ModerationCaseCandidate[]; + notifications?: ModerationCaseNotification[]; events: ModerationEvent[]; coverage: ModerationCoverage[]; assignees: { id: string; username: string }[]; @@ -202,6 +207,14 @@ export interface ModerationCaseRelationship { export interface ModerationCaseCandidate extends ModerationCaseReference { sharedMatch: boolean; } +export interface ModerationCaseNotification { + id: number; + caseId: string; + caseTitle: string; + kind: 'evidence' | 'assignment'; + createdAt: string; + readAt: string | null; +} export interface ModerationCaseAction { id: string; type: 'restriction' | 'action'; @@ -240,6 +253,7 @@ export interface ModerationQuery { caseStatus?: '' | 'active' | 'closed' | ModerationCaseStatus; priority?: '' | ModerationCasePriority; assignedTo?: string; + review?: '' | 'due' | 'scheduled' | 'updates'; } export interface ModerationOverview { query: ModerationQuery; @@ -252,6 +266,8 @@ export interface ModerationOverview { total: number; limit: number; checkedAt: string; + notifications?: ModerationCaseNotification[]; + unreadCount?: number; } /** RCON T6 IDs are hexadecimal, while the game scripts send decimal strings. */ diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index 7faea14..8026461 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -33,7 +33,8 @@ import { migrateModerationCases, migrateModerationCaseActions, migrateModerationCaseAssessments, - migrateModerationCaseRelationships + migrateModerationCaseRelationships, + migrateModerationCaseFollowups } from '../moderation/schema.js'; import type { Migration } from './migration-runner.js'; @@ -191,6 +192,11 @@ export const migrations: readonly Migration[] = [ version: 33, name: 'moderation case relationships and duplicate review', run: migrateModerationCaseRelationships + }, + { + version: 34, + name: 'moderation follow-up dates and personal updates', + run: migrateModerationCaseFollowups } ]; diff --git a/src/lib/server/moderation/actions.ts b/src/lib/server/moderation/actions.ts index 3c05618..ffe15de 100644 --- a/src/lib/server/moderation/actions.ts +++ b/src/lib/server/moderation/actions.ts @@ -4,6 +4,7 @@ import { archivedEvidenceSnapshots } from './evidence.js'; import { caseActionReference, recordedCaseAction, isCaseActionLinked } from './case-actions.js'; import { assessmentDraft } from './assessments.js'; import { linkCases, unlinkCases } from './relationships.js'; +import { notifyCase, caseReviewSchedule, readCaseUpdates } from './followups.js'; import { AuthError } from '../auth/index.js'; import { moderatorMaximumRestrictionMs, type AuthUser } from '../../auth.js'; import { @@ -53,6 +54,9 @@ type CaseRow = { title: string; priority: ModerationCasePriority; assignee_id: string | null; + review_at: string | null; + review_reason: string; + review_sanction_id: string | null; }; function serverTarget(name: unknown, game?: ModerationGame) { @@ -390,7 +394,8 @@ function createCase(actor: AuthUser, input: Record, now: string assignee.id ); 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); if (priority !== 'normal' || assignee.id) moderationEvent( @@ -404,6 +409,8 @@ function createCase(actor: AuthUser, input: Record, now: string }), now ); + if (note || attached) notifyCase(actor.id, id, 'evidence', now); + if (assignee.id) notifyCase(actor.id, id, 'assignment', now); return { id }; } function archivedEvidence( @@ -465,6 +472,8 @@ function updateCase(actor: AuthUser, input: Record, now: string JSON.stringify({ ...changes, ...(reason ? { reason } : {}) }), now ); + if (assigneeId !== row.assignee_id) + notifyCase(actor.id, row.id, 'assignment', now, row.assignee_id); return { id: row.id }; } export function mutateModeration(actorId: string, input: Record) { @@ -474,6 +483,37 @@ export function mutateModeration(actorId: string, input: Record if (input.op === 'action') return liveAction(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 === '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') { const row = caseRow(input.id); checkCaseRevision(row, input.revision); @@ -542,6 +582,7 @@ export function mutateModeration(actorId: string, input: Record evidence(actor, row.id, text, 'Staff note', null, now); touchCase(row.id, now); moderationEvent(actor, 'case.note', row, row.id, text, now); + notifyCase(actor.id, row.id, 'evidence', now); return { id: row.id }; } if (input.op === 'resolve-case') { @@ -626,6 +667,7 @@ export function mutateModeration(actorId: string, input: Record JSON.stringify({ count, matchId: (input.evidence as { matchId: number }).matchId }), now ); + notifyCase(actor.id, row.id, 'evidence', now); return { id: row.id }; } if (input.op === 'note') { diff --git a/src/lib/server/moderation/cases.ts b/src/lib/server/moderation/cases.ts index 6bffc85..29811af 100644 --- a/src/lib/server/moderation/cases.ts +++ b/src/lib/server/moderation/cases.ts @@ -5,6 +5,7 @@ import { moderationOverview } from './overview.js'; import { caseActionLists } from './case-actions.js'; import { caseAssessmentHistory } from './assessments.js'; import { relatedCases, candidateCases } from './relationships.js'; +import { caseNotifications, unreadCaseUpdates } from './followups.js'; import type { ModerationCase, ModerationCaseDetail, @@ -22,7 +23,8 @@ export function moderationCaseDetail(actorId: string, id: string): ModerationCas .prepare( `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.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=?` ) .get(caseId) as Omit | undefined; @@ -72,7 +74,7 @@ export function moderationCaseDetail(actorId: string, id: string): ModerationCas .all() as ModerationCaseDetail['assignees']; const assessmentHistory = caseAssessmentHistory(caseId); return { - case: { ...row, evidence }, + case: { ...row, evidence, unreadUpdates: unreadCaseUpdates(actorId, caseId) }, player: overview.players[0] ?? { game: row.game, guid: row.guid, @@ -89,6 +91,7 @@ export function moderationCaseDetail(actorId: string, id: string): ModerationCas assessmentHistory, relatedCases: relatedCases(caseId), candidateCases: candidateCases(row), + notifications: caseNotifications(actorId, caseId), events, coverage: overview.coverage, assignees, diff --git a/src/lib/server/moderation/followups.ts b/src/lib/server/moderation/followups.ts new file mode 100644 index 0000000..cf8a968 --- /dev/null +++ b/src/lib/server/moderation/followups.ts @@ -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, + 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 + }; +} diff --git a/src/lib/server/moderation/overview.ts b/src/lib/server/moderation/overview.ts index dda025e..9e5f0ae 100644 --- a/src/lib/server/moderation/overview.ts +++ b/src/lib/server/moderation/overview.ts @@ -1,5 +1,6 @@ import { db } from '../db/index.js'; import { currentCaseAssessment } from './assessments.js'; +import { caseNotifications, unreadCaseUpdates } from './followups.js'; import { moderationActor, moderationNow, policyRevision } from './core.js'; import { runtimeFreshMs } from './actions.js'; import { refreshActionStates } from './runtime.js'; @@ -23,6 +24,7 @@ export function moderationQuery(params: URLSearchParams): ModerationQuery { const offset = Number(params.get('offset') ?? 0); const caseStatus = params.get('caseStatus') ?? ''; const priority = params.get('priority') ?? ''; + const review = params.get('review') ?? ''; return { view: view === 'restrictions' || view === 'cases' || view === 'history' ? view : 'players', 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 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 { @@ -90,7 +95,9 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod coverage, total: 0, limit, - checkedAt: now + checkedAt: now, + notifications: caseNotifications(actorId), + unreadCount: unreadCaseUpdates(actorId) }; const where: string[] = [], values: (string | number)[] = []; @@ -122,6 +129,16 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod where.push('p.assignee_id=?'); 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) { const extra = @@ -138,7 +155,12 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod values.push(query.q, query.q, query.q); } const clause = where.length ? ' WHERE ' + where.join(' AND ') : ''; - function rows(table: string, select: string, order: string): T[] { + function rows( + table: string, + select: string, + order: string, + orderValues: string[] = [] + ): T[] { result.total = ( handle .prepare(`SELECT COUNT(*) AS count FROM ${table} p${clause}`) @@ -152,7 +174,7 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod .prepare( `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') { result.players = rows>( @@ -197,11 +219,13 @@ export function moderationOverview(actorId: string, query: ModerationQuery): Mod } else if (query.view === 'cases') { result.cases = rows>( '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', - "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" + '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", + [now, now] ).map((c) => ({ ...c, assessment: currentCaseAssessment(c.id), + unreadUpdates: unreadCaseUpdates(actorId, c.id), evidence: handle .prepare( 'SELECT id,text,source,created_at AS createdAt,actor FROM moderation_evidence WHERE case_id=? ORDER BY created_at,id' diff --git a/src/lib/server/moderation/schema.ts b/src/lib/server/moderation/schema.ts index 73d925e..dc45e94 100644 --- a/src/lib/server/moderation/schema.ts +++ b/src/lib/server/moderation/schema.ts @@ -202,3 +202,24 @@ export function migrateModerationCaseRelationships(handle: Database.Database): v 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); + `); +} diff --git a/src/lib/ui/components/moderation/CaseFollowUp.svelte b/src/lib/ui/components/moderation/CaseFollowUp.svelte new file mode 100644 index 0000000..2efa1d3 --- /dev/null +++ b/src/lib/ui/components/moderation/CaseFollowUp.svelte @@ -0,0 +1,97 @@ + + +
+
+

Follow-up review

+ {#if overdue}Overdue review{/if} +
+ {#if item.reviewAt}

+ +

+ {#if item.reviewSanctionId}

Review after linked mute expires.

{/if} + {:else}

No review scheduled.

{/if} + {#if item.reviewReason}

{item.reviewReason}

{/if} + {#if permitted}
+ + {#if item.reviewAt}{/if} +
{/if} +
+ +{#if permitted && open}{/if} + + diff --git a/src/lib/ui/components/moderation/CaseReviewDialog.svelte b/src/lib/ui/components/moderation/CaseReviewDialog.svelte new file mode 100644 index 0000000..d8c6e4e --- /dev/null +++ b/src/lib/ui/components/moderation/CaseReviewDialog.svelte @@ -0,0 +1,180 @@ + + + +
+
+ {#if !clearing} + + {:else}

+ The review will be due at the linked mute's recorded expiration time. +

{/if} + {/if} +