mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-19 05:55:34 +09:00
feat(admin): add internal notes to reports (#3510)
This commit is contained in:
1 parent
2d8d130be1
commit
49a1ffabbe
30 files changed
+1138
-87
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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});
|
||||
|
||||
|
||||
Reference in new issue
Block a user