feat(admin): add internal notes to reports (#3510)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-17 05:50:33 +02:00
1 parent 2d8d130be1
commit 49a1ffabbe
30 files changed
+1138 -87

No files matched your search

+8
View File
@@ -245,7 +245,9 @@ import {
type DSAReportTicketRow,
GUILD_REPORT_SUBMISSION_BY_REPORTER_COLUMNS,
type GuildReportSubmissionByReporterRow,
IAR_REPORT_NOTE_COLUMNS,
IAR_SUBMISSION_COLUMNS,
type IARReportNoteRow,
type IARSubmissionRow,
MESSAGE_REPORT_SUBMISSION_BY_REPORTER_COLUMNS,
type MessageReportSubmissionByReporterRow,
@@ -941,6 +943,12 @@ export const IARSubmissions = defineTable<IARSubmissionRow, 'report_id'>({
columns: IAR_SUBMISSION_COLUMNS,
primaryKey: ['report_id'],
});
export const IARReportNotes = defineTable<IARReportNoteRow, 'report_id' | 'note_id', 'report_id'>({
name: 'iar_report_notes',
columns: IAR_REPORT_NOTE_COLUMNS,
primaryKey: ['report_id', 'note_id'],
partitionKey: ['report_id'],
});
export const MessageReportSubmissionsByReporter = defineTable<
MessageReportSubmissionByReporterRow,
'reporter_id' | 'channel_id' | 'message_id',
@@ -42,6 +42,7 @@ export const AdminAuditReadActions = {
LIST_GUILD_THREADS: 'list_guild_threads',
LIST_NETWORK_BLOCK_CANDIDATES: 'list_network_block_candidates',
LIST_PENDING_REGISTRATIONS: 'list_pending_registrations',
LIST_REPORT_NOTES: 'list_report_notes',
LIST_REPORT_REASONS: 'list_report_reasons',
LIST_USER_APPLICATIONS: 'list_user_applications',
LIST_USER_CHANGE_LOG: 'list_user_change_log',
+2
View File
@@ -38,6 +38,7 @@ import {
getEnforcementDecisionRepository,
getGuildDiscoveryRepository,
getKVAccountDeletionQueue,
getReportRepository,
} from '@app/api/middleware/ServiceSingletons';
import type {ApplicationRepository} from '@app/api/oauth/repositories/ApplicationRepository';
import type {ReportService} from '@app/api/report/ReportService';
@@ -120,6 +121,7 @@ export class AdminService {
this.reportServiceAggregate = new AdminReportService({
apiContext: this.apiContext,
reportService: this.reportService,
reportRepository: getReportRepository(),
guildRepository: this.guildRepository,
channelRepository: this.channelRepository,
storageService: this.storageService,
@@ -18,6 +18,11 @@ import {
ReportLegalHoldRequest,
ReportLegalHoldResponse,
} from '@fluxer/schema/src/domains/admin/AdminReportLegalHoldSchemas';
import {
ReportNoteListResponse,
ReportNoteRequest,
ReportNoteResponse,
} from '@fluxer/schema/src/domains/admin/AdminReportNoteSchemas';
import {
AdminReportListResponse,
AdminReportReasonsResponse,
@@ -176,7 +181,7 @@ export function ReportAdminController(app: HonoApp) {
operationId: 'get_admin_report',
summary: 'Get report',
description:
'Retrieves detailed information about a specific report including content, reporter, reason, and the message context and profile snapshot captured when it was filed. Requires REPORT_VIEW permission.',
'Retrieves detailed information about a specific report including content, reporter, reason, the message context and profile snapshot captured when it was filed, and its internal notes. Requires REPORT_VIEW permission.',
responseSchema: ReportAdminResponseSchema,
statusCode: 200,
security: 'adminApiKey',
@@ -234,6 +239,64 @@ export function ReportAdminController(app: HonoApp) {
);
},
);
app.get(
'/admin/reports/:report_id/notes',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.REPORT_VIEW),
Validator('param', ReportIdParam),
OpenAPI({
operationId: 'list_admin_report_notes',
summary: 'List report notes',
description:
'Lists the internal notes on a report, oldest first. Notes are staff only and are deleted with the report. Requires REPORT_VIEW permission.',
responseSchema: ReportNoteListResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
const {report_id} = ctx.req.valid('param');
const result = await ctx.get('adminService').reportServiceAggregate.listNotes(createReportID(report_id));
await recordAdminRead(ctx, {
targetType: 'report',
targetId: report_id,
action: AdminAuditReadActions.LIST_REPORT_NOTES,
metadata: {result_count: result.notes.length},
});
return ctx.json(result);
},
);
app.post(
'/admin/reports/:report_id/notes',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.REPORT_RESOLVE),
Validator('param', ReportIdParam),
Validator('json', ReportNoteRequest),
OpenAPI({
operationId: 'annotate_admin_report',
summary: 'Add a note to a report',
description:
'Appends an internal note to a report. Notes are staff only, never change and are deleted with the report. Creates audit log entry. Requires REPORT_RESOLVE permission.',
responseSchema: ReportNoteResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
const {report_id} = ctx.req.valid('param');
const reportId = createReportID(report_id);
const note = await ctx
.get('adminService')
.reportServiceAggregate.addNote(reportId, ctx.req.valid('json').body, ctx.get('adminUserId'));
await recordAdminWrite(ctx, {
targetType: 'report',
targetId: report_id,
action: 'annotate_report',
metadata: {report_id: reportId.toString(), note_id: note.note_id},
});
return ctx.json(note);
},
);
app.post(
'/admin/reports/:report_id/legal-hold',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
@@ -294,7 +357,7 @@ export function ReportAdminController(app: HonoApp) {
operationId: 'delete_admin_report',
summary: 'Delete report',
description:
'Deletes a report now: its record with the captured message context and profile snapshot, its stored evidence copies, its search index entry, and the duplicate-report reservation it holds for the reporter. An evidence copy that another report also uses is kept. A report under a legal hold that has not ended is refused with REPORT_UNDER_LEGAL_HOLD. This cannot be undone. Creates an audit log entry. Requires REPORT_DELETE permission.',
'Deletes a report now: its record with the captured message context and profile snapshot, its notes, its stored evidence copies, its search index entry, and the duplicate-report reservation it holds for the reporter. An evidence copy that another report also uses is kept. A report under a legal hold that has not ended is refused with REPORT_UNDER_LEGAL_HOLD. This cannot be undone. Creates an audit log entry. Requires REPORT_DELETE permission.',
responseSchema: null,
statusCode: 204,
security: 'adminApiKey',
@@ -23,6 +23,7 @@ import {
} from '@app/api/channel/services/message/MessageResponseDataService';
import {resolveNsfwScopeChannel} from '@app/api/channel/utils/ThreadNsfwScope';
import type {MessageAttachment} from '@app/api/database/types/MessageTypes';
import type {IARReportNoteRow} from '@app/api/database/types/ReportTypes';
import type {GuildRepository} from '@app/api/guild/repositories/GuildRepository';
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
@@ -32,6 +33,7 @@ import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
import {describeReportFlowAnswers} from '@app/api/report/flows/ReportFlowRegistry';
import {findReportReason, listReportReasons} from '@app/api/report/flows/ReportReasonCatalog';
import type {IARMessageContext, IARSubmission} from '@app/api/report/ReportModels';
import type {ReportRepository} from '@app/api/report/ReportRepository';
import type {ReportService} from '@app/api/report/ReportService';
import {getReportSearchService} from '@app/api/SearchFactory';
import {isHiddenPartial} from '@app/api/user/ProfileVisibility';
@@ -44,6 +46,7 @@ import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/F
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
import {UnknownReportError} from '@fluxer/errors/src/domains/moderation/UnknownReportError';
import type {ReportSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
import type {ReportNoteListResponse, ReportNoteResponse} from '@fluxer/schema/src/domains/admin/AdminReportNoteSchemas';
import type {
AdminReportReasonsResponse,
ReportItemOutcome,
@@ -58,6 +61,7 @@ import {seconds} from 'itty-time';
interface AdminReportServiceDeps {
apiContext: ApiContext;
reportService: ReportService;
reportRepository: ReportRepository;
guildRepository: GuildRepository;
channelRepository: ChannelRepository;
storageService: IStorageService;
@@ -136,6 +140,15 @@ function mapMissingAttachmentToResponse(attachment: MessageAttachment) {
};
}
function toReportNoteResponse(note: IARReportNoteRow): ReportNoteResponse {
return {
note_id: note.note_id.toString(),
author_id: note.author_id.toString(),
body: note.body,
created_at: note.created_at.toISOString(),
};
}
function mapReportReasonFields(report: IARSubmission) {
const reason = report.reason ? findReportReason(report.reason) : null;
const flow =
@@ -197,7 +210,30 @@ export class AdminReportService {
const report = await reportService.getReport(reportId);
const requestCache = createRequestCache();
const reportLookupCache = createReportLookupCache();
return this.mapReportToResponse(report, true, requestCache, acls, reportLookupCache);
const [response, notes] = await Promise.all([
this.mapReportToResponse(report, true, requestCache, acls, reportLookupCache),
this.deps.reportRepository.listNotes(reportId),
]);
return {...response, notes: notes.map(toReportNoteResponse)};
}
async listNotes(reportId: ReportID): Promise<ReportNoteListResponse> {
await this.deps.reportService.getReport(reportId);
const notes = await this.deps.reportRepository.listNotes(reportId);
return {notes: notes.map(toReportNoteResponse)};
}
async addNote(reportId: ReportID, body: string, authorId: UserID): Promise<ReportNoteResponse> {
await this.deps.reportService.getReport(reportId);
const note: IARReportNoteRow = {
report_id: reportId,
note_id: await this.deps.apiContext.services.snowflake.generate(),
author_id: authorId,
body,
created_at: new Date(),
};
await this.deps.reportRepository.addNote(note);
return toReportNoteResponse(note);
}
async resolveReport(
@@ -234,6 +234,25 @@ describe('DELETE /admin/reports/:report_id', () => {
});
});
test('deletes the notes with the report and keeps the notes of other reports', async () => {
const deleted = await seedMessageReport(newEvidence());
const other = await seedMessageReport(newEvidence());
for (const seeded of [deleted, other]) {
await repository.addNote({
report_id: seeded.reportId,
note_id: nextId(),
author_id: BigInt(admin.userId),
body: 'Staff context',
created_at: new Date(),
});
}
await deleteRequest(admin, deleted.reportId).expect(HTTP_STATUS.NO_CONTENT).execute();
expect(await repository.listNotes(deleted.reportId)).toEqual([]);
expect(await repository.listNotes(other.reportId)).toHaveLength(1);
});
test('keeps an evidence copy another report still uses', async () => {
const shared = newEvidence();
const deleted = await seedMessageReport(shared);
@@ -0,0 +1,170 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {AdminAuditLog} from '@app/api/admin/AdminRepository';
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
import {createReportID, type ReportID} from '@app/api/BrandedTypes';
import {IAR_SUBMISSION_COLUMNS, type IARSubmissionRow} from '@app/api/database/types/ReportTypes';
import {getAdminRepository} from '@app/api/middleware/ServiceSingletons';
import {ReportRepository} from '@app/api/report/ReportRepository';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import type {ReportNoteListResponse, ReportNoteResponse} from '@fluxer/schema/src/domains/admin/AdminReportNoteSchemas';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
interface ValidationErrorResponse {
code: string;
errors?: Array<{path: string; message: string}>;
}
const AUDIT_REASON = 'Ticket 314';
let sequence = 1_496_000_000_000_000_000n;
describe('report notes', () => {
let harness: ApiTestHarness;
let admin: TestAccount;
let repository: ReportRepository;
beforeEach(async () => {
harness = await createApiTestHarness();
admin = await setUserACLs(harness, await createTestAccount(harness), [
'admin:authenticate',
AdminACLs.REPORT_VIEW,
AdminACLs.REPORT_RESOLVE,
]);
repository = new ReportRepository();
});
afterEach(async () => {
await harness?.shutdown();
});
async function seedReport(): Promise<ReportID> {
sequence += 1n;
await repository.createReport({
...Object.fromEntries(IAR_SUBMISSION_COLUMNS.map((column) => [column, null])),
report_id: sequence,
reported_at: new Date(),
status: 0,
report_type: 1,
category: 'harassment',
} as IARSubmissionRow);
return createReportID(sequence);
}
function addNote(account: TestAccount, reportId: ReportID | string, body: unknown) {
return createBuilder<ReportNoteResponse & ValidationErrorResponse>(harness, account.token)
.post(`/admin/reports/${reportId}/notes`)
.header('X-Audit-Log-Reason', AUDIT_REASON)
.body({body});
}
function listNotes(account: TestAccount, reportId: ReportID | string) {
return createBuilder<ReportNoteListResponse>(harness, account.token).get(`/admin/reports/${reportId}/notes`);
}
async function auditEntries(action: string): Promise<Array<AdminAuditLog>> {
const logs = await getAdminRepository().listAllAuditLogsPaginated(10000);
return logs.filter((log) => log.action === action);
}
test('adds notes, lists them oldest first and returns them with the report', async () => {
const reportId = await seedReport();
const first = await addNote(admin, reportId, ' Reporter sent more context by email ')
.expect(HTTP_STATUS.OK)
.execute();
const second = await addNote(admin, reportId, 'Waiting on the guild owner').expect(HTTP_STATUS.OK).execute();
expect(first).toEqual({
note_id: expect.any(String),
author_id: admin.userId,
body: 'Reporter sent more context by email',
created_at: expect.any(String),
});
expect(await listNotes(admin, reportId).expect(HTTP_STATUS.OK).execute()).toEqual({notes: [first, second]});
const detail = await createBuilder<{notes?: Array<ReportNoteResponse>}>(harness, admin.token)
.get(`/admin/reports/${reportId}`)
.expect(HTTP_STATUS.OK)
.execute();
expect(detail.notes).toEqual([first, second]);
});
test('records the note in the audit log without its body', async () => {
const reportId = await seedReport();
const note = await addNote(admin, reportId, 'Private staff context').expect(HTTP_STATUS.OK).execute();
const [entry, ...rest] = await auditEntries('annotate_report');
expect(rest).toEqual([]);
expect(entry?.adminUserId.toString()).toBe(admin.userId);
expect(entry?.targetType).toBe('report');
expect(entry?.targetId.toString()).toBe(reportId.toString());
expect(entry?.auditLogReason).toBe(AUDIT_REASON);
expect(Object.fromEntries(entry?.metadata ?? [])).toEqual({
report_id: reportId.toString(),
note_id: note.note_id,
});
});
test.each([
['empty', ''],
['blank', ' '],
['too long', 'x'.repeat(2001)],
['not a string', 42],
])('refuses a %s body and stores nothing', async (_, body) => {
const reportId = await seedReport();
const response = await addNote(admin, reportId, body)
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
.execute();
expect(response.errors?.[0]?.path).toBe('body');
expect(await repository.listNotes(reportId)).toEqual([]);
expect(await auditEntries('annotate_report')).toEqual([]);
});
test('accepts a body of 2000 characters', async () => {
const reportId = await seedReport();
const note = await addNote(admin, reportId, 'x'.repeat(2000)).expect(HTTP_STATUS.OK).execute();
expect(note.body).toHaveLength(2000);
});
test('an unknown report gives UNKNOWN_REPORT and stores nothing', async () => {
sequence += 1n;
const missing = createReportID(sequence);
await addNote(admin, missing, 'Note').expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_REPORT').execute();
await listNotes(admin, missing).expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_REPORT').execute();
expect(await repository.listNotes(missing)).toEqual([]);
expect(await auditEntries('annotate_report')).toEqual([]);
});
test('adding a note needs REPORT_RESOLVE', async () => {
const reportId = await seedReport();
const viewer = await setUserACLs(harness, await createTestAccount(harness), [
'admin:authenticate',
AdminACLs.REPORT_VIEW,
]);
await addNote(viewer, reportId, 'Note').expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL').execute();
expect(await repository.listNotes(reportId)).toEqual([]);
expect(await auditEntries('annotate_report')).toEqual([]);
});
test('listing notes needs REPORT_VIEW', async () => {
const reportId = await seedReport();
await addNote(admin, reportId, 'Note').expect(HTTP_STATUS.OK).execute();
const resolver = await setUserACLs(harness, await createTestAccount(harness), [
'admin:authenticate',
AdminACLs.REPORT_RESOLVE,
]);
await listNotes(resolver, reportId).expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL').execute();
});
});
@@ -9,6 +9,7 @@ import {createReportID} from '@app/api/BrandedTypes';
import {getReportRepository} from '@app/api/middleware/ServiceSingletons';
import {listReportReasons} from '@app/api/report/flows/ReportReasonCatalog';
import {createBuilder} from '@app/api/test/TestRequestBuilder';
import {expect} from 'vitest';
interface FiledUserReport {
reportId: string;
@@ -321,6 +322,42 @@ export const ReportAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
},
}),
),
{
method: 'GET',
route: '/admin/reports/:report_id/notes',
async prepare(context) {
const {reportId} = await fileUserReport(context);
await createBuilder(context.harness, context.admin.token)
.post(`/admin/reports/${reportId}/notes`)
.body({body: 'Coverage note'})
.execute();
return {
request: {path: `/admin/reports/${reportId}/notes`},
expected: {
action: 'list_report_notes',
targetType: 'report',
targetId: reportId,
metadata: {result_count: '1'},
},
};
},
},
{
method: 'POST',
route: '/admin/reports/:report_id/notes',
async prepare(context) {
const {reportId} = await fileUserReport(context);
return {
request: {path: `/admin/reports/${reportId}/notes`, body: {body: 'Coverage note'}},
expected: {
action: 'annotate_report',
targetType: 'report',
targetId: reportId,
metadata: {report_id: reportId, note_id: expect.any(String)},
},
};
},
},
{
method: 'POST',
route: '/admin/reports/:report_id/legal-hold',
@@ -8,6 +8,7 @@ import {
DSAReportEmailVerifications,
DSAReportTickets,
GuildReportSubmissionsByReporter,
IARReportNotes,
IARSubmissions,
MessageReportSubmissionsByReporter,
UserReportSubmissionsByReporter,
@@ -52,6 +53,7 @@ const IAR_MESSAGE_CONTEXT_FIELDS = {
const REPORT_TABLES = [
IARSubmissions,
IARReportNotes,
MessageReportSubmissionsByReporter,
UserReportSubmissionsByReporter,
GuildReportSubmissionsByReporter,
@@ -89,6 +89,14 @@ export interface IARSubmissionRow {
reporter_outcome?: string | null;
}
export interface IARReportNoteRow {
report_id: bigint;
note_id: bigint;
author_id: bigint;
body: string;
created_at: Date;
}
export interface DSAReportEmailVerificationRow {
email_lower: string;
code_hash: string;
@@ -186,6 +194,13 @@ export const IAR_SUBMISSION_COLUMNS = [
'legal_hold_reason',
'reporter_outcome',
] as const satisfies ReadonlyArray<keyof IARSubmissionRow>;
export const IAR_REPORT_NOTE_COLUMNS = [
'report_id',
'note_id',
'author_id',
'body',
'created_at',
] as const satisfies ReadonlyArray<keyof IARReportNoteRow>;
export const DSA_REPORT_EMAIL_VERIFICATION_COLUMNS = [
'email_lower',
'code_hash',
+22 -1
View File
@@ -10,12 +10,19 @@ import {
createUserID,
createWebhookID,
} from '@app/api/BrandedTypes';
import {executeConditional, fetchMany, fetchOne, upsertOne} from '@app/api/database/CassandraQueryExecution';
import {
deleteOneOrMany,
executeConditional,
fetchMany,
fetchOne,
upsertOne,
} from '@app/api/database/CassandraQueryExecution';
import {Db} from '@app/api/database/CassandraTypes';
import type {
DSAReportEmailVerificationRow,
DSAReportTicketRow,
GuildReportSubmissionByReporterRow,
IARReportNoteRow,
MessageReportSubmissionByReporterRow,
UserReportSubmissionByReporterRow,
} from '@app/api/database/types/ReportTypes';
@@ -32,6 +39,7 @@ import {
DSAReportEmailVerifications,
DSAReportTickets,
GuildReportSubmissionsByReporter,
IARReportNotes,
IARSubmissions,
MessageReportSubmissionsByReporter,
UserReportSubmissionsByReporter,
@@ -45,6 +53,10 @@ const GET_REPORT_QUERY = IARSubmissions.select({
where: IARSubmissions.where.eq('report_id'),
limit: 1,
});
const LIST_NOTES_QUERY = IARReportNotes.select({
where: IARReportNotes.where.eq('report_id'),
orderBy: {col: 'note_id', direction: 'ASC'},
});
const createFetchAllReportsPaginatedQuery = (limit: number) =>
IARSubmissions.select({
where: IARSubmissions.where.tokenGt('report_id', 'last_report_id'),
@@ -216,11 +228,20 @@ export class ReportRepository {
return;
}
await this.releaseReporterReservation(report);
await deleteOneOrMany(IARReportNotes.deletePartition({report_id: reportId}));
await executeConditional(
IARSubmissions.conditionalDeleteByPk({report_id: reportId}, {report_type: report.reportType}),
);
}
async addNote(note: IARReportNoteRow): Promise<void> {
await upsertOne(IARReportNotes.insert(note));
}
async listNotes(reportId: ReportID): Promise<Array<IARReportNoteRow>> {
return fetchMany<IARReportNoteRow>(LIST_NOTES_QUERY.bind({report_id: reportId}));
}
private async releaseReporterReservation(report: IARSubmission): Promise<void> {
const {reporterId, reportId, reportedAt} = report;
if (!reporterId) {
@@ -5,7 +5,7 @@ import path from 'node:path';
import {fileURLToPath} from 'node:url';
import {clearTestEmails, createUniqueEmail, findLastTestEmail, listTestEmails} from '@app/api/auth/tests/AuthTestUtils';
import {ReportRepository} from '@app/api/report/ReportRepository';
import {DSAReportEmailVerifications, DSAReportTickets, IARSubmissions} from '@app/api/Tables';
import {DSAReportEmailVerifications, DSAReportTickets, IARReportNotes, IARSubmissions} from '@app/api/Tables';
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
@@ -29,6 +29,11 @@ describe('report table TTLs', () => {
expect(IARSubmissions.defaultTtlSeconds).toBeUndefined();
expect(schemaOptions(IARSubmissions.name)).not.toContain('default_time_to_live');
});
it('keeps report notes without a table TTL, so they go only with the report', () => {
expect(IARReportNotes.defaultTtlSeconds).toBeUndefined();
expect(schemaOptions(IARReportNotes.name)).not.toContain('default_time_to_live');
});
});
describe('report table TTLs cover the service windows', () => {
@@ -89,6 +89,7 @@ export function createWorkerEnforcementServices(deps: WorkerDependencies): Worke
adminReportService: new AdminReportService({
apiContext,
reportService,
reportRepository: deps.reportRepository,
guildRepository: deps.guildRepository,
channelRepository: deps.channelRepository,
storageService: deps.storageService,
@@ -354,6 +354,43 @@ describe('report retention', () => {
});
});
it('deletes the notes with an expired report and keeps the notes of a retained one', async () => {
const expired = await seed({resolvedDaysAgo: 365});
const contextExpired = await seed({resolvedDaysAgo: 200, outcome: 'no_violation', additionalInfo: 'details'});
const kept = await seed({resolvedDaysAgo: 10});
for (const {reportId} of [expired, contextExpired, kept]) {
await repository.addNote({
report_id: reportId,
note_id: nextId(),
author_id: nextId(),
body: 'Staff context',
created_at: NOW,
});
}
const summary = await run();
expect(summary).toMatchObject({deleted: 1, contextCleared: 1});
expect(await repository.listNotes(expired.reportId)).toEqual([]);
expect(await repository.listNotes(contextExpired.reportId)).toHaveLength(1);
expect(await repository.listNotes(kept.reportId)).toHaveLength(1);
});
it('keeps the notes in a dry run', async () => {
const expired = await seed({resolvedDaysAgo: 365});
await repository.addNote({
report_id: expired.reportId,
note_id: nextId(),
author_id: nextId(),
body: 'Staff context',
created_at: NOW,
});
await run({dryRun: true});
expect(await repository.listNotes(expired.reportId)).toHaveLength(1);
});
it('keeps a pending report however old it is', async () => {
const pending = await seed({ageDays: 4000, attachment: newAttachment(), profile: true});