mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
feat(reports): add server-driven report flows (#3330)
This commit is contained in:
1 parent
bc17922b02
commit
ece622e800
471 files changed
+56262
-35932
No files matched your search
@@ -39,6 +39,7 @@ export const AdminAuditReadActions = {
|
||||
LIST_GUILD_MEMORY_STATS: 'list_guild_memory_stats',
|
||||
LIST_GUILD_STICKERS: 'list_guild_stickers',
|
||||
LIST_GUILD_THREADS: 'list_guild_threads',
|
||||
LIST_REPORT_REASONS: 'list_report_reasons',
|
||||
LIST_USER_APPLICATIONS: 'list_user_applications',
|
||||
LIST_USER_CHANGE_LOG: 'list_user_change_log',
|
||||
LIST_USER_DM_CHANNELS: 'list_user_dm_channels',
|
||||
|
||||
@@ -16,6 +16,7 @@ import {AdminSearchService} from '@app/api/admin/services/AdminSearchService';
|
||||
import {AdminUserRelationshipService} from '@app/api/admin/services/AdminUserRelationshipService';
|
||||
import {AdminUserService} from '@app/api/admin/services/AdminUserService';
|
||||
import {AdminVoiceService} from '@app/api/admin/services/AdminVoiceService';
|
||||
import {ReporterResolutionNotifier} from '@app/api/admin/services/ReporterResolutionNotifier';
|
||||
import type {UserID} from '@app/api/BrandedTypes';
|
||||
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
|
||||
import type {ChannelService} from '@app/api/channel/services/ChannelService';
|
||||
@@ -93,6 +94,14 @@ export class AdminService {
|
||||
adminRepository: this.adminRepository,
|
||||
auditService: this.auditService,
|
||||
});
|
||||
const reporterResolutionNotifier = new ReporterResolutionNotifier({
|
||||
apiContext: this.apiContext,
|
||||
systemDm: {
|
||||
channelService: this.channelService,
|
||||
userChannelService: this.runtimeUserService.channelService,
|
||||
userCacheService: this.userCacheService,
|
||||
},
|
||||
});
|
||||
this.userService = new AdminUserService({
|
||||
apiContext: this.apiContext,
|
||||
guildRepository: this.guildRepository,
|
||||
@@ -107,6 +116,7 @@ export class AdminService {
|
||||
stripe: this.stripe,
|
||||
reportService: this.reportService,
|
||||
storeEntitlementService: this.storeEntitlementService,
|
||||
reporterResolutionNotifier,
|
||||
});
|
||||
this.guildServiceAggregate = new AdminGuildService({
|
||||
guildRepository: this.guildRepository,
|
||||
@@ -146,12 +156,11 @@ export class AdminService {
|
||||
reportService: this.reportService,
|
||||
guildRepository: this.guildRepository,
|
||||
channelRepository: this.channelRepository,
|
||||
channelService: this.channelService,
|
||||
storageService: this.storageService,
|
||||
auditService: this.auditService,
|
||||
userCacheService: this.userCacheService,
|
||||
userChannelService: this.runtimeUserService.channelService,
|
||||
ncmecSubmissionService: getNcmecSubmissionService(),
|
||||
reporterResolutionNotifier,
|
||||
});
|
||||
this.voiceService = new AdminVoiceService({
|
||||
apiContext: this.apiContext,
|
||||
|
||||
@@ -6,12 +6,19 @@ import {createReportID} from '@app/api/BrandedTypes';
|
||||
import {requireAdminACL} from '@app/api/middleware/AdminMiddleware';
|
||||
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||
import {getReportRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||
import {Validator} from '@app/api/Validator';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {InputValidationError} from '@fluxer/errors/src/domains/core/InputValidationError';
|
||||
import {
|
||||
ReportLegalHoldRequest,
|
||||
ReportLegalHoldResponse,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminReportLegalHoldSchemas';
|
||||
import {
|
||||
AdminReportListResponse,
|
||||
AdminReportReasonsResponse,
|
||||
ListReportsQuery,
|
||||
ReportAdminResponseSchema,
|
||||
ResolveReportResponse,
|
||||
@@ -42,8 +49,10 @@ function usesReportSearchIndex(query: ListReportsQuery): boolean {
|
||||
query.q !== undefined ||
|
||||
query.report_type !== undefined ||
|
||||
query.category !== undefined ||
|
||||
query.reason !== undefined ||
|
||||
query.reporter_id !== undefined ||
|
||||
query.reported_user_id !== undefined ||
|
||||
query.reported_webhook_id !== undefined ||
|
||||
query.reported_guild_id !== undefined ||
|
||||
query.reported_channel_id !== undefined ||
|
||||
query.guild_context_id !== undefined ||
|
||||
@@ -60,7 +69,9 @@ function toSearchReportsRequest(query: ListReportsQuery): SearchReportsRequest {
|
||||
status: query.status === undefined ? undefined : REPORT_STATUS_BY_FILTER[query.status],
|
||||
report_type: query.report_type === undefined ? undefined : REPORT_TYPE_BY_FILTER[query.report_type],
|
||||
category: query.category,
|
||||
reason: query.reason,
|
||||
reported_user_id: query.reported_user_id,
|
||||
reported_webhook_id: query.reported_webhook_id,
|
||||
reported_guild_id: query.reported_guild_id,
|
||||
reported_channel_id: query.reported_channel_id,
|
||||
guild_context_id: query.guild_context_id,
|
||||
@@ -80,7 +91,7 @@ export function ReportAdminController(app: HonoApp) {
|
||||
operationId: 'list_admin_reports',
|
||||
summary: 'List reports',
|
||||
description:
|
||||
'Lists user and content reports with pagination. Filtering by status alone reads them straight from the database; supplying a free-text query or any of the entity, category and resolver filters searches the report index instead and adds the total, offset and limit of the page to the response. Reporter contact details are redacted unless the caller also holds REPORT_VIEW_REPORTER_PII. Requires REPORT_VIEW permission.',
|
||||
'Lists user and content reports from the report search index with pagination. Without a status filter the list covers reports of every status. A request that filters by status alone lists the reports with that status newest first and ignores sort_by and sort_order. A free-text query and the entity, category, reason and resolver filters narrow the results. Every response includes the total, offset and limit of the page. Reporter contact details are redacted unless the caller also holds REPORT_VIEW_REPORTER_PII. Requires REPORT_VIEW permission.',
|
||||
responseSchema: AdminReportListResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
@@ -108,8 +119,11 @@ export function ReportAdminController(app: HonoApp) {
|
||||
has_query: query.q === undefined ? undefined : true,
|
||||
status,
|
||||
report_type: query.report_type,
|
||||
category: query.category,
|
||||
reason: query.reason,
|
||||
reporter_user_id: query.reporter_id,
|
||||
reported_user_id: query.reported_user_id,
|
||||
reported_webhook_id: query.reported_webhook_id,
|
||||
reported_guild_id: query.reported_guild_id,
|
||||
reported_channel_id: query.reported_channel_id,
|
||||
context_guild_id: query.guild_context_id,
|
||||
@@ -125,6 +139,32 @@ export function ReportAdminController(app: HonoApp) {
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/admin/report-reasons',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.REPORT_VIEW),
|
||||
OpenAPI({
|
||||
operationId: 'list_admin_report_reasons',
|
||||
summary: 'List report reasons',
|
||||
description:
|
||||
'Lists every reason key a report filed through a report flow can carry, with its English label, whether it is a highest-priority reason, and the legacy category it maps to for message, user and community reports. Requires REPORT_VIEW permission.',
|
||||
responseSchema: AdminReportReasonsResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const response = adminService.reportServiceAggregate.listReportReasons();
|
||||
await recordAdminRead(ctx, {
|
||||
targetType: 'report',
|
||||
targetId: 0n,
|
||||
action: AdminAuditReadActions.LIST_REPORT_REASONS,
|
||||
metadata: {result_count: response.reasons.length},
|
||||
});
|
||||
return ctx.json(response);
|
||||
},
|
||||
);
|
||||
app.get(
|
||||
'/admin/reports/:report_id',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
@@ -134,7 +174,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 captured when it was filed. Requires REPORT_VIEW permission.',
|
||||
'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.',
|
||||
responseSchema: ReportAdminResponseSchema,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
@@ -167,7 +207,7 @@ export function ReportAdminController(app: HonoApp) {
|
||||
operationId: 'update_admin_report',
|
||||
summary: 'Update report',
|
||||
description:
|
||||
'Moves a report to the resolved status with an optional public comment shown to the reporter. Marks the report as handled, notifies the reporter by system DM and email unless notify_reporter is false, and creates an audit log entry. Requires REPORT_RESOLVE permission.',
|
||||
'Moves a report to the resolved status. The reporter is always told the report was reviewed: an account reporter by system DM and email, a Digital Services Act notifier by email. notify_reporter false leaves the public comment out of that notice. Nothing is sent when the reporter account is deleted or barred from reporting. Creates an audit log entry. Requires REPORT_RESOLVE permission.',
|
||||
responseSchema: ResolveReportResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
@@ -191,4 +231,62 @@ export function ReportAdminController(app: HonoApp) {
|
||||
);
|
||||
},
|
||||
);
|
||||
app.post(
|
||||
'/admin/reports/:report_id/legal-hold',
|
||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
|
||||
requireAdminACL(AdminACLs.REPORT_RESOLVE),
|
||||
Validator('param', ReportIdParam),
|
||||
Validator('json', ReportLegalHoldRequest),
|
||||
OpenAPI({
|
||||
operationId: 'set_admin_report_legal_hold',
|
||||
summary: 'Set report legal hold',
|
||||
description:
|
||||
'Places or clears a legal hold on a report. Reports are deleted 365 days after they were filed, together with their stored evidence, unless a hold that ends later is in place. A hold needs an end time in the future and a reason. A null legal_hold_until clears the hold. Creates an audit log entry. Requires REPORT_RESOLVE permission.',
|
||||
responseSchema: ReportLegalHoldResponse,
|
||||
statusCode: 200,
|
||||
security: 'adminApiKey',
|
||||
tags: 'Admin',
|
||||
}),
|
||||
async (ctx) => {
|
||||
const adminService = ctx.get('adminService');
|
||||
const {report_id} = ctx.req.valid('param');
|
||||
const {legal_hold_until, legal_hold_reason} = ctx.req.valid('json');
|
||||
const until = legal_hold_until === null ? null : new Date(legal_hold_until);
|
||||
const reason = until === null ? null : (legal_hold_reason ?? null);
|
||||
if (until !== null && until.getTime() <= Date.now()) {
|
||||
throw InputValidationError.create('legal_hold_until', 'The hold must end in the future');
|
||||
}
|
||||
if (until !== null && reason === null) {
|
||||
throw InputValidationError.create('legal_hold_reason', 'A reason is required to place a hold');
|
||||
}
|
||||
const reportId = createReportID(report_id);
|
||||
const reportRepository = getReportRepository();
|
||||
const previous = await reportRepository.getReport(reportId);
|
||||
const report = await reportRepository.setReportLegalHold(reportId, until, reason);
|
||||
const metadata = new Map([
|
||||
['report_id', reportId.toString()],
|
||||
['report_type', report.reportType.toString()],
|
||||
]);
|
||||
if (until !== null && reason !== null) {
|
||||
metadata.set('legal_hold_until', until.toISOString());
|
||||
metadata.set('legal_hold_reason', reason);
|
||||
}
|
||||
if (previous?.legalHoldUntil) {
|
||||
metadata.set('previous_legal_hold_until', previous.legalHoldUntil.toISOString());
|
||||
}
|
||||
await adminService.auditService.createAuditLog({
|
||||
adminUserId: ctx.get('adminUserId'),
|
||||
targetType: 'report',
|
||||
targetId: BigInt(reportId),
|
||||
action: until === null ? 'clear_report_legal_hold' : 'set_report_legal_hold',
|
||||
auditLogReason: ctx.get('auditLogReason'),
|
||||
metadata,
|
||||
});
|
||||
return ctx.json({
|
||||
report_id: reportId.toString(),
|
||||
legal_hold_until: report.legalHoldUntil?.toISOString() ?? null,
|
||||
legal_hold_reason: report.legalHoldReason,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService';
|
||||
import {trySendAdminNotification} from '@app/api/admin/services/AdminNotification';
|
||||
import {
|
||||
describeReporterNotice,
|
||||
type ReporterResolutionNotifier,
|
||||
} from '@app/api/admin/services/ReporterResolutionNotifier';
|
||||
import {
|
||||
type ChannelID,
|
||||
createReportID,
|
||||
@@ -13,7 +16,6 @@ import {
|
||||
} from '@app/api/BrandedTypes';
|
||||
import {Config} from '@app/api/Config';
|
||||
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
|
||||
import type {ChannelService} from '@app/api/channel/services/ChannelService';
|
||||
import {makeAttachmentCdnKey} from '@app/api/channel/services/message/MessageHelpers';
|
||||
import {
|
||||
createMessageResponseDataService,
|
||||
@@ -22,29 +24,35 @@ import {
|
||||
messageResponseAccessForGuild,
|
||||
} from '@app/api/channel/services/message/MessageResponseDataService';
|
||||
import {resolveNsfwScopeChannel} from '@app/api/channel/utils/ThreadNsfwScope';
|
||||
import {SYSTEM_USER_ID} from '@app/api/constants/Core';
|
||||
import type {NcmecAttachmentStatusResponse, NcmecSubmissionService} from '@app/api/csam/NcmecSubmissionService';
|
||||
import type {MessageAttachment} from '@app/api/database/types/MessageTypes';
|
||||
import {SYSTEM_THREAD_VIEWER} from '@app/api/experiment/ChannelThreadsGate';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IStorageService} from '@app/api/infrastructure/IStorageService';
|
||||
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {RequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {User} from '@app/api/models/User';
|
||||
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/IReportRepository';
|
||||
import type {ReportService} from '@app/api/report/ReportService';
|
||||
import {getReportSearchService} from '@app/api/SearchFactory';
|
||||
import {isHiddenPartial} from '@app/api/user/ProfileVisibility';
|
||||
import type {UserChannelService} from '@app/api/user/services/UserChannelService';
|
||||
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 {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import type {SearchReportsRequest, UpdateReportRequest} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import {UnknownReportError} from '@fluxer/errors/src/domains/moderation/UnknownReportError';
|
||||
import type {ReportSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
import type {
|
||||
AdminReportReasonsResponse,
|
||||
SearchReportsRequest,
|
||||
UpdateReportRequest,
|
||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||
import type {MessageResponse} from '@fluxer/schema/src/domains/message/MessageResponseSchemas';
|
||||
import {getEmailTemplate} from '@pkgs/email/src/email_i18n/EmailI18n';
|
||||
import type {ReportProfileAssetSnapshot} from '@fluxer/schema/src/domains/report/ReportProfileSnapshotSchemas';
|
||||
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
|
||||
import {seconds} from 'itty-time';
|
||||
|
||||
interface AdminReportServiceDeps {
|
||||
@@ -52,25 +60,68 @@ interface AdminReportServiceDeps {
|
||||
reportService: ReportService;
|
||||
guildRepository: IGuildRepositoryAggregate;
|
||||
channelRepository: IChannelRepository;
|
||||
channelService: ChannelService;
|
||||
storageService: IStorageService;
|
||||
auditService: AdminAuditService;
|
||||
userCacheService: UserCacheService;
|
||||
userChannelService: UserChannelService;
|
||||
ncmecSubmissionService: NcmecSubmissionService;
|
||||
reporterResolutionNotifier: ReporterResolutionNotifier;
|
||||
}
|
||||
|
||||
type StaffReportResolution = NonNullable<UpdateReportRequest['resolution']>;
|
||||
|
||||
interface ReportNsfwLookupCache {
|
||||
interface ReportLookupCache {
|
||||
channelNsfwByChannelId: Map<string, boolean | null>;
|
||||
guildNsfwLevelByGuildId: Map<string, number | null>;
|
||||
reporterEmailByReporterId: Map<string, Promise<string | null>>;
|
||||
}
|
||||
|
||||
function createReportNsfwLookupCache(): ReportNsfwLookupCache {
|
||||
function createReportLookupCache(): ReportLookupCache {
|
||||
return {
|
||||
channelNsfwByChannelId: new Map(),
|
||||
guildNsfwLevelByGuildId: new Map(),
|
||||
reporterEmailByReporterId: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
function mapUserPartialBotFlag(user: UserPartialResponse): boolean | null {
|
||||
if (user.bot) {
|
||||
return true;
|
||||
}
|
||||
const isUnresolved =
|
||||
user.username === DELETED_USER_USERNAME &&
|
||||
user.discriminator === DELETED_USER_DISCRIMINATOR.toString().padStart(4, '0');
|
||||
return isUnresolved ? null : false;
|
||||
}
|
||||
|
||||
function mapMissingAttachmentToResponse(attachment: MessageAttachment) {
|
||||
return {
|
||||
id: attachment.attachment_id.toString(),
|
||||
filename: String(attachment.filename),
|
||||
nsfw: attachment.nsfw ?? null,
|
||||
content_type: attachment.content_type ?? null,
|
||||
width: attachment.width ?? null,
|
||||
height: attachment.height ?? null,
|
||||
size: attachment.size != null ? assertSafeByteSize(attachment.size, 'admin report attachment size') : null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapReportReasonFields(report: IARSubmission) {
|
||||
const reason = report.reason ? findReportReason(report.reason) : null;
|
||||
const flow =
|
||||
report.flowSteps && report.flowRevision && report.flowSurface
|
||||
? describeReportFlowAnswers({
|
||||
revisionHash: report.flowRevision,
|
||||
surface: report.flowSurface,
|
||||
locale: report.flowLocale,
|
||||
steps: report.flowSteps,
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
reason: report.reason,
|
||||
reason_label: reason?.label ?? report.reason,
|
||||
reason_highest_priority: reason?.highestPriority ?? null,
|
||||
flow,
|
||||
reporter_good_faith_confirmed: report.reporterGoodFaithConfirmed,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -83,10 +134,10 @@ export class AdminReportService {
|
||||
const currentOffset = offset || 0;
|
||||
const {reports, total} = await reportService.listReportsByStatus(status, requestedLimit, currentOffset);
|
||||
const requestCache = createRequestCache();
|
||||
const reportNsfwLookupCache = createReportNsfwLookupCache();
|
||||
const reportLookupCache = createReportLookupCache();
|
||||
const reportResponses = await Promise.all(
|
||||
reports.map((report: IARSubmission) =>
|
||||
this.mapReportToResponse(report, false, requestCache, acls, reportNsfwLookupCache),
|
||||
this.mapReportToResponse(report, false, requestCache, acls, reportLookupCache),
|
||||
),
|
||||
);
|
||||
return {
|
||||
@@ -97,12 +148,25 @@ export class AdminReportService {
|
||||
};
|
||||
}
|
||||
|
||||
listReportReasons(): AdminReportReasonsResponse {
|
||||
return {
|
||||
reasons: listReportReasons().map((reason) => ({
|
||||
key: reason.key,
|
||||
label: reason.label,
|
||||
highest_priority: reason.highestPriority,
|
||||
legacy_category_message: reason.legacyCategories.message,
|
||||
legacy_category_user: reason.legacyCategories.user,
|
||||
legacy_category_guild: reason.legacyCategories.guild,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async getReport(reportId: ReportID, acls: ReadonlySet<string>) {
|
||||
const {reportService} = this.deps;
|
||||
const report = await reportService.getReport(reportId);
|
||||
const requestCache = createRequestCache();
|
||||
const reportNsfwLookupCache = createReportNsfwLookupCache();
|
||||
return this.mapReportToResponse(report, true, requestCache, acls, reportNsfwLookupCache);
|
||||
const reportLookupCache = createReportLookupCache();
|
||||
return this.mapReportToResponse(report, true, requestCache, acls, reportLookupCache);
|
||||
}
|
||||
|
||||
async resolveReport(
|
||||
@@ -113,38 +177,14 @@ export class AdminReportService {
|
||||
notifyReporter: boolean,
|
||||
resolution?: StaffReportResolution,
|
||||
) {
|
||||
const {reportService, auditService} = this.deps;
|
||||
const {users: userRepository, email: emailService} = this.deps.apiContext.services;
|
||||
const resolvedReport = await reportService.resolveReport(reportId, adminUserId, publicComment, auditLogReason, {
|
||||
const {reportService, auditService, reporterResolutionNotifier} = this.deps;
|
||||
const sentComment = notifyReporter ? publicComment : null;
|
||||
const internalComment = notifyReporter ? null : publicComment;
|
||||
const resolvedReport = await reportService.resolveReport(reportId, adminUserId, sentComment, auditLogReason, {
|
||||
outcome: resolution,
|
||||
resolvedBy: 'staff',
|
||||
});
|
||||
let reporterDmSent = false;
|
||||
let reporterEmailSent = false;
|
||||
const reporter =
|
||||
notifyReporter && resolvedReport.reporterId ? await userRepository.findUnique(resolvedReport.reporterId) : null;
|
||||
if (reporter) {
|
||||
const commentForTemplate = publicComment ?? '';
|
||||
reporterDmSent = await this.sendResolvedReportSystemDm({
|
||||
reporter,
|
||||
reportId,
|
||||
publicComment: commentForTemplate,
|
||||
});
|
||||
const email = reporter.email;
|
||||
if (email) {
|
||||
reporterEmailSent = await trySendAdminNotification(
|
||||
() =>
|
||||
emailService.sendReportResolvedEmail(
|
||||
email,
|
||||
reporter.username,
|
||||
reportId.toString(),
|
||||
commentForTemplate,
|
||||
reporter.locale,
|
||||
),
|
||||
{action: 'resolve_report', targetId: reportId.toString()},
|
||||
);
|
||||
}
|
||||
}
|
||||
const notice = await reporterResolutionNotifier.notifyReporterOfResolution(resolvedReport, sentComment);
|
||||
await auditService.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'report',
|
||||
@@ -155,8 +195,8 @@ export class AdminReportService {
|
||||
['report_id', reportId.toString()],
|
||||
['report_type', resolvedReport.reportType.toString()],
|
||||
['notify_reporter', notifyReporter ? 'true' : 'false'],
|
||||
['reporter_dm_sent', reporterDmSent ? 'true' : 'false'],
|
||||
['reporter_email_sent', reporterEmailSent ? 'true' : 'false'],
|
||||
...(internalComment ? [['internal_comment', internalComment] as [string, string]] : []),
|
||||
...describeReporterNotice(notice),
|
||||
...(resolution ? [['resolution', resolution] as [string, string]] : []),
|
||||
]),
|
||||
});
|
||||
@@ -168,135 +208,91 @@ export class AdminReportService {
|
||||
};
|
||||
}
|
||||
|
||||
private async sendResolvedReportSystemDm({
|
||||
reporter,
|
||||
reportId,
|
||||
publicComment,
|
||||
}: {
|
||||
reporter: User;
|
||||
reportId: ReportID;
|
||||
publicComment: string;
|
||||
}): Promise<boolean> {
|
||||
const {users: userRepository} = this.deps.apiContext.services;
|
||||
const template = getEmailTemplate('report_resolved', reporter.locale, {
|
||||
username: reporter.username,
|
||||
reportId: reportId.toString(),
|
||||
publicComment,
|
||||
hasComment: publicComment ? 'yes' : 'no',
|
||||
});
|
||||
if (!template.ok) {
|
||||
Logger.warn(
|
||||
{
|
||||
reportId: reportId.toString(),
|
||||
reporterId: reporter.id.toString(),
|
||||
locale: reporter.locale,
|
||||
error: template.error,
|
||||
},
|
||||
'Skipping report review system DM because the email template could not be resolved',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const requestCache = createRequestCache();
|
||||
try {
|
||||
const systemUser = await userRepository.findUniqueAssert(SYSTEM_USER_ID);
|
||||
const dmChannel = await this.deps.userChannelService.ensureDmOpenForBothUsers({
|
||||
userId: systemUser.id,
|
||||
recipientId: reporter.id,
|
||||
userCacheService: this.deps.userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
await this.deps.channelService.messages.send.sendMessage({
|
||||
user: systemUser,
|
||||
viewer: SYSTEM_THREAD_VIEWER,
|
||||
channelId: dmChannel.id,
|
||||
data: {
|
||||
content: template.value.body,
|
||||
},
|
||||
requestCache,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{reportId: reportId.toString(), reporterId: reporter.id.toString(), error},
|
||||
'Failed to send report review system DM',
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
requestCache.clear();
|
||||
}
|
||||
}
|
||||
|
||||
async searchReports(data: SearchReportsRequest, acls: ReadonlySet<string>) {
|
||||
const reportSearchService = getReportSearchService();
|
||||
if (!reportSearchService) {
|
||||
throw new FeatureTemporarilyDisabledError();
|
||||
}
|
||||
const filters: Record<string, string | number> = {};
|
||||
const filters: ReportSearchFilters = {};
|
||||
if (data.reporter_id !== undefined) {
|
||||
filters['reporterId'] = data.reporter_id.toString();
|
||||
filters.reporterId = data.reporter_id.toString();
|
||||
}
|
||||
if (data.status !== undefined) {
|
||||
filters['status'] = data.status;
|
||||
filters.status = data.status;
|
||||
}
|
||||
if (data.report_type !== undefined) {
|
||||
filters['reportType'] = data.report_type;
|
||||
filters.reportType = data.report_type;
|
||||
}
|
||||
if (data.category !== undefined) {
|
||||
filters['category'] = data.category;
|
||||
filters.category = data.category;
|
||||
}
|
||||
if (data.reason !== undefined) {
|
||||
filters.reason = data.reason;
|
||||
}
|
||||
if (data.reported_user_id !== undefined) {
|
||||
filters['reportedUserId'] = data.reported_user_id.toString();
|
||||
filters.reportedUserId = data.reported_user_id.toString();
|
||||
}
|
||||
if (data.reported_webhook_id !== undefined) {
|
||||
filters.reportedWebhookId = data.reported_webhook_id.toString();
|
||||
}
|
||||
if (data.reported_guild_id !== undefined) {
|
||||
filters['reportedGuildId'] = data.reported_guild_id.toString();
|
||||
filters.reportedGuildId = data.reported_guild_id.toString();
|
||||
}
|
||||
if (data.reported_channel_id !== undefined) {
|
||||
filters['reportedChannelId'] = data.reported_channel_id.toString();
|
||||
filters.reportedChannelId = data.reported_channel_id.toString();
|
||||
}
|
||||
if (data.guild_context_id !== undefined) {
|
||||
filters['guildContextId'] = data.guild_context_id.toString();
|
||||
filters.guildContextId = data.guild_context_id.toString();
|
||||
}
|
||||
if (data.resolved_by_admin_id !== undefined) {
|
||||
filters['resolvedByAdminId'] = data.resolved_by_admin_id.toString();
|
||||
filters.resolvedByAdminId = data.resolved_by_admin_id.toString();
|
||||
}
|
||||
if (data.sort_by) {
|
||||
filters['sortBy'] = data.sort_by;
|
||||
filters.sortBy = data.sort_by;
|
||||
}
|
||||
if (data.sort_order) {
|
||||
filters['sortOrder'] = data.sort_order;
|
||||
filters.sortOrder = data.sort_order;
|
||||
}
|
||||
const {hits, total} = await reportSearchService.searchReports(data.query || '', filters, {
|
||||
limit: data.limit,
|
||||
offset: data.offset,
|
||||
});
|
||||
const requestCache = createRequestCache();
|
||||
const reportNsfwLookupCache = createReportNsfwLookupCache();
|
||||
const orderedReports = await this.loadReportsInSearchOrder(hits.map((hit) => createReportID(BigInt(hit.id))));
|
||||
const reportLookupCache = createReportLookupCache();
|
||||
const reportIds = hits.map((hit) => createReportID(BigInt(hit.id)));
|
||||
const loaded = await this.loadReportsInSearchOrder(reportIds);
|
||||
const orderedReports = loaded.filter((report): report is IARSubmission => report !== null);
|
||||
const orphanedReportIds = reportIds.filter((_, index) => loaded[index] === null).map((id) => id.toString());
|
||||
if (orphanedReportIds.length > 0) {
|
||||
Logger.warn(
|
||||
{orphanedReportIds},
|
||||
'Report search index lists reports that are no longer stored, run refresh_search_index reports',
|
||||
);
|
||||
}
|
||||
const reports = await Promise.all(
|
||||
orderedReports.map((report) =>
|
||||
this.mapReportToResponse(report, false, requestCache, acls, reportNsfwLookupCache),
|
||||
),
|
||||
orderedReports.map((report) => this.mapReportToResponse(report, false, requestCache, acls, reportLookupCache)),
|
||||
);
|
||||
const missingCount = hits.length - orderedReports.length;
|
||||
return {
|
||||
reports,
|
||||
total: Math.max(0, total - missingCount),
|
||||
total,
|
||||
offset: data.offset,
|
||||
limit: data.limit,
|
||||
};
|
||||
}
|
||||
|
||||
private async loadReportsInSearchOrder(reportIds: Array<ReportID>): Promise<Array<IARSubmission>> {
|
||||
const reports = await Promise.all(
|
||||
private loadReportsInSearchOrder(reportIds: Array<ReportID>): Promise<Array<IARSubmission | null>> {
|
||||
return Promise.all(
|
||||
reportIds.map(async (reportId) => {
|
||||
try {
|
||||
return await this.deps.reportService.getReport(reportId);
|
||||
} catch (_error) {
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (error instanceof UnknownReportError) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return reports.filter((report): report is IARSubmission => report !== null);
|
||||
}
|
||||
|
||||
private async mapReportToResponse(
|
||||
@@ -304,7 +300,7 @@ export class AdminReportService {
|
||||
includeContext: boolean,
|
||||
requestCache: RequestCache,
|
||||
acls: ReadonlySet<string>,
|
||||
reportNsfwLookupCache: ReportNsfwLookupCache,
|
||||
reportLookupCache: ReportLookupCache,
|
||||
) {
|
||||
const reporterInfo = await this.buildUserTag(report.reporterId, requestCache);
|
||||
const reportedUserInfo = await this.buildUserTag(report.reportedUserId, requestCache);
|
||||
@@ -317,12 +313,12 @@ export class AdminReportService {
|
||||
: await this.getGuildNsfwLevelForContext(
|
||||
report.reportedChannelId,
|
||||
report.reportedGuildId ?? report.guildContextId ?? null,
|
||||
reportNsfwLookupCache,
|
||||
reportLookupCache,
|
||||
);
|
||||
const reportedChannelNsfw =
|
||||
report.reportedChannelEffectiveNsfw !== null
|
||||
? report.reportedChannelEffectiveNsfw
|
||||
: await this.getChannelNsfwState(report.reportedChannelId, reportNsfwLookupCache);
|
||||
: await this.getChannelNsfwState(report.reportedChannelId, reportLookupCache);
|
||||
const baseResponse = {
|
||||
report_id: report.reportId.toString(),
|
||||
reporter_id: report.reporterId?.toString() ?? null,
|
||||
@@ -330,13 +326,14 @@ export class AdminReportService {
|
||||
reporter_username: reporterInfo?.username ?? null,
|
||||
reporter_global_name: reporterInfo?.global_name ?? null,
|
||||
reporter_discriminator: reporterInfo?.discriminator ?? null,
|
||||
reporter_email: canViewReporterPii ? report.reporterEmail : null,
|
||||
reporter_email: canViewReporterPii ? await this.getReporterEmail(report, reportLookupCache) : null,
|
||||
reporter_full_legal_name: canViewReporterPii ? report.reporterFullLegalName : null,
|
||||
reporter_country_of_residence: canViewReporterPii ? report.reporterCountryOfResidence : null,
|
||||
reported_at: report.reportedAt.toISOString(),
|
||||
status: report.status,
|
||||
report_type: report.reportType,
|
||||
category: report.category,
|
||||
...mapReportReasonFields(report),
|
||||
additional_info: report.additionalInfo,
|
||||
reported_user_id: report.reportedUserId?.toString() ?? null,
|
||||
reported_user_tag: reportedUserInfo?.tag ?? null,
|
||||
@@ -344,6 +341,18 @@ export class AdminReportService {
|
||||
reported_user_global_name: reportedUserInfo?.global_name ?? null,
|
||||
reported_user_discriminator: reportedUserInfo?.discriminator ?? null,
|
||||
reported_user_avatar_hash: report.reportedUserAvatarHash,
|
||||
reported_user_bot: reportedUserInfo?.bot ?? null,
|
||||
reported_webhook_id: report.reportedWebhookId?.toString() ?? null,
|
||||
reported_webhook_name: report.reportedWebhookName,
|
||||
reported_webhook_avatar_hash: report.reportedWebhookAvatarHash,
|
||||
reported_webhook_default_name: report.reportedWebhookDefaultName,
|
||||
reported_webhook_default_avatar_hash: report.reportedWebhookDefaultAvatarHash,
|
||||
reported_webhook_type: report.reportedWebhookType,
|
||||
reported_webhook_application_id: report.reportedWebhookApplicationId?.toString() ?? null,
|
||||
reported_webhook_channel_id: report.reportedWebhookChannelId?.toString() ?? null,
|
||||
reported_webhook_guild_id: report.reportedWebhookGuildId?.toString() ?? null,
|
||||
reported_webhook_created_at: report.reportedWebhookCreatedAt?.toISOString() ?? null,
|
||||
...mapWebhookCreatorFields(report),
|
||||
reported_guild_id: report.reportedGuildId?.toString() ?? null,
|
||||
reported_guild_name: report.reportedGuildName,
|
||||
reported_guild_icon_hash: report.reportedGuildIconHash,
|
||||
@@ -371,21 +380,22 @@ export class AdminReportService {
|
||||
}
|
||||
const attachmentStatusesById = await this.getAttachmentStatusesById(report);
|
||||
const priorReportsByAuthor = await this.getPriorReportsForContext(report);
|
||||
const messageContext =
|
||||
report.messageContext && report.messageContext.length > 0
|
||||
? await Promise.all(
|
||||
report.messageContext.map((message) =>
|
||||
this.mapReportMessageContextToResponse(
|
||||
message,
|
||||
report.reportedChannelId ?? null,
|
||||
report.reportedGuildId ?? report.guildContextId ?? null,
|
||||
reportNsfwLookupCache,
|
||||
attachmentStatusesById,
|
||||
priorReportsByAuthor,
|
||||
),
|
||||
),
|
||||
)
|
||||
: [];
|
||||
const authorBotFlags = await this.getContextAuthorBotFlags(report, requestCache);
|
||||
const messageContext = (
|
||||
await Promise.all(
|
||||
(report.messageContext ?? []).map((message) =>
|
||||
this.mapReportMessageContextToResponse(
|
||||
message,
|
||||
report.reportedChannelId ?? null,
|
||||
report.reportedGuildId ?? report.guildContextId ?? null,
|
||||
reportLookupCache,
|
||||
attachmentStatusesById,
|
||||
priorReportsByAuthor,
|
||||
authorBotFlags,
|
||||
),
|
||||
),
|
||||
)
|
||||
).filter((message) => message !== null);
|
||||
const messageResponses = await this.getLiveMessageResponsesForContext(report);
|
||||
const mutualDmChannelId = await this.getMutualDmChannelId(report);
|
||||
return {
|
||||
@@ -393,9 +403,117 @@ export class AdminReportService {
|
||||
mutual_dm_channel_id: mutualDmChannelId,
|
||||
message_context: messageContext,
|
||||
message_responses: messageResponses,
|
||||
reported_profile_snapshot: await this.mapProfileSnapshotToResponse(report),
|
||||
legal_hold_until: report.legalHoldUntil?.toISOString() ?? null,
|
||||
legal_hold_reason: report.legalHoldReason,
|
||||
};
|
||||
}
|
||||
|
||||
private getReporterEmail(report: IARSubmission, reportLookupCache: ReportLookupCache): Promise<string | null> {
|
||||
const reporterId = report.reporterId;
|
||||
if (!reporterId) {
|
||||
return Promise.resolve(report.reporterEmail);
|
||||
}
|
||||
const key = reporterId.toString();
|
||||
const cached = reportLookupCache.reporterEmailByReporterId.get(key);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const email = this.deps.apiContext.services.users.findUnique(reporterId).then((user) => user?.email ?? null);
|
||||
reportLookupCache.reporterEmailByReporterId.set(key, email);
|
||||
return email;
|
||||
}
|
||||
|
||||
private async getContextAuthorBotFlags(
|
||||
report: IARSubmission,
|
||||
requestCache: RequestCache,
|
||||
): Promise<Map<string, boolean | null>> {
|
||||
const authorIds = [
|
||||
...new Set((report.messageContext ?? []).flatMap((message) => (message.authorId ? [message.authorId] : []))),
|
||||
];
|
||||
if (authorIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
try {
|
||||
const partials = await this.deps.userCacheService.getUserPartialResponses(authorIds, requestCache);
|
||||
return new Map([...partials].map(([userId, partial]) => [userId.toString(), mapUserPartialBotFlag(partial)]));
|
||||
} catch (error) {
|
||||
Logger.warn({reportId: report.reportId.toString(), error}, 'Failed to resolve author bot flags for report');
|
||||
return new Map();
|
||||
}
|
||||
}
|
||||
|
||||
private async mapProfileSnapshotToResponse(report: IARSubmission) {
|
||||
const snapshot = report.reportedProfileSnapshot;
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
const asset = (value: ReportProfileAssetSnapshot | null) => this.mapProfileSnapshotAsset(report.reportId, value);
|
||||
const {user, member, guild} = snapshot;
|
||||
return {
|
||||
captured_at: snapshot.captured_at,
|
||||
user: user
|
||||
? {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
discriminator: user.discriminator === null ? null : user.discriminator.toString().padStart(4, '0'),
|
||||
global_name: user.global_name,
|
||||
bio: user.bio,
|
||||
pronouns: user.pronouns,
|
||||
avatar: await asset(user.avatar),
|
||||
banner: await asset(user.banner),
|
||||
}
|
||||
: null,
|
||||
member: member
|
||||
? {
|
||||
guild_id: member.guild_id,
|
||||
nick: member.nick,
|
||||
bio: member.bio,
|
||||
pronouns: member.pronouns,
|
||||
joined_at: member.joined_at,
|
||||
avatar: await asset(member.avatar),
|
||||
banner: await asset(member.banner),
|
||||
}
|
||||
: null,
|
||||
guild: guild
|
||||
? {
|
||||
id: guild.id,
|
||||
name: guild.name,
|
||||
vanity_url_code: guild.vanity_url_code,
|
||||
icon: await asset(guild.icon),
|
||||
banner: await asset(guild.banner),
|
||||
splash: await asset(guild.splash),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async mapProfileSnapshotAsset(
|
||||
reportId: ReportID,
|
||||
asset: ReportProfileAssetSnapshot | null,
|
||||
): Promise<{hash: string; url: string | null} | null> {
|
||||
if (!asset) {
|
||||
return null;
|
||||
}
|
||||
if (!asset.key) {
|
||||
return {hash: asset.hash, url: null};
|
||||
}
|
||||
try {
|
||||
const url = await this.deps.storageService.getPresignedDownloadURL({
|
||||
bucket: Config.s3.buckets.reports,
|
||||
key: asset.key,
|
||||
expiresIn: seconds('5 minutes'),
|
||||
});
|
||||
return {hash: asset.hash, url};
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
{error, reportId: reportId.toString(), key: asset.key},
|
||||
'Failed to generate presigned URL for report profile asset',
|
||||
);
|
||||
return {hash: asset.hash, url: null};
|
||||
}
|
||||
}
|
||||
|
||||
private async getLiveMessageResponsesForContext(report: IARSubmission): Promise<Array<MessageResponse>> {
|
||||
const contexts = report.messageContext ?? [];
|
||||
if (contexts.length === 0) return [];
|
||||
@@ -447,13 +565,18 @@ export class AdminReportService {
|
||||
message: IARMessageContext,
|
||||
fallbackChannelId: ChannelID | null,
|
||||
fallbackGuildId: GuildID | null,
|
||||
reportNsfwLookupCache: ReportNsfwLookupCache,
|
||||
reportLookupCache: ReportLookupCache,
|
||||
attachmentStatusesById: Map<string, NcmecAttachmentStatusResponse>,
|
||||
priorReportsByAuthor: Map<string, Array<string>>,
|
||||
authorBotFlags: Map<string, boolean | null>,
|
||||
) {
|
||||
const channelId = message.channelId ?? fallbackChannelId;
|
||||
const channelNsfw = await this.getChannelNsfwState(channelId, reportNsfwLookupCache);
|
||||
const guildNsfwLevel = await this.getGuildNsfwLevelForContext(channelId, fallbackGuildId, reportNsfwLookupCache);
|
||||
if (!channelId) {
|
||||
return null;
|
||||
}
|
||||
const authorId = (message.authorId ?? message.webhookId ?? 0n).toString();
|
||||
const channelNsfw = await this.getChannelNsfwState(channelId, reportLookupCache);
|
||||
const guildNsfwLevel = await this.getGuildNsfwLevelForContext(channelId, fallbackGuildId, reportLookupCache);
|
||||
const attachments =
|
||||
message.attachments && message.attachments.length > 0
|
||||
? (
|
||||
@@ -482,7 +605,7 @@ export class AdminReportService {
|
||||
: [];
|
||||
return {
|
||||
id: message.messageId.toString(),
|
||||
channel_id: channelId ? channelId.toString() : '',
|
||||
channel_id: channelId.toString(),
|
||||
channel_nsfw: channelNsfw,
|
||||
channel_content_warning_level: null,
|
||||
channel_content_warning_text: null,
|
||||
@@ -494,19 +617,24 @@ export class AdminReportService {
|
||||
content: message.content ?? '',
|
||||
timestamp: message.timestamp.toISOString(),
|
||||
attachments,
|
||||
author_id: message.authorId.toString(),
|
||||
author_id: authorId,
|
||||
author_username: message.authorUsername,
|
||||
author_global_name: null,
|
||||
author_discriminator: message.authorDiscriminator.toString().padStart(4, '0'),
|
||||
author_avatar: message.authorAvatarHash,
|
||||
user_prior_ncmec_report_ids: priorReportsByAuthor.get(message.authorId.toString()) ?? [],
|
||||
webhook_id: message.webhookId?.toString() ?? null,
|
||||
author_bot: message.authorId ? (authorBotFlags.get(authorId) ?? null) : null,
|
||||
missing_attachments: message.missingAttachments
|
||||
.filter((attachment) => attachment.attachment_id != null && attachment.filename)
|
||||
.map(mapMissingAttachmentToResponse),
|
||||
user_prior_ncmec_report_ids: message.authorId ? (priorReportsByAuthor.get(authorId) ?? []) : [],
|
||||
};
|
||||
}
|
||||
|
||||
private async getPriorReportsForContext(report: IARSubmission): Promise<Map<string, Array<string>>> {
|
||||
const authorIds = new Set<string>();
|
||||
for (const message of report.messageContext ?? []) {
|
||||
authorIds.add(message.authorId.toString());
|
||||
if (message.authorId) authorIds.add(message.authorId.toString());
|
||||
}
|
||||
if (report.reportedUserId) authorIds.add(report.reportedUserId.toString());
|
||||
if (authorIds.size === 0) return new Map();
|
||||
@@ -516,48 +644,48 @@ export class AdminReportService {
|
||||
|
||||
private async getChannelNsfwState(
|
||||
channelId: ChannelID | null,
|
||||
reportNsfwLookupCache: ReportNsfwLookupCache,
|
||||
reportLookupCache: ReportLookupCache,
|
||||
): Promise<boolean | null> {
|
||||
if (!channelId) {
|
||||
return null;
|
||||
}
|
||||
const channelIdString = channelId.toString();
|
||||
if (reportNsfwLookupCache.channelNsfwByChannelId.has(channelIdString)) {
|
||||
return reportNsfwLookupCache.channelNsfwByChannelId.get(channelIdString) ?? null;
|
||||
if (reportLookupCache.channelNsfwByChannelId.has(channelIdString)) {
|
||||
return reportLookupCache.channelNsfwByChannelId.get(channelIdString) ?? null;
|
||||
}
|
||||
const channel = await this.deps.channelRepository.findUnique(channelId);
|
||||
const scope = channel
|
||||
? await resolveNsfwScopeChannel(channel, (id) => this.deps.channelRepository.findUnique(id))
|
||||
: null;
|
||||
const channelNsfw = scope?.isNsfw ?? null;
|
||||
reportNsfwLookupCache.channelNsfwByChannelId.set(channelIdString, channelNsfw);
|
||||
reportLookupCache.channelNsfwByChannelId.set(channelIdString, channelNsfw);
|
||||
return channelNsfw;
|
||||
}
|
||||
|
||||
private async getGuildNsfwLevel(
|
||||
guildId: GuildID | null,
|
||||
reportNsfwLookupCache: ReportNsfwLookupCache,
|
||||
reportLookupCache: ReportLookupCache,
|
||||
): Promise<number | null> {
|
||||
if (!guildId) {
|
||||
return null;
|
||||
}
|
||||
const guildIdString = guildId.toString();
|
||||
if (reportNsfwLookupCache.guildNsfwLevelByGuildId.has(guildIdString)) {
|
||||
return reportNsfwLookupCache.guildNsfwLevelByGuildId.get(guildIdString) ?? null;
|
||||
if (reportLookupCache.guildNsfwLevelByGuildId.has(guildIdString)) {
|
||||
return reportLookupCache.guildNsfwLevelByGuildId.get(guildIdString) ?? null;
|
||||
}
|
||||
const guild = await this.deps.guildRepository.findUnique(guildId);
|
||||
const guildNsfwLevel = guild?.nsfwLevel ?? null;
|
||||
reportNsfwLookupCache.guildNsfwLevelByGuildId.set(guildIdString, guildNsfwLevel);
|
||||
reportLookupCache.guildNsfwLevelByGuildId.set(guildIdString, guildNsfwLevel);
|
||||
return guildNsfwLevel;
|
||||
}
|
||||
|
||||
private async getGuildNsfwLevelForContext(
|
||||
channelId: ChannelID | null,
|
||||
fallbackGuildId: GuildID | null,
|
||||
reportNsfwLookupCache: ReportNsfwLookupCache,
|
||||
reportLookupCache: ReportLookupCache,
|
||||
): Promise<number | null> {
|
||||
if (fallbackGuildId) {
|
||||
return this.getGuildNsfwLevel(fallbackGuildId, reportNsfwLookupCache);
|
||||
return this.getGuildNsfwLevel(fallbackGuildId, reportLookupCache);
|
||||
}
|
||||
if (!channelId) {
|
||||
return null;
|
||||
@@ -566,7 +694,7 @@ export class AdminReportService {
|
||||
if (!channel?.guildId) {
|
||||
return null;
|
||||
}
|
||||
return this.getGuildNsfwLevel(channel.guildId, reportNsfwLookupCache);
|
||||
return this.getGuildNsfwLevel(channel.guildId, reportLookupCache);
|
||||
}
|
||||
|
||||
private async mapReportAttachmentToResponse(
|
||||
@@ -637,6 +765,7 @@ export class AdminReportService {
|
||||
const stored = isHiddenPartial(cached) ? await this.deps.apiContext.services.users.findUnique(userId) : null;
|
||||
const user = stored
|
||||
? {
|
||||
...cached,
|
||||
username: stored.username,
|
||||
global_name: stored.globalName,
|
||||
discriminator: stored.discriminator.toString(),
|
||||
@@ -653,6 +782,7 @@ export class AdminReportService {
|
||||
username: user.username,
|
||||
global_name: user.global_name ?? null,
|
||||
discriminator,
|
||||
bot: mapUserPartialBotFlag(user),
|
||||
};
|
||||
} catch (error) {
|
||||
Logger.warn({userId: userId.toString(), error}, 'Failed to resolve user tag for report');
|
||||
@@ -661,9 +791,32 @@ export class AdminReportService {
|
||||
}
|
||||
}
|
||||
|
||||
function mapWebhookCreatorFields(report: IARSubmission) {
|
||||
const creatorDiscriminator = report.reportedWebhookCreatorDiscriminator;
|
||||
const discriminator = creatorDiscriminator === null ? null : creatorDiscriminator.toString().padStart(4, '0');
|
||||
const username = report.reportedWebhookCreatorUsername;
|
||||
const creatorId = report.reportedWebhookCreatorId;
|
||||
return {
|
||||
reported_webhook_creator_id: creatorId?.toString() ?? null,
|
||||
reported_webhook_creator_tag:
|
||||
username !== null && creatorDiscriminator !== null
|
||||
? formatUserTag({
|
||||
username,
|
||||
discriminator: creatorDiscriminator,
|
||||
isBot: creatorId !== null && creatorId.toString() === report.reportedWebhookApplicationId?.toString(),
|
||||
})
|
||||
: null,
|
||||
reported_webhook_creator_username: username,
|
||||
reported_webhook_creator_global_name: report.reportedWebhookCreatorGlobalName,
|
||||
reported_webhook_creator_discriminator: discriminator,
|
||||
reported_webhook_creator_avatar_hash: report.reportedWebhookCreatorAvatarHash,
|
||||
};
|
||||
}
|
||||
|
||||
interface UserTagInfo {
|
||||
tag: string;
|
||||
username: string;
|
||||
global_name: string | null;
|
||||
discriminator: string;
|
||||
bot: boolean | null;
|
||||
}
|
||||
@@ -6,16 +6,16 @@ import type {AdminAuditService} from '@app/api/admin/services/AdminAuditService'
|
||||
import type {AdminBanManagementService} from '@app/api/admin/services/AdminBanManagementService';
|
||||
import {trySendAdminNotification} from '@app/api/admin/services/AdminNotification';
|
||||
import type {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserUpdatePropagator';
|
||||
import {type ReporterResolutionNotifier, wasReporterNotified} from '@app/api/admin/services/ReporterResolutionNotifier';
|
||||
import * as AuthSession from '@app/api/auth/AuthSession';
|
||||
import {createReportID, createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import type {BillingRepository} from '@app/api/billing/repositories/BillingRepository';
|
||||
import type {NcmecRepository} from '@app/api/csam/NcmecRepository';
|
||||
import {emitAdminAction} from '@app/api/infrastructure/activity/AccountChangeEvents';
|
||||
import type {KVAccountDeletionQueueService} from '@app/api/infrastructure/KVAccountDeletionQueueService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {OAuth2TokenRepository} from '@app/api/oauth/repositories/OAuth2TokenRepository';
|
||||
import {ReportStatus} from '@app/api/report/IReportRepository';
|
||||
import {type IARSubmission, ReportStatus} from '@app/api/report/IReportRepository';
|
||||
import type {ReportService} from '@app/api/report/ReportService';
|
||||
import {getReportSearchService} from '@app/api/SearchFactory';
|
||||
import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService';
|
||||
@@ -46,7 +46,17 @@ interface AdminUserDeletionServiceDeps {
|
||||
billingRepository: BillingRepository;
|
||||
oauth2Tokens: Pick<OAuth2TokenRepository, 'deleteAllAccessTokensForUser' | 'deleteAllRefreshTokensForUser'>;
|
||||
storeEntitlementService: StoreEntitlementService;
|
||||
ncmecRepository: Pick<NcmecRepository, 'getUserWorkflow'>;
|
||||
reporterResolutionNotifier: ReporterResolutionNotifier;
|
||||
}
|
||||
|
||||
type AutoResolveOutcome = 'completed' | 'search_unavailable' | 'enumeration_failed';
|
||||
|
||||
interface AutoResolveSummary {
|
||||
outcome: AutoResolveOutcome;
|
||||
foundCount: number;
|
||||
resolvedCount: number;
|
||||
failedCount: number;
|
||||
notifiedCount: number;
|
||||
}
|
||||
|
||||
const minUserRequestedDeletionDays = 14;
|
||||
@@ -409,22 +419,27 @@ export class AdminUserDeletionService {
|
||||
}): Promise<void> {
|
||||
const {user, adminUserId, reasonCode} = params;
|
||||
const outcome = isEnforcementDeletionReason(reasonCode) ? 'actioned' : 'auto_resolved';
|
||||
const {reportService, auditService, ncmecRepository} = this.deps;
|
||||
if (await ncmecRepository.getUserWorkflow(user.id)) {
|
||||
return;
|
||||
}
|
||||
const {reportService, reporterResolutionNotifier} = this.deps;
|
||||
const auditLogReason = 'auto-resolved on scheduled deletion of reported user';
|
||||
const summary: AutoResolveSummary = {
|
||||
outcome: 'completed',
|
||||
foundCount: 0,
|
||||
resolvedCount: 0,
|
||||
failedCount: 0,
|
||||
notifiedCount: 0,
|
||||
};
|
||||
const reportSearchService = getReportSearchService();
|
||||
if (!reportSearchService) {
|
||||
Logger.warn(
|
||||
{userId: user.id.toString()},
|
||||
'Report search is unavailable; pending reports were not auto-resolved on scheduled deletion',
|
||||
);
|
||||
summary.outcome = 'search_unavailable';
|
||||
await this.recordAutoResolveSummary({user, adminUserId, auditLogReason, summary});
|
||||
return;
|
||||
}
|
||||
const auditLogReason = 'auto-resolved on scheduled deletion of reported user';
|
||||
const pageSize = 100;
|
||||
const pendingReportIds = new Set<string>();
|
||||
let resolvedCount = 0;
|
||||
let offset = 0;
|
||||
try {
|
||||
while (true) {
|
||||
@@ -445,43 +460,66 @@ export class AdminUserDeletionService {
|
||||
if (hits.length < pageSize) break;
|
||||
}
|
||||
} catch (error) {
|
||||
summary.outcome = 'enumeration_failed';
|
||||
Logger.warn(
|
||||
{error, userId: user.id.toString()},
|
||||
'Failed to enumerate pending reports for auto-resolution on scheduled deletion',
|
||||
);
|
||||
}
|
||||
summary.foundCount = pendingReportIds.size;
|
||||
for (const hitId of pendingReportIds) {
|
||||
const reportId = createReportID(BigInt(hitId));
|
||||
let resolvedReport: IARSubmission;
|
||||
try {
|
||||
await reportService.resolveReport(reportId, adminUserId, null, auditLogReason, {
|
||||
resolvedReport = await reportService.resolveReport(reportId, adminUserId, null, auditLogReason, {
|
||||
outcome,
|
||||
resolvedBy: 'system',
|
||||
});
|
||||
resolvedCount++;
|
||||
} catch (error) {
|
||||
if (error instanceof ReportAlreadyResolvedError) continue;
|
||||
summary.failedCount++;
|
||||
Logger.warn(
|
||||
{error, userId: user.id.toString(), reportId: reportId.toString()},
|
||||
'Failed to auto-resolve report on scheduled deletion',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
summary.resolvedCount++;
|
||||
const notice = await reporterResolutionNotifier.notifyReporterOfResolution(resolvedReport, null);
|
||||
if (wasReporterNotified(notice)) {
|
||||
summary.notifiedCount++;
|
||||
}
|
||||
}
|
||||
if (resolvedCount > 0) {
|
||||
await auditService
|
||||
.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(user.id),
|
||||
action: 'auto_resolve_reports_on_deletion',
|
||||
auditLogReason,
|
||||
metadata: new Map([['resolved_count', resolvedCount.toString()]]),
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.warn(
|
||||
{error, userId: user.id.toString(), resolvedCount},
|
||||
'Failed to write audit log for auto-resolved reports on scheduled deletion',
|
||||
);
|
||||
});
|
||||
}
|
||||
await this.recordAutoResolveSummary({user, adminUserId, auditLogReason, summary});
|
||||
}
|
||||
|
||||
private async recordAutoResolveSummary(params: {
|
||||
user: User;
|
||||
adminUserId: UserID;
|
||||
auditLogReason: string;
|
||||
summary: AutoResolveSummary;
|
||||
}): Promise<void> {
|
||||
const {user, adminUserId, auditLogReason, summary} = params;
|
||||
await this.deps.auditService
|
||||
.createAuditLog({
|
||||
adminUserId,
|
||||
targetType: 'user',
|
||||
targetId: BigInt(user.id),
|
||||
action: 'auto_resolve_reports_on_deletion',
|
||||
auditLogReason,
|
||||
metadata: new Map([
|
||||
['outcome', summary.outcome],
|
||||
['found_count', summary.foundCount.toString()],
|
||||
['resolved_count', summary.resolvedCount.toString()],
|
||||
['failed_count', summary.failedCount.toString()],
|
||||
['notified_count', summary.notifiedCount.toString()],
|
||||
]),
|
||||
})
|
||||
.catch((error) => {
|
||||
Logger.warn(
|
||||
{error, userId: user.id.toString(), ...summary},
|
||||
'Failed to write audit log for auto-resolved reports on scheduled deletion',
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,9 @@ import {AdminUserLookupService} from '@app/api/admin/services/AdminUserLookupSer
|
||||
import {AdminUserProfileService} from '@app/api/admin/services/AdminUserProfileService';
|
||||
import {AdminUserSecurityService} from '@app/api/admin/services/AdminUserSecurityService';
|
||||
import {AdminUserUpdatePropagator} from '@app/api/admin/services/AdminUserUpdatePropagator';
|
||||
import {ReporterResolutionNotifier} from '@app/api/admin/services/ReporterResolutionNotifier';
|
||||
import {createChannelID, createUserID, type UserID} from '@app/api/BrandedTypes';
|
||||
import type {IChannelRepository} from '@app/api/channel/IChannelRepository';
|
||||
import {NcmecRepository} from '@app/api/csam/NcmecRepository';
|
||||
import type {IGuildRepositoryAggregate} from '@app/api/guild/repositories/IGuildRepositoryAggregate';
|
||||
import type {IDiscriminatorService} from '@app/api/infrastructure/DiscriminatorService';
|
||||
import type {EntityAssetService} from '@app/api/infrastructure/EntityAssetService';
|
||||
@@ -50,6 +50,7 @@ interface AdminUserServiceDeps {
|
||||
stripe: Stripe | null;
|
||||
reportService: ReportService;
|
||||
storeEntitlementService: StoreEntitlementService;
|
||||
reporterResolutionNotifier?: ReporterResolutionNotifier;
|
||||
}
|
||||
|
||||
export class AdminUserService {
|
||||
@@ -113,7 +114,9 @@ export class AdminUserService {
|
||||
billingRepository: getBillingRepository(),
|
||||
oauth2Tokens: new OAuth2TokenRepository(),
|
||||
storeEntitlementService: deps.storeEntitlementService,
|
||||
ncmecRepository: new NcmecRepository(),
|
||||
reporterResolutionNotifier:
|
||||
deps.reporterResolutionNotifier ??
|
||||
new ReporterResolutionNotifier({apiContext: deps.apiContext, systemDm: null}),
|
||||
});
|
||||
this.contactChangeLogService = contactChangeLog;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {ApiContext} from '@app/api/ApiContext';
|
||||
import {trySendAdminNotification} from '@app/api/admin/services/AdminNotification';
|
||||
import type {UserID} from '@app/api/BrandedTypes';
|
||||
import type {ChannelService} from '@app/api/channel/services/ChannelService';
|
||||
import {SYSTEM_USER_ID} from '@app/api/constants/Core';
|
||||
import {SYSTEM_THREAD_VIEWER} from '@app/api/experiment/ChannelThreadsGate';
|
||||
import type {UserCacheService} from '@app/api/infrastructure/UserCacheService';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {createRequestCache} from '@app/api/middleware/RequestCacheMiddleware';
|
||||
import type {User} from '@app/api/models/User';
|
||||
import type {IARSubmission} from '@app/api/report/IReportRepository';
|
||||
import type {UserChannelService} from '@app/api/user/services/UserChannelService';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {getEmailTemplate} from '@pkgs/email/src/email_i18n/EmailI18n';
|
||||
|
||||
interface ReporterSystemDmDeps {
|
||||
channelService: ChannelService;
|
||||
userChannelService: UserChannelService;
|
||||
userCacheService: UserCacheService;
|
||||
}
|
||||
|
||||
interface ReporterResolutionNotifierDeps {
|
||||
apiContext: ApiContext;
|
||||
systemDm: ReporterSystemDmDeps | null;
|
||||
}
|
||||
|
||||
type ReporterNoticeSkipReason =
|
||||
| 'reporter_account_deleted'
|
||||
| 'reporter_report_banned'
|
||||
| 'reporter_lookup_failed'
|
||||
| 'no_reporter_email';
|
||||
|
||||
interface ReporterNoticeResult {
|
||||
dmSent: boolean;
|
||||
emailSent: boolean;
|
||||
dsaEmailSent: boolean;
|
||||
skipped: ReporterNoticeSkipReason | null;
|
||||
}
|
||||
|
||||
function skippedNotice(reason: ReporterNoticeSkipReason): ReporterNoticeResult {
|
||||
return {dmSent: false, emailSent: false, dsaEmailSent: false, skipped: reason};
|
||||
}
|
||||
|
||||
export function wasReporterNotified(result: ReporterNoticeResult): boolean {
|
||||
return result.dmSent || result.emailSent || result.dsaEmailSent;
|
||||
}
|
||||
|
||||
export function describeReporterNotice(result: ReporterNoticeResult): Array<[string, string]> {
|
||||
return [
|
||||
['reporter_dm_sent', result.dmSent ? 'true' : 'false'],
|
||||
['reporter_email_sent', result.emailSent ? 'true' : 'false'],
|
||||
['reporter_dsa_email_sent', result.dsaEmailSent ? 'true' : 'false'],
|
||||
...(result.skipped ? [['reporter_notice_skipped', result.skipped] as [string, string]] : []),
|
||||
];
|
||||
}
|
||||
|
||||
export class ReporterResolutionNotifier {
|
||||
constructor(private readonly deps: ReporterResolutionNotifierDeps) {}
|
||||
|
||||
async notifyReporterOfResolution(report: IARSubmission, publicComment: string | null): Promise<ReporterNoticeResult> {
|
||||
const comment = publicComment ?? '';
|
||||
if (report.reporterId) {
|
||||
return this.notifyAccountReporter(report, report.reporterId, comment);
|
||||
}
|
||||
return this.notifyDsaReporter(report, comment);
|
||||
}
|
||||
|
||||
private async notifyAccountReporter(
|
||||
report: IARSubmission,
|
||||
reporterId: UserID,
|
||||
comment: string,
|
||||
): Promise<ReporterNoticeResult> {
|
||||
const {users: userRepository, email: emailService} = this.deps.apiContext.services;
|
||||
const reportId = report.reportId.toString();
|
||||
let reporter: User | null;
|
||||
try {
|
||||
reporter = await userRepository.findUnique(reporterId);
|
||||
} catch (error) {
|
||||
Logger.warn(
|
||||
{error, reportId, reporterId: reporterId.toString()},
|
||||
'Failed to load the reporter of a resolved report',
|
||||
);
|
||||
return skippedNotice('reporter_lookup_failed');
|
||||
}
|
||||
if (!reporter || (reporter.flags & UserFlags.DELETED) !== 0n) {
|
||||
return skippedNotice('reporter_account_deleted');
|
||||
}
|
||||
if ((reporter.flags & UserFlags.REPORT_BANNED) !== 0n) {
|
||||
return skippedNotice('reporter_report_banned');
|
||||
}
|
||||
const dmSent = await this.sendSystemDm(reporter, reportId, comment);
|
||||
const email = reporter.email;
|
||||
const emailSent = email
|
||||
? await trySendAdminNotification(
|
||||
() => emailService.sendReportResolvedEmail(email, reporter.username, reportId, comment, reporter.locale),
|
||||
{action: 'resolve_report', targetId: reportId},
|
||||
)
|
||||
: false;
|
||||
return {dmSent, emailSent, dsaEmailSent: false, skipped: null};
|
||||
}
|
||||
|
||||
private async notifyDsaReporter(report: IARSubmission, comment: string): Promise<ReporterNoticeResult> {
|
||||
const email = report.reporterEmail;
|
||||
if (!email) {
|
||||
return skippedNotice('no_reporter_email');
|
||||
}
|
||||
const {email: emailService} = this.deps.apiContext.services;
|
||||
const reportId = report.reportId.toString();
|
||||
const dsaEmailSent = await trySendAdminNotification(
|
||||
() => emailService.sendDsaReportResolvedEmail(email, reportId, comment, report.flowLocale),
|
||||
{action: 'resolve_report', targetId: reportId},
|
||||
);
|
||||
return {dmSent: false, emailSent: false, dsaEmailSent, skipped: null};
|
||||
}
|
||||
|
||||
private async sendSystemDm(reporter: User, reportId: string, comment: string): Promise<boolean> {
|
||||
const systemDm = this.deps.systemDm;
|
||||
if (!systemDm) {
|
||||
return false;
|
||||
}
|
||||
const template = getEmailTemplate('report_resolved', reporter.locale, {
|
||||
username: reporter.username,
|
||||
reportId,
|
||||
publicComment: comment,
|
||||
hasComment: comment ? 'yes' : 'no',
|
||||
});
|
||||
if (!template.ok) {
|
||||
Logger.warn(
|
||||
{reportId, reporterId: reporter.id.toString(), locale: reporter.locale, error: template.error},
|
||||
'Skipping report review system DM because the email template could not be resolved',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const {users: userRepository} = this.deps.apiContext.services;
|
||||
const requestCache = createRequestCache();
|
||||
try {
|
||||
const systemUser = await userRepository.findUniqueAssert(SYSTEM_USER_ID);
|
||||
const dmChannel = await systemDm.userChannelService.ensureDmOpenForBothUsers({
|
||||
userId: systemUser.id,
|
||||
recipientId: reporter.id,
|
||||
userCacheService: systemDm.userCacheService,
|
||||
requestCache,
|
||||
});
|
||||
await systemDm.channelService.messages.send.sendMessage({
|
||||
user: systemUser,
|
||||
viewer: SYSTEM_THREAD_VIEWER,
|
||||
channelId: dmChannel.id,
|
||||
data: {content: template.value.body},
|
||||
requestCache,
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
Logger.warn({reportId, reporterId: reporter.id.toString(), error}, 'Failed to send report review system DM');
|
||||
return false;
|
||||
} finally {
|
||||
requestCache.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
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 {ReportRepository} from '@app/api/report/ReportRepository';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
import {MockStorageService} from '@app/api/test/mocks/MockStorageService';
|
||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||
import {createBuilder} from '@app/api/test/TestRequestBuilder';
|
||||
import {processReportRetention} from '@app/api/worker/tasks/ExpireReportSnapshots';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import type {ReportLegalHoldResponse} from '@fluxer/schema/src/domains/admin/AdminReportLegalHoldSchemas';
|
||||
import {ms} from 'itty-time';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
interface ValidationErrorResponse {
|
||||
code: string;
|
||||
errors?: Array<{path: string; message: string}>;
|
||||
}
|
||||
|
||||
const HOLD_UNTIL = '2099-01-01T00:00:00.000Z';
|
||||
let sequence = 1_497_000_000_000_000_000n;
|
||||
|
||||
describe('report legal hold', () => {
|
||||
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(ageDays: number): Promise<ReportID> {
|
||||
sequence += 1n;
|
||||
await repository.createReport({
|
||||
...Object.fromEntries(IAR_SUBMISSION_COLUMNS.map((column) => [column, null])),
|
||||
report_id: sequence,
|
||||
reported_at: new Date(Date.now() - ageDays * ms('1 day')),
|
||||
status: 1,
|
||||
report_type: 1,
|
||||
category: 'harassment',
|
||||
} as IARSubmissionRow);
|
||||
return createReportID(sequence);
|
||||
}
|
||||
|
||||
function holdRequest(account: TestAccount, reportId: ReportID | string, body: unknown) {
|
||||
return createBuilder<ReportLegalHoldResponse & ValidationErrorResponse>(harness, account.token)
|
||||
.post(`/admin/reports/${reportId}/legal-hold`)
|
||||
.body(body);
|
||||
}
|
||||
|
||||
function reportDetail(reportId: ReportID) {
|
||||
return createBuilder<{legal_hold_until?: string | null; legal_hold_reason?: string | null}>(harness, admin.token)
|
||||
.get(`/admin/reports/${reportId}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function runRetention() {
|
||||
return processReportRetention(
|
||||
{reportRepository: repository, storageService: new MockStorageService(), reportSearchService: null},
|
||||
{now: new Date(), dryRun: false},
|
||||
);
|
||||
}
|
||||
|
||||
test('a hold keeps an expired report until it is cleared', async () => {
|
||||
const reportId = await seedReport(400);
|
||||
|
||||
const placed = await holdRequest(admin, reportId, {
|
||||
legal_hold_until: HOLD_UNTIL,
|
||||
legal_hold_reason: ' Preservation request 42 ',
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(placed).toEqual({
|
||||
report_id: reportId.toString(),
|
||||
legal_hold_until: HOLD_UNTIL,
|
||||
legal_hold_reason: 'Preservation request 42',
|
||||
});
|
||||
const stored = await repository.getReport(reportId);
|
||||
expect(stored?.legalHoldUntil?.toISOString()).toBe(HOLD_UNTIL);
|
||||
expect(stored?.legalHoldReason).toBe('Preservation request 42');
|
||||
expect(await reportDetail(reportId)).toMatchObject({
|
||||
legal_hold_until: HOLD_UNTIL,
|
||||
legal_hold_reason: 'Preservation request 42',
|
||||
});
|
||||
expect(await runRetention()).toMatchObject({held: 1, deleted: 0});
|
||||
expect(await repository.getReport(reportId)).not.toBeNull();
|
||||
|
||||
const cleared = await holdRequest(admin, reportId, {legal_hold_until: null, legal_hold_reason: 'ignored'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
|
||||
expect(cleared).toEqual({report_id: reportId.toString(), legal_hold_until: null, legal_hold_reason: null});
|
||||
expect(await reportDetail(reportId)).toMatchObject({legal_hold_until: null, legal_hold_reason: null});
|
||||
expect(await runRetention()).toMatchObject({held: 0, deleted: 1});
|
||||
expect(await repository.getReport(reportId)).toBeNull();
|
||||
});
|
||||
|
||||
test('a hold that ends in the past is refused', async () => {
|
||||
const reportId = await seedReport(10);
|
||||
|
||||
const response = await holdRequest(admin, reportId, {
|
||||
legal_hold_until: new Date(Date.now() - ms('1 minute')).toISOString(),
|
||||
legal_hold_reason: 'Preservation request',
|
||||
})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
|
||||
expect(response.errors?.[0]?.path).toBe('legal_hold_until');
|
||||
expect((await repository.getReport(reportId))?.legalHoldUntil).toBeNull();
|
||||
});
|
||||
|
||||
test.each([[{legal_hold_until: HOLD_UNTIL}], [{legal_hold_until: HOLD_UNTIL, legal_hold_reason: null}]])(
|
||||
'a hold without a reason is refused (%j)',
|
||||
async (body) => {
|
||||
const reportId = await seedReport(10);
|
||||
|
||||
const response = await holdRequest(admin, reportId, body)
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
|
||||
expect(response.errors?.[0]?.path).toBe('legal_hold_reason');
|
||||
expect((await repository.getReport(reportId))?.legalHoldUntil).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
test('an end time that is not an ISO 8601 timestamp is refused', async () => {
|
||||
const reportId = await seedReport(10);
|
||||
|
||||
const response = await holdRequest(admin, reportId, {legal_hold_until: 'next year', legal_hold_reason: 'Request'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, 'INVALID_FORM_BODY')
|
||||
.execute();
|
||||
|
||||
expect(response.errors?.[0]?.path).toBe('legal_hold_until');
|
||||
});
|
||||
|
||||
test('an unknown report gives UNKNOWN_REPORT and stores nothing', async () => {
|
||||
await holdRequest(admin, '1499999999999999999', {legal_hold_until: HOLD_UNTIL, legal_hold_reason: 'Request'})
|
||||
.expect(HTTP_STATUS.NOT_FOUND, 'UNKNOWN_REPORT')
|
||||
.execute();
|
||||
|
||||
expect(await repository.getReport(createReportID(1499999999999999999n))).toBeNull();
|
||||
});
|
||||
|
||||
test('placing a hold needs REPORT_RESOLVE', async () => {
|
||||
const reportId = await seedReport(10);
|
||||
const viewer = await setUserACLs(harness, await createTestAccount(harness), [
|
||||
'admin:authenticate',
|
||||
AdminACLs.REPORT_VIEW,
|
||||
]);
|
||||
|
||||
await holdRequest(viewer, reportId, {legal_hold_until: HOLD_UNTIL, legal_hold_reason: 'Request'})
|
||||
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL')
|
||||
.execute();
|
||||
|
||||
expect((await repository.getReport(reportId))?.legalHoldUntil).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {AdminReportService} from '@app/api/admin/services/AdminReportService';
|
||||
import {createReportID, type ReportID} from '@app/api/BrandedTypes';
|
||||
import {Logger} from '@app/api/Logger';
|
||||
import {type IARSubmission, ReportStatus, ReportType} from '@app/api/report/IReportRepository';
|
||||
import type {ReportService} from '@app/api/report/ReportService';
|
||||
import {setInjectedSearchProvider} from '@app/api/SearchFactory';
|
||||
import type {IReportSearchService} from '@app/api/search/IReportSearchService';
|
||||
import type {ISearchProvider} from '@app/api/search/ISearchProvider';
|
||||
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
|
||||
import {UnknownReportError} from '@fluxer/errors/src/domains/moderation/UnknownReportError';
|
||||
import type {ReportSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
import {afterEach, describe, expect, test, vi} from 'vitest';
|
||||
|
||||
const REPORT_A = createReportID(101n);
|
||||
const REPORT_B = createReportID(102n);
|
||||
const ORPHAN = createReportID(999n);
|
||||
const NO_ACLS: ReadonlySet<string> = new Set();
|
||||
|
||||
interface SearchCall {
|
||||
query: string;
|
||||
filters: ReportSearchFilters;
|
||||
options: {limit?: number; offset?: number} | undefined;
|
||||
}
|
||||
|
||||
function storedReport(reportId: ReportID): IARSubmission {
|
||||
return {
|
||||
reportId,
|
||||
reporterId: null,
|
||||
reporterEmail: null,
|
||||
reportedUserId: null,
|
||||
reportedAt: new Date('2026-10-01T12:00:00.000Z'),
|
||||
status: ReportStatus.PENDING,
|
||||
reportType: ReportType.GUILD,
|
||||
category: 'other',
|
||||
reportedGuildNsfw: false,
|
||||
reportedChannelEffectiveNsfw: false,
|
||||
reportedWebhookCreatorDiscriminator: null,
|
||||
reportedWebhookCreatorUsername: null,
|
||||
} as IARSubmission;
|
||||
}
|
||||
|
||||
function createService(options: {
|
||||
hitIds: Array<ReportID>;
|
||||
total: number;
|
||||
getReport?: (reportId: ReportID) => Promise<IARSubmission>;
|
||||
}): {service: AdminReportService; calls: Array<SearchCall>; lookups: Array<ReportID>} {
|
||||
const calls: Array<SearchCall> = [];
|
||||
const lookups: Array<ReportID> = [];
|
||||
const stored = new Map([REPORT_A, REPORT_B].map((reportId) => [reportId, storedReport(reportId)]));
|
||||
const getReport =
|
||||
options.getReport ??
|
||||
(async (reportId: ReportID) => {
|
||||
const report = stored.get(reportId);
|
||||
if (!report) {
|
||||
throw new UnknownReportError();
|
||||
}
|
||||
return report;
|
||||
});
|
||||
const reportSearchService = {
|
||||
searchReports: async (query: string, filters: ReportSearchFilters, searchOptions?: SearchCall['options']) => {
|
||||
calls.push({query, filters, options: searchOptions});
|
||||
return {hits: options.hitIds.map((reportId) => ({id: reportId.toString()})), total: options.total};
|
||||
},
|
||||
} as unknown as IReportSearchService;
|
||||
setInjectedSearchProvider({getReportSearchService: () => reportSearchService} as unknown as ISearchProvider);
|
||||
const reportService = {
|
||||
getReport: async (reportId: ReportID) => {
|
||||
lookups.push(reportId);
|
||||
return getReport(reportId);
|
||||
},
|
||||
} as unknown as ReportService;
|
||||
const service = new AdminReportService({reportService} as ConstructorParameters<typeof AdminReportService>[0]);
|
||||
return {service, calls, lookups};
|
||||
}
|
||||
|
||||
describe('Admin report search totals', () => {
|
||||
afterEach(() => {
|
||||
setInjectedSearchProvider(undefined);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
test('a hit with no stored report is skipped and the index total is returned as is', async () => {
|
||||
const warn = vi.spyOn(Logger.child({}), 'warn');
|
||||
const {service, calls, lookups} = createService({hitIds: [REPORT_A, ORPHAN, REPORT_B], total: 10});
|
||||
const result = await service.searchReports(
|
||||
{query: 'spam', limit: 3, offset: 6, sort_by: 'reportedAt', sort_order: 'desc'},
|
||||
NO_ACLS,
|
||||
);
|
||||
expect(result.reports.map((report) => report.report_id)).toEqual(['101', '102']);
|
||||
expect(result.total).toBe(10);
|
||||
expect(result.offset).toBe(6);
|
||||
expect(result.limit).toBe(3);
|
||||
expect(lookups).toEqual([REPORT_A, ORPHAN, REPORT_B]);
|
||||
expect(calls).toEqual([
|
||||
{query: 'spam', filters: {sortBy: 'reportedAt', sortOrder: 'desc'}, options: {limit: 3, offset: 6}},
|
||||
]);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn.mock.calls[0]).toEqual([
|
||||
{orphanedReportIds: ['999']},
|
||||
'Report search index lists reports that are no longer stored, run refresh_search_index reports',
|
||||
]);
|
||||
});
|
||||
|
||||
test('a page with every report stored logs nothing', async () => {
|
||||
const warn = vi.spyOn(Logger.child({}), 'warn');
|
||||
const {service} = createService({hitIds: [REPORT_B, REPORT_A], total: 2});
|
||||
const result = await service.searchReports(
|
||||
{limit: 50, offset: 0, sort_by: 'reportedAt', sort_order: 'desc'},
|
||||
NO_ACLS,
|
||||
);
|
||||
expect(result.reports.map((report) => report.report_id)).toEqual(['102', '101']);
|
||||
expect(result.total).toBe(2);
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('a page of only missing reports is empty and keeps the index total', async () => {
|
||||
const {service} = createService({hitIds: [ORPHAN], total: 7});
|
||||
expect(
|
||||
await service.searchReports({limit: 1, offset: 0, sort_by: 'reportedAt', sort_order: 'desc'}, NO_ACLS),
|
||||
).toEqual({reports: [], total: 7, offset: 0, limit: 1});
|
||||
});
|
||||
|
||||
test('a failed report lookup rejects the search', async () => {
|
||||
const failure = new Error('report storage is unavailable');
|
||||
const {service} = createService({
|
||||
hitIds: [REPORT_A, ORPHAN, REPORT_B],
|
||||
total: 10,
|
||||
getReport: async (reportId) => {
|
||||
if (reportId === ORPHAN) {
|
||||
throw failure;
|
||||
}
|
||||
return storedReport(reportId);
|
||||
},
|
||||
});
|
||||
await expect(
|
||||
service.searchReports({limit: 3, offset: 0, sort_by: 'reportedAt', sort_order: 'desc'}, NO_ACLS),
|
||||
).rejects.toBe(failure);
|
||||
});
|
||||
|
||||
test('every supplied filter reaches the index under its typed name', async () => {
|
||||
const {service, calls} = createService({hitIds: [], total: 0});
|
||||
await service.searchReports(
|
||||
{
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
reporter_id: 11n,
|
||||
status: 1,
|
||||
report_type: 0,
|
||||
category: 'harassment',
|
||||
reason: 'csam',
|
||||
reported_user_id: 12n,
|
||||
reported_webhook_id: 13n,
|
||||
reported_guild_id: 14n,
|
||||
reported_channel_id: 15n,
|
||||
guild_context_id: 16n,
|
||||
resolved_by_admin_id: 17n,
|
||||
sort_by: 'resolvedAt',
|
||||
sort_order: 'asc',
|
||||
},
|
||||
NO_ACLS,
|
||||
);
|
||||
const expected: Required<Omit<ReportSearchFilters, 'reportedMessageId' | 'isResolved'>> = {
|
||||
reporterId: '11',
|
||||
status: 1,
|
||||
reportType: 0,
|
||||
category: 'harassment',
|
||||
reason: 'csam',
|
||||
reportedUserId: '12',
|
||||
reportedWebhookId: '13',
|
||||
reportedGuildId: '14',
|
||||
reportedChannelId: '15',
|
||||
guildContextId: '16',
|
||||
resolvedByAdminId: '17',
|
||||
sortBy: 'resolvedAt',
|
||||
sortOrder: 'asc',
|
||||
};
|
||||
expect(calls).toEqual([{query: '', filters: expected, options: {limit: 50, offset: 0}}]);
|
||||
});
|
||||
|
||||
test('the search is unavailable without a search service', async () => {
|
||||
const {service} = createService({hitIds: [], total: 0});
|
||||
setInjectedSearchProvider(undefined);
|
||||
await expect(
|
||||
service.searchReports({limit: 50, offset: 0, sort_by: 'reportedAt', sort_order: 'desc'}, NO_ACLS),
|
||||
).rejects.toBeInstanceOf(FeatureTemporarilyDisabledError);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {createTestAccount, setUserACLs} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createTestAccount, setUserACLs, type TestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createDmChannel, createFriendship, createGuild} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {getUserActivityBuffer} from '@app/api/middleware/ServiceSingletons';
|
||||
import {type ApiTestHarness, createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
||||
@@ -17,6 +17,38 @@ async function setLastActiveIp(harness: ApiTestHarness, token: string, ip: strin
|
||||
await getUserActivityBuffer().drainAndFlush();
|
||||
}
|
||||
|
||||
interface ReportListResponse {
|
||||
reports: Array<{report_id: string; status: number}>;
|
||||
total: number;
|
||||
offset: number;
|
||||
limit: number;
|
||||
}
|
||||
|
||||
async function fileUserReport(harness: ApiTestHarness, reporter: TestAccount): Promise<string> {
|
||||
const reported = await createTestAccount(harness);
|
||||
const report = await createBuilder<{report_id: string}>(harness, reporter.token)
|
||||
.post('/reports/user')
|
||||
.body({user_id: reported.userId, category: 'harassment'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
return report.report_id;
|
||||
}
|
||||
|
||||
async function filePendingAndResolvedReports(
|
||||
harness: ApiTestHarness,
|
||||
admin: TestAccount,
|
||||
): Promise<{pendingReportId: string; resolvedReportId: string}> {
|
||||
const reporter = await createTestAccount(harness);
|
||||
const pendingReportId = await fileUserReport(harness, reporter);
|
||||
const resolvedReportId = await fileUserReport(harness, reporter);
|
||||
await createBuilder(harness, admin.token)
|
||||
.patch(`/admin/reports/${resolvedReportId}`)
|
||||
.body({status: 'resolved'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
return {pendingReportId, resolvedReportId};
|
||||
}
|
||||
|
||||
describe('Admin Search Endpoints', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeEach(async () => {
|
||||
@@ -256,6 +288,51 @@ describe('Admin Search Endpoints', () => {
|
||||
.execute();
|
||||
expect(Array.isArray(result.reports)).toBe(true);
|
||||
});
|
||||
test('a request with no query returns reports of every status with the page totals', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'report:view', 'report:resolve']);
|
||||
const {pendingReportId, resolvedReportId} = await filePendingAndResolvedReports(harness, admin);
|
||||
const result = await createBuilder<ReportListResponse>(harness, admin.token)
|
||||
.get('/admin/reports')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(new Map(result.reports.map((report) => [report.report_id, report.status]))).toEqual(
|
||||
new Map([
|
||||
[pendingReportId, 0],
|
||||
[resolvedReportId, 1],
|
||||
]),
|
||||
);
|
||||
expect(result.reports).toHaveLength(2);
|
||||
expect(result.total).toBe(2);
|
||||
expect(result.offset).toBe(0);
|
||||
expect(result.limit).toBe(50);
|
||||
});
|
||||
test('a status filter alone returns only that status with the page totals', async () => {
|
||||
const admin = await createTestAccount(harness);
|
||||
await setUserACLs(harness, admin, ['admin:authenticate', 'report:view', 'report:resolve']);
|
||||
const {pendingReportId, resolvedReportId} = await filePendingAndResolvedReports(harness, admin);
|
||||
const pending = await createBuilder<ReportListResponse>(harness, admin.token)
|
||||
.get('/admin/reports?status=pending')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(pending.reports.map((report) => [report.report_id, report.status])).toEqual([[pendingReportId, 0]]);
|
||||
expect(pending.total).toBe(1);
|
||||
expect(pending.offset).toBe(0);
|
||||
expect(pending.limit).toBe(50);
|
||||
const resolved = await createBuilder<ReportListResponse>(harness, admin.token)
|
||||
.get('/admin/reports?status=resolved&limit=10&offset=0')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(resolved.reports.map((report) => [report.report_id, report.status])).toEqual([[resolvedReportId, 1]]);
|
||||
expect(resolved.total).toBe(1);
|
||||
expect(resolved.offset).toBe(0);
|
||||
expect(resolved.limit).toBe(10);
|
||||
const pastTheEnd = await createBuilder<ReportListResponse>(harness, admin.token)
|
||||
.get('/admin/reports?status=pending&limit=10&offset=5')
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(pastTheEnd).toEqual({reports: [], total: 1, offset: 5, limit: 10});
|
||||
});
|
||||
});
|
||||
describe('/admin/audit-logs (search)', () => {
|
||||
test('requires audit_log:view ACL', async () => {
|
||||
|
||||
@@ -4,17 +4,25 @@ import {scheduledDeletionEmailTemplate} from '@app/api/admin/services/AdminUserD
|
||||
import {
|
||||
clearTestEmails,
|
||||
createTestAccount,
|
||||
createUniqueEmail,
|
||||
findLastTestEmail,
|
||||
listTestEmails,
|
||||
setUserACLs,
|
||||
type TestAccount,
|
||||
} from '@app/api/auth/tests/AuthTestUtils';
|
||||
import {createUserID} from '@app/api/BrandedTypes';
|
||||
import {createReportID, createUserID} from '@app/api/BrandedTypes';
|
||||
import {setupTestGuildWithMembers} from '@app/api/channel/tests/ChannelTestUtils';
|
||||
import {getAdminRepository, getUserRepository} from '@app/api/middleware/ServiceSingletons';
|
||||
import {getReportFlowVariant} from '@app/api/report/flows/ReportFlowRegistry';
|
||||
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 {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||
import {deleteAccount, setPendingDeletionAt} from '@app/api/user/tests/UserTestUtils';
|
||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
||||
import {DeletionReasons} from '@fluxer/constants/src/Core';
|
||||
import {UserFlags} from '@fluxer/constants/src/UserConstants';
|
||||
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
|
||||
|
||||
const DELETION_TEMPLATES = [
|
||||
@@ -267,36 +275,218 @@ describe('Admin staff notifications', () => {
|
||||
return {reporter, reportId: report.report_id};
|
||||
}
|
||||
|
||||
async function fileDsaGuildNotice(): Promise<{email: string; reportId: string}> {
|
||||
const {guild} = await setupTestGuildWithMembers(harness, 1);
|
||||
const email = createUniqueEmail('dsa-resolve');
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post('/reports/dsa/email/send')
|
||||
.body({email})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const code = findLastTestEmail(await listTestEmails(harness, {recipient: email}), 'dsa_report_verification')
|
||||
?.metadata.code;
|
||||
const {ticket} = await createBuilderWithoutAuth<{ticket: string}>(harness)
|
||||
.post('/reports/dsa/email/verify')
|
||||
.body({email, code})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
const report = await createBuilderWithoutAuth<{report_id: string}>(harness)
|
||||
.post('/reports/dsa')
|
||||
.body({
|
||||
ticket,
|
||||
report_type: 'guild',
|
||||
guild_id: guild.id,
|
||||
revision_hash: getReportFlowVariant('guild', 'dsa').revisionHash,
|
||||
steps: [
|
||||
{screen_id: 'community_parts', item_ids: ['activity']},
|
||||
{screen_id: 'root_guild', option_id: 'abuse'},
|
||||
{screen_id: 'abuse_guild', option_id: 'raid'},
|
||||
],
|
||||
good_faith_confirmed: true,
|
||||
additional_info: 'This community organizes raids on other communities.',
|
||||
reporter_full_legal_name: 'Jane Doe',
|
||||
reporter_country_of_residence: 'NL',
|
||||
})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
return {email, reportId: report.report_id};
|
||||
}
|
||||
|
||||
async function resolve(reportId: string, body: Record<string, unknown>) {
|
||||
await createBuilder(harness, admin.token)
|
||||
.patch(`/admin/reports/${reportId}`)
|
||||
.body({status: 'resolved', ...body})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async function setReporterFlag(reporter: TestAccount, flag: bigint) {
|
||||
const users = getUserRepository();
|
||||
const user = (await users.findUnique(createUserID(BigInt(reporter.userId))))!;
|
||||
await users.patchUpsert(user.id, {flags: user.flags | flag}, user.toRow());
|
||||
}
|
||||
|
||||
test('notifies the reporter by default', async () => {
|
||||
const {reporter, reportId} = await fileReport();
|
||||
await clearTestEmails(harness);
|
||||
await resolve(reportId, {public_comment: 'Handled'});
|
||||
const sent = (await emailsTo(reporter)).filter((email) => email.type === 'report_resolved');
|
||||
expect(sent).toHaveLength(1);
|
||||
expect(sent[0]?.metadata).toEqual({report_id: reportId, public_comment: 'Handled'});
|
||||
const dms = await systemDmMessages(reporter);
|
||||
expect(dms).toHaveLength(1);
|
||||
expect(dms[0]?.content).toContain('Handled');
|
||||
const metadata = await auditMetadata('resolve_report', reportId);
|
||||
expect(metadata?.get('notify_reporter')).toBe('true');
|
||||
expect(metadata?.get('reporter_dm_sent')).toBe('true');
|
||||
expect(metadata?.get('reporter_email_sent')).toBe('true');
|
||||
expect(metadata?.get('reporter_dsa_email_sent')).toBe('false');
|
||||
expect(metadata?.has('reporter_notice_skipped')).toBe(false);
|
||||
expect(metadata?.has('internal_comment')).toBe(false);
|
||||
const stored = await new ReportRepository().getReport(createReportID(BigInt(reportId)));
|
||||
expect(stored?.publicComment).toBe('Handled');
|
||||
});
|
||||
|
||||
test('notify_reporter false still sends the generic notice without the comment', async () => {
|
||||
const {reporter, reportId} = await fileReport();
|
||||
await clearTestEmails(harness);
|
||||
await resolve(reportId, {public_comment: 'Staff only wording', notify_reporter: false});
|
||||
const sent = (await emailsTo(reporter)).filter((email) => email.type === 'report_resolved');
|
||||
expect(sent).toHaveLength(1);
|
||||
expect(sent[0]?.metadata).toEqual({report_id: reportId, public_comment: ''});
|
||||
const dms = await systemDmMessages(reporter);
|
||||
expect(dms).toHaveLength(1);
|
||||
expect(dms[0]?.content).toContain(reportId);
|
||||
expect(dms[0]?.content).not.toContain('Staff only wording');
|
||||
const metadata = await auditMetadata('resolve_report', reportId);
|
||||
expect(metadata?.get('notify_reporter')).toBe('false');
|
||||
expect(metadata?.get('reporter_dm_sent')).toBe('true');
|
||||
expect(metadata?.get('reporter_email_sent')).toBe('true');
|
||||
expect(metadata?.has('reporter_notice_skipped')).toBe(false);
|
||||
expect(metadata?.get('internal_comment')).toBe('Staff only wording');
|
||||
const stored = await new ReportRepository().getReport(createReportID(BigInt(reportId)));
|
||||
expect(stored?.publicComment).toBeNull();
|
||||
const detail = await createBuilder<{public_comment: string | null}>(harness, admin.token)
|
||||
.get(`/admin/reports/${reportId}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect(detail.public_comment).toBeNull();
|
||||
});
|
||||
|
||||
test('a whitespace comment sends the generic notice', async () => {
|
||||
const {reporter, reportId} = await fileReport();
|
||||
await clearTestEmails(harness);
|
||||
await resolve(reportId, {public_comment: ' '});
|
||||
const sent = (await emailsTo(reporter)).filter((email) => email.type === 'report_resolved');
|
||||
expect(sent.map((email) => email.metadata.public_comment)).toEqual(['']);
|
||||
});
|
||||
|
||||
test('a DSA notice emails the verified address and sends no DM', async () => {
|
||||
const {email, reportId} = await fileDsaGuildNotice();
|
||||
await clearTestEmails(harness);
|
||||
await resolve(reportId, {public_comment: 'We removed the community.'});
|
||||
const sent = await listTestEmails(harness);
|
||||
expect(sent.map((entry) => [entry.to, entry.type])).toEqual([[email, 'dsa_report_resolved']]);
|
||||
expect(sent[0]?.metadata).toEqual({report_id: reportId, public_comment: 'We removed the community.'});
|
||||
const metadata = await auditMetadata('resolve_report', reportId);
|
||||
expect(metadata?.get('report_type')).toBe('2');
|
||||
expect(metadata?.get('reporter_dm_sent')).toBe('false');
|
||||
expect(metadata?.get('reporter_email_sent')).toBe('false');
|
||||
expect(metadata?.get('reporter_dsa_email_sent')).toBe('true');
|
||||
expect(metadata?.has('reporter_notice_skipped')).toBe(false);
|
||||
});
|
||||
|
||||
test('a DSA notice resolved with notify_reporter false gets the decision email without the comment', async () => {
|
||||
const {email, reportId} = await fileDsaGuildNotice();
|
||||
await clearTestEmails(harness);
|
||||
await resolve(reportId, {public_comment: 'Staff only wording', notify_reporter: false});
|
||||
const sent = await listTestEmails(harness, {recipient: email});
|
||||
expect(sent.map((entry) => entry.type)).toEqual(['dsa_report_resolved']);
|
||||
expect(sent[0]?.metadata).toEqual({report_id: reportId, public_comment: ''});
|
||||
const metadata = await auditMetadata('resolve_report', reportId);
|
||||
expect(metadata?.get('reporter_dsa_email_sent')).toBe('true');
|
||||
expect(metadata?.get('internal_comment')).toBe('Staff only wording');
|
||||
const stored = await new ReportRepository().getReport(createReportID(BigInt(reportId)));
|
||||
expect(stored?.publicComment).toBeNull();
|
||||
});
|
||||
|
||||
test('a deleted reporter gets nothing and the audit row says why', async () => {
|
||||
const {reporter, reportId} = await fileReport();
|
||||
await deleteAccount(harness, reporter.token, reporter.password);
|
||||
await setPendingDeletionAt(harness, reporter.userId, new Date(Date.now() - 60_000));
|
||||
await createBuilderWithoutAuth(harness)
|
||||
.post(`/test/worker/process-pending-deletion/${reporter.userId}`)
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
await clearTestEmails(harness);
|
||||
await resolve(reportId, {public_comment: 'Handled'});
|
||||
expect((await listTestEmails(harness)).filter((email) => email.type === 'report_resolved')).toEqual([]);
|
||||
const metadata = await auditMetadata('resolve_report', reportId);
|
||||
expect(metadata?.get('reporter_dm_sent')).toBe('false');
|
||||
expect(metadata?.get('reporter_email_sent')).toBe('false');
|
||||
expect(metadata?.get('reporter_dsa_email_sent')).toBe('false');
|
||||
expect(metadata?.get('reporter_notice_skipped')).toBe('reporter_account_deleted');
|
||||
});
|
||||
|
||||
test('a reporter barred from reporting gets nothing and the audit row says why', async () => {
|
||||
const {reporter, reportId} = await fileReport();
|
||||
await setReporterFlag(reporter, UserFlags.REPORT_BANNED);
|
||||
await clearTestEmails(harness);
|
||||
await resolve(reportId, {public_comment: 'Handled'});
|
||||
expect((await emailsTo(reporter)).filter((email) => email.type === 'report_resolved')).toEqual([]);
|
||||
expect(await systemDmMessages(reporter)).toEqual([]);
|
||||
const metadata = await auditMetadata('resolve_report', reportId);
|
||||
expect(metadata?.get('reporter_dm_sent')).toBe('false');
|
||||
expect(metadata?.get('reporter_email_sent')).toBe('false');
|
||||
expect(metadata?.get('reporter_notice_skipped')).toBe('reporter_report_banned');
|
||||
});
|
||||
|
||||
async function resolveAuditRows(reportId: string) {
|
||||
const logs = await getAdminRepository().listAllAuditLogsPaginated(1000);
|
||||
return logs.filter((log) => log.action === 'resolve_report' && log.targetId.toString() === reportId);
|
||||
}
|
||||
|
||||
test('two resolves at the same time apply once', async () => {
|
||||
const {reporter, reportId} = await fileReport();
|
||||
await clearTestEmails(harness);
|
||||
const attempts = await Promise.all(
|
||||
['First comment', 'Second comment'].map((comment) =>
|
||||
createBuilder<{public_comment: string | null; code?: string}>(harness, admin.token)
|
||||
.patch(`/admin/reports/${reportId}`)
|
||||
.body({status: 'resolved', public_comment: comment})
|
||||
.executeRaw(),
|
||||
),
|
||||
);
|
||||
expect(attempts.map(({response}) => response.status).sort()).toEqual([HTTP_STATUS.OK, HTTP_STATUS.BAD_REQUEST]);
|
||||
const winner = attempts.find(({response}) => response.status === HTTP_STATUS.OK)!;
|
||||
const loser = attempts.find(({response}) => response.status === HTTP_STATUS.BAD_REQUEST)!;
|
||||
expect(loser.json.code).toBe(APIErrorCodes.REPORT_ALREADY_RESOLVED);
|
||||
const stored = await new ReportRepository().getReport(createReportID(BigInt(reportId)));
|
||||
expect(stored?.status).toBe(1);
|
||||
expect(stored?.publicComment).toBe(winner.json.public_comment);
|
||||
expect((await emailsTo(reporter)).filter((email) => email.type === 'report_resolved')).toHaveLength(1);
|
||||
expect(await systemDmMessages(reporter)).toHaveLength(1);
|
||||
expect(await resolveAuditRows(reportId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('resolving a resolved report fails and sends nothing more', async () => {
|
||||
const {reporter, reportId} = await fileReport();
|
||||
await createBuilder(harness, admin.token)
|
||||
.patch(`/admin/reports/${reportId}`)
|
||||
.body({status: 'resolved', public_comment: 'Handled'})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.execute();
|
||||
expect((await emailsTo(reporter)).map((email) => email.type)).toContain('report_resolved');
|
||||
expect(await systemDmMessages(reporter)).toHaveLength(1);
|
||||
const metadata = await auditMetadata('resolve_report', reportId);
|
||||
expect(metadata?.get('notify_reporter')).toBe('true');
|
||||
expect(metadata?.get('reporter_dm_sent')).toBe('true');
|
||||
expect(metadata?.get('reporter_email_sent')).toBe('true');
|
||||
});
|
||||
|
||||
test('notify_reporter false sends neither DM nor email', async () => {
|
||||
const {reporter, reportId} = await fileReport();
|
||||
await clearTestEmails(harness);
|
||||
await createBuilder(harness, admin.token)
|
||||
.patch(`/admin/reports/${reportId}`)
|
||||
.body({status: 'resolved', notify_reporter: false})
|
||||
.expect(HTTP_STATUS.OK)
|
||||
.body({status: 'resolved', public_comment: 'Handled again'})
|
||||
.expect(HTTP_STATUS.BAD_REQUEST, APIErrorCodes.REPORT_ALREADY_RESOLVED)
|
||||
.execute();
|
||||
expect((await emailsTo(reporter)).map((email) => email.type)).not.toContain('report_resolved');
|
||||
expect(await systemDmMessages(reporter)).toEqual([]);
|
||||
const metadata = await auditMetadata('resolve_report', reportId);
|
||||
expect(metadata?.get('notify_reporter')).toBe('false');
|
||||
expect(metadata?.get('reporter_dm_sent')).toBe('false');
|
||||
expect(metadata?.get('reporter_email_sent')).toBe('false');
|
||||
expect((await emailsTo(reporter)).filter((email) => email.type === 'report_resolved')).toEqual([]);
|
||||
expect(await systemDmMessages(reporter)).toHaveLength(1);
|
||||
expect(await resolveAuditRows(reportId)).toHaveLength(1);
|
||||
const stored = await new ReportRepository().getReport(createReportID(BigInt(reportId)));
|
||||
expect(stored?.publicComment).toBe('Handled');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,9 @@ import type {
|
||||
AdminAuditCoverageContext,
|
||||
} from '@app/api/admin/tests/audit_coverage/AdminAuditCoverage';
|
||||
import {createTestAccount} from '@app/api/auth/tests/AuthTestUtils';
|
||||
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';
|
||||
|
||||
interface FiledUserReport {
|
||||
@@ -12,6 +15,9 @@ interface FiledUserReport {
|
||||
reportedUserId: string;
|
||||
}
|
||||
|
||||
const HOLD_UNTIL = '2099-01-01T00:00:00.000Z';
|
||||
const PREVIOUS_HOLD_UNTIL = '2098-06-01T00:00:00.000Z';
|
||||
|
||||
async function fileUserReport({harness}: AdminAuditCoverageContext): Promise<FiledUserReport> {
|
||||
const reporter = await createTestAccount(harness);
|
||||
const reported = await createTestAccount(harness);
|
||||
@@ -79,6 +85,74 @@ export const ReportAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/reports',
|
||||
name: 'reason',
|
||||
search: 'enabled',
|
||||
async prepare(context) {
|
||||
await fileUserReport(context);
|
||||
return {
|
||||
request: {path: '/admin/reports?status=pending&reason=csam'},
|
||||
expected: {
|
||||
action: 'search_reports',
|
||||
targetType: 'report',
|
||||
targetId: '0',
|
||||
metadata: {
|
||||
status: 'pending',
|
||||
reason: 'csam',
|
||||
sort_by: 'reported_at',
|
||||
sort_order: 'desc',
|
||||
limit: '50',
|
||||
offset: '0',
|
||||
result_count: '0',
|
||||
total: '0',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/reports',
|
||||
name: 'category',
|
||||
search: 'enabled',
|
||||
async prepare(context) {
|
||||
await fileUserReport(context);
|
||||
return {
|
||||
request: {path: '/admin/reports?category=harassment'},
|
||||
expected: {
|
||||
action: 'search_reports',
|
||||
targetType: 'report',
|
||||
targetId: '0',
|
||||
metadata: {
|
||||
category: 'harassment',
|
||||
sort_by: 'reported_at',
|
||||
sort_order: 'desc',
|
||||
limit: '50',
|
||||
offset: '0',
|
||||
result_count: '1',
|
||||
total: '1',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/report-reasons',
|
||||
async prepare() {
|
||||
return {
|
||||
request: {path: '/admin/report-reasons'},
|
||||
expected: {
|
||||
action: 'list_report_reasons',
|
||||
targetType: 'report',
|
||||
targetId: '0',
|
||||
metadata: {result_count: String(listReportReasons().length)},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'GET',
|
||||
route: '/admin/reports/:report_id',
|
||||
@@ -115,6 +189,88 @@ export const ReportAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase> = [
|
||||
notify_reporter: 'true',
|
||||
reporter_dm_sent: 'true',
|
||||
reporter_email_sent: 'true',
|
||||
reporter_dsa_email_sent: 'false',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
route: '/admin/reports/:report_id',
|
||||
name: 'generic notice',
|
||||
async prepare(context) {
|
||||
const {reportId} = await fileUserReport(context);
|
||||
return {
|
||||
request: {
|
||||
path: `/admin/reports/${reportId}`,
|
||||
body: {status: 'resolved', public_comment: 'Internal wording', notify_reporter: false},
|
||||
},
|
||||
expected: {
|
||||
action: 'resolve_report',
|
||||
targetType: 'report',
|
||||
targetId: reportId,
|
||||
metadata: {
|
||||
report_id: reportId,
|
||||
report_type: '1',
|
||||
notify_reporter: 'false',
|
||||
internal_comment: 'Internal wording',
|
||||
reporter_dm_sent: 'true',
|
||||
reporter_email_sent: 'true',
|
||||
reporter_dsa_email_sent: 'false',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/reports/:report_id/legal-hold',
|
||||
async prepare(context) {
|
||||
const {reportId} = await fileUserReport(context);
|
||||
return {
|
||||
request: {
|
||||
path: `/admin/reports/${reportId}/legal-hold`,
|
||||
body: {legal_hold_until: HOLD_UNTIL, legal_hold_reason: 'Preservation request 42'},
|
||||
},
|
||||
expected: {
|
||||
action: 'set_report_legal_hold',
|
||||
targetType: 'report',
|
||||
targetId: reportId,
|
||||
metadata: {
|
||||
report_id: reportId,
|
||||
report_type: '1',
|
||||
legal_hold_until: HOLD_UNTIL,
|
||||
legal_hold_reason: 'Preservation request 42',
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
{
|
||||
method: 'POST',
|
||||
route: '/admin/reports/:report_id/legal-hold',
|
||||
name: 'clear',
|
||||
async prepare(context) {
|
||||
const {reportId} = await fileUserReport(context);
|
||||
await getReportRepository().setReportLegalHold(
|
||||
createReportID(BigInt(reportId)),
|
||||
new Date(PREVIOUS_HOLD_UNTIL),
|
||||
'Preservation request 41',
|
||||
);
|
||||
return {
|
||||
request: {
|
||||
path: `/admin/reports/${reportId}/legal-hold`,
|
||||
body: {legal_hold_until: null},
|
||||
},
|
||||
expected: {
|
||||
action: 'clear_report_legal_hold',
|
||||
targetType: 'report',
|
||||
targetId: reportId,
|
||||
metadata: {
|
||||
report_id: reportId,
|
||||
report_type: '1',
|
||||
previous_legal_hold_until: PREVIOUS_HOLD_UNTIL,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user