feat(api): add moderation events and visibility actions (#3219)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-05 13:24:19 +02:00
1 parent 532e828fe6
commit 05257d6439
39 files changed
+2464 -88

No files matched your search

@@ -178,7 +178,7 @@ export function ReportAdminController(app: HonoApp) {
const adminUserId = ctx.get('adminUserId');
const auditLogReason = ctx.get('auditLogReason');
const {report_id} = ctx.req.valid('param');
const {public_comment, notify_reporter} = ctx.req.valid('json');
const {public_comment, notify_reporter, resolution} = ctx.req.valid('json');
return ctx.json(
await adminService.reportServiceAggregate.resolveReport(
createReportID(report_id),
@@ -186,6 +186,7 @@ export function ReportAdminController(app: HonoApp) {
public_comment || null,
auditLogReason,
notify_reporter,
resolution,
),
);
},
@@ -261,7 +261,8 @@ export class AdminMessageService {
private async getMessageResponseAccessForAdmin(channelId: ChannelID): Promise<MessageResponseAccessContext> {
const channel = await this.deps.channelRepository.findUnique(channelId);
return channel ? messageResponseAccessForChannel(channel) : messageResponseAccessForGuild(null);
const access = channel ? messageResponseAccessForChannel(channel) : messageResponseAccessForGuild(null);
return {...access, includeHidden: true};
}
private async listMessageResponsesForAdmin(params: {
@@ -34,11 +34,12 @@ import type {User} from '@app/api/models/User';
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 {assertSafeByteSize} from '@app/api/utils/ByteSizeUtils';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {FeatureTemporarilyDisabledError} from '@fluxer/errors/src/domains/core/FeatureTemporarilyDisabledError';
import type {SearchReportsRequest} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {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 {seconds} from 'itty-time';
@@ -56,6 +57,8 @@ interface AdminReportServiceDeps {
ncmecSubmissionService: NcmecSubmissionService;
}
type StaffReportResolution = NonNullable<UpdateReportRequest['resolution']>;
interface ReportNsfwLookupCache {
channelNsfwByChannelId: Map<string, boolean | null>;
guildNsfwLevelByGuildId: Map<string, number | null>;
@@ -105,10 +108,14 @@ export class AdminReportService {
publicComment: string | null,
auditLogReason: string | null,
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 resolvedReport = await reportService.resolveReport(reportId, adminUserId, publicComment, auditLogReason, {
outcome: resolution,
resolvedBy: 'staff',
});
let reporterDmSent = false;
let reporterEmailSent = false;
const reporter =
@@ -147,6 +154,7 @@ export class AdminReportService {
['notify_reporter', notifyReporter ? 'true' : 'false'],
['reporter_dm_sent', reporterDmSent ? 'true' : 'false'],
['reporter_email_sent', reporterEmailSent ? 'true' : 'false'],
...(resolution ? [['resolution', resolution] as [string, string]] : []),
]),
});
return {
@@ -418,7 +426,8 @@ export class AdminReportService {
private async getMessageResponseAccessForAdmin(channelId: ChannelID): Promise<MessageResponseAccessContext> {
const channel = await this.deps.channelRepository.findUnique(channelId);
return channel ? messageResponseAccessForChannel(channel) : messageResponseAccessForGuild(null);
const access = channel ? messageResponseAccessForChannel(channel) : messageResponseAccessForGuild(null);
return {...access, includeHidden: true};
}
private async getMutualDmChannelId(report: IARSubmission): Promise<string | null> {
@@ -617,7 +626,11 @@ export class AdminReportService {
return null;
}
try {
const user = await this.deps.userCacheService.getUserPartialResponse(userId, requestCache);
const cached = await this.deps.userCacheService.getUserPartialResponse(userId, requestCache);
const stored = isHiddenPartial(cached) ? await this.deps.apiContext.services.users.findUnique(userId) : null;
const user = stored
? {username: stored.username, global_name: stored.globalName, discriminator: stored.discriminator.toString()}
: cached;
const discriminator = user.discriminator?.padStart(4, '0') ?? '0000';
return {
tag: `${user.username}#${discriminator}`,
@@ -19,6 +19,7 @@ import type {ReportService} from '@app/api/report/ReportService';
import {getReportSearchService} from '@app/api/SearchFactory';
import type {StoreEntitlementService} from '@app/api/store_billing/StoreEntitlementService';
import {clearNewConversationLimit} from '@app/api/user/NewConversationLimit';
import {isEnforcementDeletionReason} from '@app/api/user/ProfileVisibility';
import {clearPendingDeletion, reschedulePendingDeletion} from '@app/api/user/services/PendingDeletionCoordinator';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import {DeletionReasons} from '@fluxer/constants/src/Core';
@@ -246,7 +247,7 @@ export class AdminUserDeletionService {
let knownIps: ReadonlySet<string> = new Set();
if (data.reason_code !== DeletionReasons.USER_REQUESTED) {
knownIps = await this.banIdentifiersForScheduledDeletion({user, adminUserId, auditLogReason});
await this.resolvePendingReportsAgainstUser({user, adminUserId});
await this.resolvePendingReportsAgainstUser({user, adminUserId, reasonCode: data.reason_code});
}
await emitAdminAction(adminUserId, userId, 'schedule_deletion', {reasonCode: data.reason_code, ips: knownIps});
await updatePropagator.propagateUserUpdate({userId, oldUser: user, updatedUser: updatedUser});
@@ -381,8 +382,13 @@ export class AdminUserDeletionService {
return knownIps;
}
private async resolvePendingReportsAgainstUser(params: {user: User; adminUserId: UserID}): Promise<void> {
const {user, adminUserId} = params;
private async resolvePendingReportsAgainstUser(params: {
user: User;
adminUserId: UserID;
reasonCode: number;
}): Promise<void> {
const {user, adminUserId, reasonCode} = params;
const outcome = isEnforcementDeletionReason(reasonCode) ? 'actioned' : 'auto_resolved';
const {reportService, auditService} = this.deps;
const reportSearchService = getReportSearchService();
if (!reportSearchService) {
@@ -423,7 +429,10 @@ export class AdminUserDeletionService {
for (const hitId of pendingReportIds) {
const reportId = createReportID(BigInt(hitId));
try {
await reportService.resolveReport(reportId, adminUserId, null, auditLogReason);
await reportService.resolveReport(reportId, adminUserId, null, auditLogReason, {
outcome,
resolvedBy: 'system',
});
resolvedCount++;
} catch (error) {
if (error instanceof ReportAlreadyResolvedError) continue;
@@ -78,6 +78,7 @@ export class AdminGuildMembershipService {
reason: data.reason ?? undefined,
banDurationSeconds: data.ban_duration_seconds ?? undefined,
skipGuildAuditLog: true,
by: 'staff',
},
auditLogReason,
);