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 }; }