feat(enforcement): record decisions and send notices (#3492)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-15 07:12:46 +02:00
1 parent da1842ecab
commit f5c30fdf8f
383 files changed
+29692 -4139

No files matched your search

@@ -39,11 +39,14 @@ import {formatUserTag} from '@app/api/user/UserTag';
import {assertSafeByteSize} from '@app/api/utils/ByteSizeUtils';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {DELETED_USER_DISCRIMINATOR, DELETED_USER_USERNAME} from '@fluxer/constants/src/UserConstants';
import {ValidationErrorCodes} from '@fluxer/constants/src/ValidationErrorCodes';
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
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 {
AdminReportReasonsResponse,
ReportItemOutcome,
SearchReportsRequest,
UpdateReportRequest,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
@@ -63,7 +66,39 @@ interface AdminReportServiceDeps {
reporterResolutionNotifier: ReporterResolutionNotifier;
}
type StaffReportResolution = NonNullable<UpdateReportRequest['resolution']>;
interface StaffReportResolution {
adminUserId: UserID;
publicComment: string | null;
auditLogReason: string | null;
notifyReporter: boolean;
resolution: UpdateReportRequest['resolution'];
outcome?: ReportItemOutcome;
duplicateOf?: ReportID;
}
function refuse(path: 'outcome' | 'duplicate_of'): never {
throw InputValidationError.fromCode(path, ValidationErrorCodes.REPORT_OUTCOME_INVALID);
}
function staffOutcome(
resolution: UpdateReportRequest['resolution'],
outcome: ReportItemOutcome | undefined,
duplicateOf: ReportID | undefined,
): ReportItemOutcome | null {
if (resolution !== 'duplicate' && duplicateOf !== undefined) refuse('duplicate_of');
switch (resolution) {
case 'no_violation':
if (outcome !== undefined && outcome !== 'no_violation') refuse('outcome');
return 'no_violation';
case 'actioned':
if (outcome === undefined || outcome === 'no_violation') refuse('outcome');
return outcome;
case 'duplicate':
if (outcome !== undefined) refuse('outcome');
if (duplicateOf === undefined) refuse('duplicate_of');
return null;
}
}
interface ReportLookupCache {
channelNsfwByChannelId: Map<string, boolean | null>;
@@ -167,20 +202,29 @@ export class AdminReportService {
async resolveReport(
reportId: ReportID,
adminUserId: UserID,
publicComment: string | null,
auditLogReason: string | null,
notifyReporter: boolean,
resolution?: StaffReportResolution,
{
adminUserId,
publicComment,
auditLogReason,
notifyReporter,
resolution,
outcome,
duplicateOf,
}: StaffReportResolution,
) {
const {reportService, auditService, reporterResolutionNotifier} = this.deps;
const reporterOutcome =
staffOutcome(resolution, outcome, duplicateOf) ?? (await this.duplicatedOutcome(reportId, duplicateOf));
const sentComment = notifyReporter ? publicComment : null;
const internalComment = notifyReporter ? null : publicComment;
const resolvedReport = await reportService.resolveReport(reportId, adminUserId, sentComment, auditLogReason, {
outcome: resolution,
resolvedBy: 'staff',
reporterOutcome,
});
const notice = await reporterResolutionNotifier.notifyReporterOfResolution(resolvedReport, sentComment);
const notice = reporterOutcome
? await reporterResolutionNotifier.notifyReporterOfResolution(resolvedReport, sentComment, reporterOutcome)
: null;
await auditService.createAuditLog({
adminUserId,
targetType: 'report',
@@ -192,8 +236,12 @@ export class AdminReportService {
['report_type', resolvedReport.reportType.toString()],
['notify_reporter', notifyReporter ? 'true' : 'false'],
...(internalComment ? [['internal_comment', internalComment] as [string, string]] : []),
...describeReporterNotice(notice),
...(resolution ? [['resolution', resolution] as [string, string]] : []),
...(notice
? describeReporterNotice(notice)
: [['reporter_notice_skipped', 'duplicate_outcome_unknown'] as [string, string]]),
['resolution', resolution],
...(duplicateOf ? [['duplicate_of', duplicateOf.toString()] as [string, string]] : []),
...(reporterOutcome ? [['outcome', reporterOutcome] as [string, string]] : []),
]),
});
return {
@@ -204,6 +252,16 @@ export class AdminReportService {
};
}
private async duplicatedOutcome(
reportId: ReportID,
duplicateOf: ReportID | undefined,
): Promise<ReportItemOutcome | null> {
if (duplicateOf === undefined) return null;
if (duplicateOf === reportId) refuse('duplicate_of');
const original = await this.deps.reportService.getReport(duplicateOf);
return original.reporterOutcome;
}
async searchReports(data: SearchReportsRequest, acls: ReadonlySet<string>) {
const reportSearchService = getReportSearchService();
if (!reportSearchService) {