/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see .
*/
import type {ChannelID, ReportID, UserID} from '~/BrandedTypes';
import {createReportID} from '~/BrandedTypes';
import {Config} from '~/Config';
import {makeAttachmentCdnKey} from '~/channel/services/message/MessageHelpers';
import type {MessageAttachment} from '~/database/types/MessageTypes';
import type {IEmailService} from '~/infrastructure/IEmailService';
import type {IStorageService} from '~/infrastructure/IStorageService';
import type {UserCacheService} from '~/infrastructure/UserCacheService';
import {Logger} from '~/Logger';
import {getReportSearchService} from '~/Meilisearch';
import type {RequestCache} from '~/middleware/RequestCacheMiddleware';
import {createRequestCache} from '~/middleware/RequestCacheMiddleware';
import type {IARMessageContext, IARSubmission} from '~/report/IReportRepository';
import type {ReportService} from '~/report/ReportService';
import type {IUserRepository} from '~/user/IUserRepository';
import type {SearchReportsRequest} from '../AdminModel';
import type {AdminAuditService} from './AdminAuditService';
interface AdminReportServiceDeps {
reportService: ReportService;
userRepository: IUserRepository;
emailService: IEmailService;
storageService: IStorageService;
auditService: AdminAuditService;
userCacheService: UserCacheService;
}
export class AdminReportService {
constructor(private readonly deps: AdminReportServiceDeps) {}
async listReports(status: number, limit?: number, offset?: number) {
const {reportService} = this.deps;
const requestedLimit = limit || 50;
const currentOffset = offset || 0;
const reports = await reportService.listReportsByStatus(status, requestedLimit, currentOffset);
const requestCache = createRequestCache();
const reportResponses = await Promise.all(
reports.map((report: IARSubmission) => this.mapReportToResponse(report, false, requestCache)),
);
return {
reports: reportResponses,
};
}
async getReport(reportId: ReportID) {
const {reportService} = this.deps;
const report = await reportService.getReport(reportId);
const requestCache = createRequestCache();
return this.mapReportToResponse(report, true, requestCache);
}
async resolveReport(
reportId: ReportID,
adminUserId: UserID,
publicComment: string | null,
auditLogReason: string | null,
) {
const {reportService, userRepository, emailService, auditService} = this.deps;
const resolvedReport = await reportService.resolveReport(reportId, adminUserId, publicComment, auditLogReason);
await auditService.createAuditLog({
adminUserId,
targetType: 'report',
targetId: BigInt(reportId),
action: 'resolve_report',
auditLogReason,
metadata: new Map([
['report_id', reportId.toString()],
['report_type', resolvedReport.reportType.toString()],
]),
});
if (resolvedReport.reporterId && publicComment) {
const reporter = await userRepository.findUnique(resolvedReport.reporterId);
if (reporter?.email) {
await emailService.sendReportResolvedEmail(
reporter.email,
reporter.username,
reportId.toString(),
publicComment,
reporter.locale,
);
}
}
return {
report_id: resolvedReport.reportId.toString(),
status: resolvedReport.status,
resolved_at: resolvedReport.resolvedAt?.toISOString() ?? null,
public_comment: resolvedReport.publicComment,
};
}
async searchReports(data: SearchReportsRequest) {
const reportSearchService = getReportSearchService();
if (!reportSearchService) {
throw new Error('Search is not enabled');
}
const filters: Record = {};
if (data.reporter_id !== undefined) {
filters.reporterId = data.reporter_id.toString();
}
if (data.status !== undefined) {
filters.status = data.status;
}
if (data.report_type !== undefined) {
filters.reportType = data.report_type;
}
if (data.category !== undefined) {
filters.category = data.category;
}
if (data.reported_user_id !== undefined) {
filters.reportedUserId = data.reported_user_id.toString();
}
if (data.reported_guild_id !== undefined) {
filters.reportedGuildId = data.reported_guild_id.toString();
}
if (data.reported_channel_id !== undefined) {
filters.reportedChannelId = data.reported_channel_id.toString();
}
if (data.guild_context_id !== undefined) {
filters.guildContextId = data.guild_context_id.toString();
}
if (data.resolved_by_admin_id !== undefined) {
filters.resolvedByAdminId = data.resolved_by_admin_id.toString();
}
if (data.sort_by) {
filters.sortBy = data.sort_by;
}
if (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 reports = await Promise.all(
hits.map(async (hit) => {
const report = await this.deps.reportService.getReport(createReportID(BigInt(hit.id)));
return this.mapReportToResponse(report, false, requestCache);
}),
);
return {
reports,
total,
offset: data.offset,
limit: data.limit,
};
}
private async mapReportToResponse(report: IARSubmission, includeContext: boolean, requestCache: RequestCache) {
const reporterInfo = await this.buildUserTag(report.reporterId, requestCache);
const reportedUserInfo = await this.buildUserTag(report.reportedUserId, requestCache);
const baseResponse = {
report_id: report.reportId.toString(),
reporter_id: report.reporterId?.toString() ?? null,
reporter_tag: reporterInfo?.tag ?? null,
reporter_username: reporterInfo?.username ?? null,
reporter_discriminator: reporterInfo?.discriminator ?? null,
reporter_email: report.reporterEmail,
reporter_full_legal_name: report.reporterFullLegalName,
reporter_country_of_residence: report.reporterCountryOfResidence,
reported_at: report.reportedAt.toISOString(),
status: report.status,
report_type: report.reportType,
category: report.category,
additional_info: report.additionalInfo,
reported_user_id: report.reportedUserId?.toString() ?? null,
reported_user_tag: reportedUserInfo?.tag ?? null,
reported_user_username: reportedUserInfo?.username ?? null,
reported_user_discriminator: reportedUserInfo?.discriminator ?? null,
reported_user_avatar_hash: report.reportedUserAvatarHash,
reported_guild_id: report.reportedGuildId?.toString() ?? null,
reported_guild_name: report.reportedGuildName,
reported_message_id: report.reportedMessageId?.toString() ?? null,
reported_channel_id: report.reportedChannelId?.toString() ?? null,
reported_channel_name: report.reportedChannelName,
reported_guild_invite_code: report.reportedGuildInviteCode,
resolved_at: report.resolvedAt?.toISOString() ?? null,
resolved_by_admin_id: report.resolvedByAdminId?.toString() ?? null,
public_comment: report.publicComment,
};
if (!includeContext) {
return baseResponse;
}
const messageContext =
report.messageContext && report.messageContext.length > 0
? await Promise.all(
report.messageContext.map((message) =>
this.mapReportMessageContextToResponse(message, report.reportedChannelId ?? null),
),
)
: [];
return {
...baseResponse,
message_context: messageContext,
};
}
private async mapReportMessageContextToResponse(message: IARMessageContext, fallbackChannelId: ChannelID | null) {
const channelId = message.channelId ?? fallbackChannelId;
const attachments =
message.attachments && message.attachments.length > 0
? (
await Promise.all(
message.attachments.map((attachment) => this.mapReportAttachmentToResponse(attachment, channelId)),
)
).filter((attachment): attachment is {filename: string; url: string} => attachment !== null)
: [];
return {
id: message.messageId.toString(),
channel_id: channelId ? channelId.toString() : '',
content: message.content ?? '',
timestamp: message.timestamp.toISOString(),
attachments,
author_id: message.authorId.toString(),
author_username: message.authorUsername,
};
}
private async mapReportAttachmentToResponse(
attachment: MessageAttachment,
channelId: ChannelID | null,
): Promise<{filename: string; url: string} | null> {
if (!attachment || attachment.attachment_id == null || !attachment.filename || !channelId) {
return null;
}
const {storageService} = this.deps;
const attachmentId = attachment.attachment_id;
const filename = String(attachment.filename);
const key = makeAttachmentCdnKey(channelId, attachmentId, filename);
try {
const url = await storageService.getPresignedDownloadURL({
bucket: Config.s3.buckets.reports,
key,
expiresIn: 300,
});
return {filename, url};
} catch (error) {
Logger.error(
{error, attachmentId, filename, channelId},
'Failed to generate presigned URL for report attachment',
);
}
return null;
}
private async buildUserTag(userId: UserID | null, requestCache: RequestCache): Promise {
if (!userId) {
return null;
}
try {
const user = await this.deps.userCacheService.getUserPartialResponse(userId, requestCache);
const discriminator = user.discriminator?.padStart(4, '0') ?? '0000';
return {tag: `${user.username}#${discriminator}`, username: user.username, discriminator};
} catch (error) {
Logger.warn({userId: userId.toString(), error}, 'Failed to resolve user tag for report');
return null;
}
}
}
interface UserTagInfo {
tag: string;
username: string;
discriminator: string;
}