Add case follow-up queues and personal update notifications
This commit is contained in:
@@ -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();
|
||||
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(
|
||||
'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();
|
||||
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(
|
||||
'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