refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,162 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import type {ChannelID, GuildID, MessageID, ReportID, UserID} from '@fluxer/api/src/BrandedTypes';
import type {MessageAttachment, MessageEmbed, MessageStickerItem} from '@fluxer/api/src/database/types/MessageTypes';
import type {DSAReportEmailVerificationRow, DSAReportTicketRow} from '@fluxer/api/src/database/types/ReportTypes';
export enum ReportStatus {
PENDING = 0,
RESOLVED = 1,
}
export enum ReportType {
MESSAGE = 0,
USER = 1,
GUILD = 2,
}
const REPORT_STATUS_STRINGS: Record<ReportStatus, string> = {
[ReportStatus.PENDING]: 'pending',
[ReportStatus.RESOLVED]: 'resolved',
};
export function reportStatusToString(status: ReportStatus | number): string {
return REPORT_STATUS_STRINGS[status as ReportStatus] ?? 'unknown';
}
type MentionCollection = ReadonlyArray<bigint> | Set<bigint> | null | undefined;
export interface IARMessageContextRow {
message_id: bigint;
channel_id: bigint | null;
author_id: bigint;
author_username: string;
author_discriminator: number;
author_avatar_hash: string | null;
content: string | null;
timestamp: Date;
edited_timestamp: Date | null;
type: number;
flags: number;
mention_everyone: boolean;
mention_users: MentionCollection;
mention_roles: MentionCollection;
mention_channels: MentionCollection;
attachments: Array<MessageAttachment> | null;
embeds: Array<MessageEmbed> | null;
sticker_items: Array<MessageStickerItem> | null;
}
export interface IARMessageContext {
messageId: MessageID;
channelId: ChannelID | null;
authorId: UserID;
authorUsername: string;
authorDiscriminator: number;
authorAvatarHash: string | null;
content: string | null;
timestamp: Date;
editedTimestamp: Date | null;
type: number;
flags: number;
mentionEveryone: boolean;
mentionUsers: Array<bigint>;
mentionRoles: Array<bigint>;
mentionChannels: Array<bigint>;
attachments: Array<MessageAttachment>;
embeds: Array<MessageEmbed>;
stickers: Array<MessageStickerItem>;
}
export interface IARSubmissionRow {
report_id: bigint;
reporter_id: bigint | null;
reporter_email: string | null;
reporter_full_legal_name: string | null;
reporter_country_of_residence: string | null;
reported_at: Date;
status: number;
report_type: number;
category: string;
additional_info: string | null;
reported_user_id: bigint | null;
reported_user_avatar_hash: string | null;
reported_guild_id: bigint | null;
reported_guild_name: string | null;
reported_guild_icon_hash: string | null;
reported_message_id: bigint | null;
reported_channel_id: bigint | null;
reported_channel_name: string | null;
message_context: Array<IARMessageContextRow> | null;
guild_context_id: bigint | null;
resolved_at: Date | null;
resolved_by_admin_id: bigint | null;
public_comment: string | null;
audit_log_reason: string | null;
reported_guild_invite_code: string | null;
}
export interface IARSubmission {
reportId: ReportID;
reporterId: UserID | null;
reporterEmail: string | null;
reporterFullLegalName: string | null;
reporterCountryOfResidence: string | null;
reportedAt: Date;
status: number;
reportType: number;
category: string;
additionalInfo: string | null;
reportedUserId: UserID | null;
reportedUserAvatarHash: string | null;
reportedGuildId: GuildID | null;
reportedGuildName: string | null;
reportedGuildIconHash: string | null;
reportedMessageId: MessageID | null;
reportedChannelId: ChannelID | null;
reportedChannelName: string | null;
messageContext: Array<IARMessageContext> | null;
guildContextId: GuildID | null;
resolvedAt: Date | null;
resolvedByAdminId: UserID | null;
publicComment: string | null;
auditLogReason: string | null;
reportedGuildInviteCode: string | null;
}
export abstract class IReportRepository {
abstract createReport(data: IARSubmissionRow): Promise<IARSubmission>;
abstract getReport(reportId: ReportID): Promise<IARSubmission | null>;
abstract resolveReport(
reportId: ReportID,
resolvedByAdminId: UserID,
publicComment: string | null,
auditLogReason: string | null,
): Promise<IARSubmission>;
abstract listAllReportsPaginated(limit: number, lastReportId?: ReportID): Promise<Array<IARSubmission>>;
abstract upsertDsaEmailVerification(row: DSAReportEmailVerificationRow): Promise<void>;
abstract deleteDsaEmailVerification(emailLower: string): Promise<void>;
abstract getDsaEmailVerification(emailLower: string): Promise<DSAReportEmailVerificationRow | null>;
abstract createDsaTicket(row: DSAReportTicketRow): Promise<void>;
abstract getDsaTicket(ticket: string): Promise<DSAReportTicketRow | null>;
abstract deleteDsaTicket(ticket: string): Promise<void>;
}

View File

@@ -0,0 +1,168 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {DefaultUserOnly, LoginRequired} from '@fluxer/api/src/middleware/AuthMiddleware';
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
import {Validator} from '@fluxer/api/src/Validator';
import {
DsaReportEmailSendRequest,
DsaReportEmailVerifyRequest,
DsaReportRequest,
OkResponse,
ReportGuildRequest,
ReportMessageRequest,
ReportResponse,
ReportUserRequest,
TicketResponse,
} from '@fluxer/schema/src/domains/report/ReportSchemas';
export function ReportController(app: HonoApp) {
app.post(
'/reports/message',
RateLimitMiddleware(RateLimitConfigs.REPORT_CREATE),
LoginRequired,
DefaultUserOnly,
OpenAPI({
operationId: 'report_message',
summary: 'Report message',
description: 'Submits a report about a message to moderators for content violation review.',
responseSchema: ReportResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: 'Reports',
}),
Validator('json', ReportMessageRequest),
async (ctx) => {
return ctx.json(
await ctx.get('reportRequestService').reportMessage({
user: ctx.get('user'),
data: ctx.req.valid('json'),
}),
);
},
);
app.post(
'/reports/user',
RateLimitMiddleware(RateLimitConfigs.REPORT_CREATE),
LoginRequired,
DefaultUserOnly,
OpenAPI({
operationId: 'report_user',
summary: 'Report user',
description: 'Submits a report about a user to moderators for content violation or behaviour review.',
responseSchema: ReportResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: 'Reports',
}),
Validator('json', ReportUserRequest),
async (ctx) => {
return ctx.json(
await ctx.get('reportRequestService').reportUser({
user: ctx.get('user'),
data: ctx.req.valid('json'),
}),
);
},
);
app.post(
'/reports/guild',
RateLimitMiddleware(RateLimitConfigs.REPORT_CREATE),
LoginRequired,
DefaultUserOnly,
OpenAPI({
operationId: 'report_guild',
summary: 'Report guild',
description: 'Submits a report about a guild to moderators for policy violation review.',
responseSchema: ReportResponse,
statusCode: 200,
security: ['botToken', 'bearerToken', 'sessionToken'],
tags: 'Reports',
}),
Validator('json', ReportGuildRequest),
async (ctx) => {
return ctx.json(
await ctx.get('reportRequestService').reportGuild({
user: ctx.get('user'),
data: ctx.req.valid('json'),
}),
);
},
);
app.post(
'/reports/dsa/email/send',
RateLimitMiddleware(RateLimitConfigs.DSA_REPORT_EMAIL_SEND),
OpenAPI({
operationId: 'send_dsa_report_email',
summary: 'Send DSA report email',
description: 'Initiates DSA (Digital Services Act) report submission by sending verification email to reporter.',
responseSchema: OkResponse,
statusCode: 200,
security: [],
tags: 'Reports',
}),
Validator('json', DsaReportEmailSendRequest),
async (ctx) => {
await ctx.get('reportRequestService').sendDsaReportVerificationEmail({data: ctx.req.valid('json')});
return ctx.json({ok: true});
},
);
app.post(
'/reports/dsa/email/verify',
RateLimitMiddleware(RateLimitConfigs.DSA_REPORT_EMAIL_VERIFY),
OpenAPI({
operationId: 'verify_dsa_report_email',
summary: 'Verify DSA report email',
description: 'Verifies the DSA report email and creates a report ticket for legal compliance.',
responseSchema: TicketResponse,
statusCode: 200,
security: [],
tags: 'Reports',
}),
Validator('json', DsaReportEmailVerifyRequest),
async (ctx) => {
return ctx.json(await ctx.get('reportRequestService').verifyDsaReportEmail({data: ctx.req.valid('json')}));
},
);
app.post(
'/reports/dsa',
RateLimitMiddleware(RateLimitConfigs.DSA_REPORT_CREATE),
OpenAPI({
operationId: 'create_dsa_report',
summary: 'Create DSA report',
description: 'Creates a DSA complaint report with verified email for Digital Services Act compliance.',
responseSchema: ReportResponse,
statusCode: 200,
security: [],
tags: 'Reports',
}),
Validator('json', DsaReportRequest),
async (ctx) => {
return ctx.json(await ctx.get('reportRequestService').createDsaReport({data: ctx.req.valid('json')}));
},
);
}

View File

@@ -0,0 +1,215 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import type {ReportID, UserID} from '@fluxer/api/src/BrandedTypes';
import {
createChannelID,
createGuildID,
createMessageID,
createReportID,
createUserID,
} from '@fluxer/api/src/BrandedTypes';
import {Db, executeConditional, fetchMany, fetchOne, upsertOne} from '@fluxer/api/src/database/Cassandra';
import type {DSAReportEmailVerificationRow, DSAReportTicketRow} from '@fluxer/api/src/database/types/ReportTypes';
import type {
IARMessageContext,
IARMessageContextRow,
IARSubmission,
IARSubmissionRow,
IReportRepository,
} from '@fluxer/api/src/report/IReportRepository';
import {DSAReportEmailVerifications, DSAReportTickets, IARSubmissions} from '@fluxer/api/src/Tables';
import {ReportAlreadyResolvedError} from '@fluxer/errors/src/domains/moderation/ReportAlreadyResolvedError';
import {UnknownReportError} from '@fluxer/errors/src/domains/moderation/UnknownReportError';
const GET_REPORT_QUERY = IARSubmissions.select({
where: IARSubmissions.where.eq('report_id'),
limit: 1,
});
const createFetchAllReportsPaginatedQuery = (limit: number) =>
IARSubmissions.select({
where: IARSubmissions.where.tokenGt('report_id', 'last_report_id'),
limit,
});
const GET_DSA_EMAIL_VERIFICATION_QUERY = DSAReportEmailVerifications.select({
where: DSAReportEmailVerifications.where.eq('email_lower'),
limit: 1,
});
const GET_DSA_REPORT_TICKET_QUERY = DSAReportTickets.select({
where: DSAReportTickets.where.eq('ticket'),
limit: 1,
});
function createFetchAllReportsFirstPageQuery(limit: number) {
return IARSubmissions.select({limit});
}
export class ReportRepository implements IReportRepository {
async createReport(data: IARSubmissionRow): Promise<IARSubmission> {
await upsertOne(IARSubmissions.insert(data));
return this.mapRowToSubmission(data);
}
async getReport(reportId: ReportID): Promise<IARSubmission | null> {
const row = await fetchOne<IARSubmissionRow>(GET_REPORT_QUERY.bind({report_id: reportId}));
return row ? this.mapRowToSubmission(row) : null;
}
async resolveReport(
reportId: ReportID,
resolvedByAdminId: UserID,
publicComment: string | null,
auditLogReason: string | null,
): Promise<IARSubmission> {
const report = await this.getReport(reportId);
if (!report) {
throw new UnknownReportError();
}
const resolvedAt = new Date();
const newStatus = 1;
const q = IARSubmissions.patchByPkIf(
{report_id: reportId},
{
resolved_at: Db.set(resolvedAt),
resolved_by_admin_id: Db.set(resolvedByAdminId),
public_comment: Db.set(publicComment),
audit_log_reason: Db.set(auditLogReason),
status: Db.set(newStatus),
},
{col: 'status', expectedParam: 'expected_status', expectedValue: 0},
);
const result = await executeConditional(q);
if (!result.applied) {
throw new ReportAlreadyResolvedError();
}
return {
...report,
resolvedAt,
resolvedByAdminId,
publicComment,
auditLogReason,
status: newStatus,
};
}
private mapRowToSubmission(row: IARSubmissionRow): IARSubmission {
return {
reportId: createReportID(row.report_id),
reporterId: row.reporter_id ? createUserID(row.reporter_id) : null,
reporterEmail: row.reporter_email,
reporterFullLegalName: row.reporter_full_legal_name,
reporterCountryOfResidence: row.reporter_country_of_residence,
reportedAt: row.reported_at,
status: row.status,
reportType: row.report_type,
category: row.category,
additionalInfo: row.additional_info,
reportedUserId: row.reported_user_id ? createUserID(row.reported_user_id) : null,
reportedUserAvatarHash: row.reported_user_avatar_hash,
reportedGuildId: row.reported_guild_id ? createGuildID(row.reported_guild_id) : null,
reportedGuildName: row.reported_guild_name,
reportedGuildIconHash: row.reported_guild_icon_hash,
reportedMessageId: row.reported_message_id ? createMessageID(row.reported_message_id) : null,
reportedChannelId: row.reported_channel_id ? createChannelID(row.reported_channel_id) : null,
reportedChannelName: row.reported_channel_name,
messageContext: row.message_context ? this.mapMessageContext(row.message_context) : null,
guildContextId: row.guild_context_id ? createGuildID(row.guild_context_id) : null,
resolvedAt: row.resolved_at,
resolvedByAdminId: row.resolved_by_admin_id ? createUserID(row.resolved_by_admin_id) : null,
publicComment: row.public_comment,
auditLogReason: row.audit_log_reason,
reportedGuildInviteCode: row.reported_guild_invite_code,
};
}
async listAllReportsPaginated(limit: number, lastReportId?: ReportID): Promise<Array<IARSubmission>> {
let reports: Array<IARSubmissionRow>;
if (lastReportId) {
const query = createFetchAllReportsPaginatedQuery(limit);
reports = await fetchMany<IARSubmissionRow>(query.bind({last_report_id: lastReportId}));
} else {
const query = createFetchAllReportsFirstPageQuery(limit);
reports = await fetchMany<IARSubmissionRow>(query.bind({}));
}
return reports.map((report) => this.mapRowToSubmission(report));
}
async upsertDsaEmailVerification(row: DSAReportEmailVerificationRow): Promise<void> {
await upsertOne(DSAReportEmailVerifications.insert(row));
}
async getDsaEmailVerification(emailLower: string): Promise<DSAReportEmailVerificationRow | null> {
const row = await fetchOne<DSAReportEmailVerificationRow>(
GET_DSA_EMAIL_VERIFICATION_QUERY.bind({email_lower: emailLower}),
);
return row ?? null;
}
async deleteDsaEmailVerification(emailLower: string): Promise<void> {
await DSAReportEmailVerifications.deleteByPk({email_lower: emailLower});
}
async createDsaTicket(row: DSAReportTicketRow): Promise<void> {
await upsertOne(DSAReportTickets.insert(row));
}
async getDsaTicket(ticket: string): Promise<DSAReportTicketRow | null> {
const row = await fetchOne<DSAReportTicketRow>(GET_DSA_REPORT_TICKET_QUERY.bind({ticket}));
return row ?? null;
}
async deleteDsaTicket(ticket: string): Promise<void> {
await DSAReportTickets.deleteByPk({ticket});
}
private mapMessageContext(rawContext: Array<IARMessageContextRow>): Array<IARMessageContext> {
const toBigintArray = (collection: ReadonlyArray<bigint> | Set<bigint> | null | undefined): Array<bigint> =>
collection ? Array.from(collection) : [];
return rawContext.map((msg) => ({
messageId: createMessageID(msg.message_id),
authorId: createUserID(msg.author_id),
channelId: msg.channel_id ? createChannelID(msg.channel_id) : null,
authorUsername: msg.author_username,
authorDiscriminator: msg.author_discriminator,
authorAvatarHash: msg.author_avatar_hash,
content: msg.content,
timestamp: msg.timestamp,
editedTimestamp: msg.edited_timestamp,
type: msg.type,
flags: msg.flags,
mentionEveryone: msg.mention_everyone,
mentionUsers: toBigintArray(msg.mention_users),
mentionRoles: toBigintArray(msg.mention_roles),
mentionChannels: toBigintArray(msg.mention_channels),
attachments: msg.attachments ?? [],
embeds: msg.embeds ?? [],
stickers: msg.sticker_items ?? [],
}));
}
}

View File

@@ -0,0 +1,115 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {createChannelID, createGuildID, createMessageID, createUserID} from '@fluxer/api/src/BrandedTypes';
import type {User} from '@fluxer/api/src/models/User';
import {type ReportStatus, reportStatusToString} from '@fluxer/api/src/report/IReportRepository';
import type {ReportService} from '@fluxer/api/src/report/ReportService';
import type {
DsaReportEmailSendRequest,
DsaReportEmailVerifyRequest,
DsaReportRequest,
ReportGuildRequest,
ReportMessageRequest,
ReportResponse,
ReportUserRequest,
TicketResponse,
} from '@fluxer/schema/src/domains/report/ReportSchemas';
interface ReportUserRequestContext<T> {
user: User;
data: T;
}
interface ReportDsaRequestContext<T> {
data: T;
}
interface ReportRecord {
reportId: bigint;
status: ReportStatus;
reportedAt: Date;
}
export class ReportRequestService {
constructor(private reportService: ReportService) {}
async reportMessage({user, data}: ReportUserRequestContext<ReportMessageRequest>): Promise<ReportResponse> {
const report = await this.reportService.reportMessage(
this.createReporter(user),
createChannelID(data.channel_id),
createMessageID(data.message_id),
data.category,
data.additional_info,
);
return this.toReportResponse(report);
}
async reportUser({user, data}: ReportUserRequestContext<ReportUserRequest>): Promise<ReportResponse> {
const report = await this.reportService.reportUser(
this.createReporter(user),
createUserID(data.user_id),
data.category,
data.additional_info,
data.guild_id ? createGuildID(data.guild_id) : undefined,
);
return this.toReportResponse(report);
}
async reportGuild({user, data}: ReportUserRequestContext<ReportGuildRequest>): Promise<ReportResponse> {
const report = await this.reportService.reportGuild(
this.createReporter(user),
createGuildID(data.guild_id),
data.category,
data.additional_info,
);
return this.toReportResponse(report);
}
async sendDsaReportVerificationEmail({data}: ReportDsaRequestContext<DsaReportEmailSendRequest>): Promise<void> {
await this.reportService.sendDsaReportVerificationCode(data.email);
}
async verifyDsaReportEmail({data}: ReportDsaRequestContext<DsaReportEmailVerifyRequest>): Promise<TicketResponse> {
const ticket = await this.reportService.verifyDsaReportEmail(data.email, data.code);
return {ticket};
}
async createDsaReport({data}: ReportDsaRequestContext<DsaReportRequest>): Promise<ReportResponse> {
const report = await this.reportService.createDsaReport(data);
return this.toReportResponse(report);
}
private createReporter(user: User) {
return {
id: user.id,
email: user.email,
fullLegalName: null,
countryOfResidence: null,
};
}
private toReportResponse(report: ReportRecord): ReportResponse {
return {
report_id: report.reportId.toString(),
status: reportStatusToString(report.status),
reported_at: report.reportedAt.toISOString(),
};
}
}

View File

@@ -0,0 +1,946 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {createHash, randomBytes} from 'node:crypto';
import type {ChannelID, GuildID, MessageID, ReportID, UserID} from '@fluxer/api/src/BrandedTypes';
import {
createChannelID,
createGuildID,
createInviteCode,
createMessageID,
createReportID,
createUserID,
} from '@fluxer/api/src/BrandedTypes';
import {Config} from '@fluxer/api/src/Config';
import type {IChannelRepository} from '@fluxer/api/src/channel/IChannelRepository';
import * as MessageHelpers from '@fluxer/api/src/channel/services/message/MessageHelpers';
import type {MessageAttachment} from '@fluxer/api/src/database/types/MessageTypes';
import type {DSAReportTicketRow} from '@fluxer/api/src/database/types/ReportTypes';
import type {IGuildRepositoryAggregate} from '@fluxer/api/src/guild/repositories/IGuildRepositoryAggregate';
import type {IStorageService} from '@fluxer/api/src/infrastructure/IStorageService';
import type {SnowflakeService} from '@fluxer/api/src/infrastructure/SnowflakeService';
import type {IInviteRepository} from '@fluxer/api/src/invite/IInviteRepository';
import {Logger} from '@fluxer/api/src/Logger';
import type {Attachment} from '@fluxer/api/src/models/Attachment';
import type {User} from '@fluxer/api/src/models/User';
import type {
IARMessageContextRow,
IARSubmission,
IARSubmissionRow,
IReportRepository,
} from '@fluxer/api/src/report/IReportRepository';
import {ReportStatus, ReportType} from '@fluxer/api/src/report/IReportRepository';
import type {IReportSearchService} from '@fluxer/api/src/search/IReportSearchService';
import type {IUserRepository} from '@fluxer/api/src/user/IUserRepository';
import {InviteTypes} from '@fluxer/constants/src/ChannelConstants';
import {UserFlags} from '@fluxer/constants/src/UserConstants';
import type {IEmailService} from '@fluxer/email/src/IEmailService';
import {CannotReportOwnMessageError} from '@fluxer/errors/src/domains/channel/CannotReportOwnMessageError';
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
import {UnknownMessageError} from '@fluxer/errors/src/domains/channel/UnknownMessageError';
import {RateLimitError} from '@fluxer/errors/src/domains/core/RateLimitError';
import {CannotReportOwnGuildError} from '@fluxer/errors/src/domains/guild/CannotReportOwnGuildError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {UnknownInviteError} from '@fluxer/errors/src/domains/invite/UnknownInviteError';
import {CannotReportYourselfError} from '@fluxer/errors/src/domains/moderation/CannotReportYourselfError';
import {InvalidDsaReportTargetError} from '@fluxer/errors/src/domains/moderation/InvalidDsaReportTargetError';
import {InvalidDsaTicketError} from '@fluxer/errors/src/domains/moderation/InvalidDsaTicketError';
import {InvalidDsaVerificationCodeError} from '@fluxer/errors/src/domains/moderation/InvalidDsaVerificationCodeError';
import {ReportBannedError} from '@fluxer/errors/src/domains/moderation/ReportBannedError';
import {UnknownReportError} from '@fluxer/errors/src/domains/moderation/UnknownReportError';
import {UnknownUserError} from '@fluxer/errors/src/domains/user/UnknownUserError';
import type {DsaReportRequest} from '@fluxer/schema/src/domains/report/ReportSchemas';
import {snowflakeToDate} from '@fluxer/snowflake/src/Snowflake';
import {recordCounter} from '@fluxer/telemetry/src/Metrics';
import {ms} from 'itty-time';
interface ReporterMetadata {
id: UserID | null;
email: string | null;
fullLegalName: string | null;
countryOfResidence: string | null;
}
const REPORT_RATE_LIMIT_WINDOW = ms('1 hour');
const REPORT_RATE_LIMIT_MAX = 5;
const MESSAGE_CONTEXT_WINDOW = 25;
const DSA_CODE_CHARSET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const DSA_CODE_SEGMENT_LENGTH = 4;
const DSA_CODE_SEPARATOR = '-';
const DSA_TICKET_BYTES = 32;
export class ReportService {
private reportRateLimitMap = new Map<string, Array<number>>();
private cleanupInterval: NodeJS.Timeout;
constructor(
private reportRepository: IReportRepository,
private channelRepository: IChannelRepository,
private guildRepository: IGuildRepositoryAggregate,
private userRepository: IUserRepository,
private inviteRepository: IInviteRepository,
private emailService: IEmailService,
private snowflakeService: SnowflakeService,
private storageService: IStorageService,
private reportSearchService: IReportSearchService | null = null,
) {
this.cleanupInterval = setInterval(() => this.cleanupRateLimitMap(), ms('5 minutes'));
}
async reportMessage(
reporter: ReporterMetadata,
channelId: ChannelID,
messageId: MessageID,
category: string,
additionalInfo?: string,
): Promise<IARSubmission> {
await this.checkReportBan(reporter.id);
const reporterKey = this.getReporterRateLimitKey(reporter);
await this.checkRateLimit(reporterKey);
const channel = await this.channelRepository.findUnique(channelId);
if (!channel) {
throw new UnknownChannelError();
}
const message = await this.channelRepository.getMessage(channelId, messageId);
if (!message) {
throw new UnknownMessageError();
}
if (reporter.id && message.authorId === reporter.id) {
throw new CannotReportOwnMessageError();
}
const [reportedUser, messageContext] = await Promise.all([
this.userRepository.findUnique(message.authorId!),
this.gatherMessageContext(channelId, messageId),
]);
if (!reportedUser) {
throw new UnknownUserError();
}
const reportId = createReportID(await this.snowflakeService.generate());
const reportData: IARSubmissionRow = {
report_id: reportId,
reporter_id: reporter.id,
reporter_email: reporter.email,
reporter_full_legal_name: reporter.fullLegalName,
reporter_country_of_residence: reporter.countryOfResidence,
reported_at: new Date(),
status: ReportStatus.PENDING,
report_type: ReportType.MESSAGE,
category,
additional_info: additionalInfo || null,
reported_user_id: message.authorId,
reported_user_avatar_hash: reportedUser.avatarHash || null,
reported_guild_id: channel.guildId || null,
reported_guild_name: null,
reported_guild_icon_hash: null,
reported_message_id: messageId,
reported_channel_id: channelId,
reported_channel_name: channel.name || null,
message_context: messageContext,
guild_context_id: channel.guildId || null,
resolved_at: null,
resolved_by_admin_id: null,
public_comment: null,
audit_log_reason: null,
reported_guild_invite_code: null,
};
if (channel.guildId) {
const guild = await this.guildRepository.findUnique(channel.guildId);
if (guild) {
reportData.reported_guild_name = guild.name;
reportData.reported_guild_icon_hash = guild.iconHash || null;
}
}
this.trackRateLimit(reporterKey);
const report = await this.reportRepository.createReport(reportData);
emitReportMetric('reports.iar.created', report);
if (this.reportSearchService && 'indexReport' in this.reportSearchService) {
await this.reportSearchService.indexReport(report).catch((error) => {
Logger.error({error, reportId: report.reportId}, 'Failed to index message report in search');
});
}
return report;
}
async reportUser(
reporter: ReporterMetadata,
reportedUserId: UserID,
category: string,
additionalInfo?: string,
guildId?: GuildID,
): Promise<IARSubmission> {
await this.checkReportBan(reporter.id);
const reporterKey = this.getReporterRateLimitKey(reporter);
await this.checkRateLimit(reporterKey);
if (reporter.id && reportedUserId === reporter.id) {
throw new CannotReportYourselfError();
}
const reportedUser = await this.userRepository.findUnique(reportedUserId);
if (!reportedUser) {
throw new UnknownUserError();
}
const reportId = createReportID(await this.snowflakeService.generate());
const reportData: IARSubmissionRow = {
report_id: reportId,
reporter_id: reporter.id,
reporter_email: reporter.email,
reporter_full_legal_name: reporter.fullLegalName,
reporter_country_of_residence: reporter.countryOfResidence,
reported_at: new Date(),
status: ReportStatus.PENDING,
report_type: ReportType.USER,
category,
additional_info: additionalInfo || null,
reported_user_id: reportedUserId,
reported_user_avatar_hash: reportedUser.avatarHash || null,
reported_guild_id: guildId || null,
reported_guild_name: null,
reported_guild_icon_hash: null,
reported_message_id: null,
reported_channel_id: null,
reported_channel_name: null,
message_context: null,
guild_context_id: guildId || null,
resolved_at: null,
resolved_by_admin_id: null,
public_comment: null,
audit_log_reason: null,
reported_guild_invite_code: null,
};
if (guildId) {
const guild = await this.guildRepository.findUnique(guildId);
if (guild) {
reportData.reported_guild_name = guild.name;
reportData.reported_guild_icon_hash = guild.iconHash || null;
}
}
this.trackRateLimit(reporterKey);
const report = await this.reportRepository.createReport(reportData);
emitReportMetric('reports.iar.created', report);
if (this.reportSearchService && 'indexReport' in this.reportSearchService) {
await this.reportSearchService.indexReport(report).catch((error) => {
Logger.error({error, reportId: report.reportId}, 'Failed to index user report in search');
});
}
return report;
}
async reportGuild(
reporter: ReporterMetadata,
guildId: GuildID,
category: string,
additionalInfo?: string,
): Promise<IARSubmission> {
await this.checkReportBan(reporter.id);
const reporterKey = this.getReporterRateLimitKey(reporter);
await this.checkRateLimit(reporterKey);
const guild = await this.guildRepository.findUnique(guildId);
if (!guild) {
throw new UnknownGuildError();
}
if (reporter.id && guild.ownerId === reporter.id) {
throw new CannotReportOwnGuildError();
}
const reportId = createReportID(await this.snowflakeService.generate());
const reportData: IARSubmissionRow = {
report_id: reportId,
reporter_id: reporter.id,
reporter_email: reporter.email,
reporter_full_legal_name: reporter.fullLegalName,
reporter_country_of_residence: reporter.countryOfResidence,
reported_at: new Date(),
status: ReportStatus.PENDING,
report_type: ReportType.GUILD,
category,
additional_info: additionalInfo || null,
reported_user_id: null,
reported_user_avatar_hash: null,
reported_guild_id: guildId,
reported_guild_name: guild.name,
reported_guild_icon_hash: guild.iconHash || null,
reported_message_id: null,
reported_channel_id: null,
reported_channel_name: null,
message_context: null,
guild_context_id: guildId,
resolved_at: null,
resolved_by_admin_id: null,
public_comment: null,
audit_log_reason: null,
reported_guild_invite_code: null,
};
this.trackRateLimit(reporterKey);
const report = await this.reportRepository.createReport(reportData);
emitReportMetric('reports.iar.created', report);
if (this.reportSearchService && 'indexReport' in this.reportSearchService) {
await this.reportSearchService.indexReport(report).catch((error) => {
Logger.error({error, reportId: report.reportId}, 'Failed to index guild report in search');
});
}
return report;
}
async sendDsaReportVerificationCode(email: string): Promise<void> {
const normalizedEmail = this.normalizeEmail(email);
const verificationCode = this.generateDsaVerificationCode();
const expiresAt = new Date(Date.now() + ms('10 minutes'));
await this.reportRepository.upsertDsaEmailVerification({
email_lower: normalizedEmail,
code_hash: this.hashVerificationCode(verificationCode),
expires_at: expiresAt,
last_sent_at: new Date(),
});
await this.emailService.sendDsaReportVerificationCode(normalizedEmail, verificationCode, expiresAt);
}
async verifyDsaReportEmail(email: string, code: string): Promise<string> {
const normalizedEmail = this.normalizeEmail(email);
const verificationRow = await this.reportRepository.getDsaEmailVerification(normalizedEmail);
if (!verificationRow || verificationRow.expires_at.getTime() < Date.now()) {
throw new InvalidDsaVerificationCodeError();
}
if (this.hashVerificationCode(code) !== verificationRow.code_hash) {
throw new InvalidDsaVerificationCodeError();
}
await this.reportRepository.deleteDsaEmailVerification(normalizedEmail);
const ticket = this.generateDsaTicket();
await this.reportRepository.createDsaTicket({
ticket,
email_lower: normalizedEmail,
expires_at: new Date(Date.now() + ms('1 hour')),
created_at: new Date(),
});
return ticket;
}
async createDsaReport(report: DsaReportRequest): Promise<IARSubmission> {
const ticket = await this.consumeDsaTicket(report.ticket);
const reporterMeta: ReporterMetadata = {
id: null,
email: ticket.email_lower,
fullLegalName: report.reporter_full_legal_name,
countryOfResidence: report.reporter_country_of_residence,
};
await this.checkReportBan(null);
const reporterKey = this.getReporterRateLimitKey(reporterMeta);
await this.checkRateLimit(reporterKey);
const reportId = createReportID(await this.snowflakeService.generate());
const reportRow = await this.buildDsaReportRow(reportId, report, reporterMeta);
this.trackRateLimit(reporterKey);
const createdReport = await this.reportRepository.createReport(reportRow);
emitReportMetric('reports.iar.created', createdReport);
if (this.reportSearchService && 'indexReport' in this.reportSearchService) {
await this.reportSearchService.indexReport(createdReport).catch((error) => {
Logger.error({error, reportId: createdReport.reportId}, 'Failed to index DSA report in search');
});
}
return createdReport;
}
private async buildDsaReportRow(
reportId: ReportID,
report: DsaReportRequest,
reporter: ReporterMetadata,
): Promise<IARSubmissionRow> {
switch (report.report_type) {
case 'message':
return this.buildDsaMessageReportRow(reportId, report, reporter);
case 'user':
return this.buildDsaUserReportRow(reportId, report, reporter);
case 'guild':
return this.buildDsaGuildReportRow(reportId, report, reporter);
default:
throw new InvalidDsaReportTargetError();
}
}
private async buildDsaMessageReportRow(
reportId: ReportID,
report: Extract<DsaReportRequest, {report_type: 'message'}>,
reporter: ReporterMetadata,
): Promise<IARSubmissionRow> {
const {channelId, messageId} = this.extractChannelAndMessageFromLink(report.message_link);
const channel = await this.channelRepository.findUnique(channelId);
if (!channel) throw new UnknownChannelError();
const message = await this.channelRepository.getMessage(channelId, messageId);
if (!message) throw new UnknownMessageError();
if (message.authorId == null) {
throw new UnknownUserError();
}
if (report.reported_user_tag) {
const tagged = await this.findUserByTag(report.reported_user_tag);
if (tagged.id !== message.authorId) {
throw new InvalidDsaReportTargetError();
}
}
const reportedUser = await this.userRepository.findUnique(message.authorId);
if (!reportedUser) {
throw new UnknownUserError();
}
const messageContext = await this.gatherMessageContext(channelId, messageId);
const guild = channel.guildId ? await this.guildRepository.findUnique(channel.guildId) : null;
return {
report_id: reportId,
reporter_id: null,
reporter_email: reporter.email,
reporter_full_legal_name: reporter.fullLegalName,
reporter_country_of_residence: reporter.countryOfResidence,
reported_at: new Date(),
status: ReportStatus.PENDING,
report_type: ReportType.MESSAGE,
category: report.category,
additional_info: report.additional_info ?? null,
reported_user_id: message.authorId,
reported_user_avatar_hash: reportedUser.avatarHash || null,
reported_guild_id: channel.guildId || null,
reported_guild_name: guild?.name ?? null,
reported_guild_icon_hash: guild?.iconHash ?? null,
reported_message_id: messageId,
reported_channel_id: channelId,
reported_channel_name: channel.name || null,
message_context: messageContext,
guild_context_id: channel.guildId || null,
resolved_at: null,
resolved_by_admin_id: null,
public_comment: null,
audit_log_reason: null,
reported_guild_invite_code: null,
};
}
private async buildDsaUserReportRow(
reportId: ReportID,
report: Extract<DsaReportRequest, {report_type: 'user'}>,
reporter: ReporterMetadata,
): Promise<IARSubmissionRow> {
const target = await this.resolveDsaUser(report.user_id ?? undefined, report.user_tag ?? undefined);
return {
report_id: reportId,
reporter_id: null,
reporter_email: reporter.email,
reporter_full_legal_name: reporter.fullLegalName,
reporter_country_of_residence: reporter.countryOfResidence,
reported_at: new Date(),
status: ReportStatus.PENDING,
report_type: ReportType.USER,
category: report.category,
additional_info: report.additional_info ?? null,
reported_user_id: target.id,
reported_user_avatar_hash: target.avatarHash || null,
reported_guild_id: null,
reported_guild_name: null,
reported_guild_icon_hash: null,
reported_message_id: null,
reported_channel_id: null,
reported_channel_name: null,
message_context: null,
guild_context_id: null,
resolved_at: null,
resolved_by_admin_id: null,
public_comment: null,
audit_log_reason: null,
reported_guild_invite_code: null,
};
}
private async buildDsaGuildReportRow(
reportId: ReportID,
report: Extract<DsaReportRequest, {report_type: 'guild'}>,
reporter: ReporterMetadata,
): Promise<IARSubmissionRow> {
const guildId = createGuildID(report.guild_id);
const guild = await this.guildRepository.findUnique(guildId);
if (!guild) {
throw new UnknownGuildError();
}
let inviteCode: string | null = null;
if (report.invite_code) {
inviteCode = this.sanitizeInviteCode(report.invite_code);
if (!inviteCode) {
throw new InvalidDsaReportTargetError();
}
await this.validateInviteForGuild(inviteCode, guildId);
}
return {
report_id: reportId,
reporter_id: null,
reporter_email: reporter.email,
reporter_full_legal_name: reporter.fullLegalName,
reporter_country_of_residence: reporter.countryOfResidence,
reported_at: new Date(),
status: ReportStatus.PENDING,
report_type: ReportType.GUILD,
category: report.category,
additional_info: report.additional_info ?? null,
reported_user_id: null,
reported_user_avatar_hash: null,
reported_guild_id: guildId,
reported_guild_name: guild.name,
reported_guild_icon_hash: guild.iconHash || null,
reported_message_id: null,
reported_channel_id: null,
reported_channel_name: null,
message_context: null,
guild_context_id: guildId,
resolved_at: null,
resolved_by_admin_id: null,
public_comment: null,
audit_log_reason: null,
reported_guild_invite_code: inviteCode,
};
}
private async resolveDsaUser(userId?: bigint, userTag?: string | null): Promise<User> {
if (userId != null) {
const user = await this.userRepository.findUnique(createUserID(userId));
if (!user) {
throw new UnknownUserError();
}
if (userTag) {
const taggedUser = await this.findUserByTag(userTag);
if (taggedUser.id !== user.id) {
throw new InvalidDsaReportTargetError();
}
}
return user;
}
if (userTag) {
return this.findUserByTag(userTag);
}
throw new InvalidDsaReportTargetError();
}
private async findUserByTag(tag: string): Promise<User> {
const parsed = this.parseFluxerTag(tag);
if (!parsed) {
throw new InvalidDsaReportTargetError();
}
const user = await this.userRepository.findByUsernameDiscriminator(parsed.username, parsed.discriminator);
if (!user) {
throw new UnknownUserError();
}
return user;
}
private async consumeDsaTicket(ticket: string): Promise<DSAReportTicketRow> {
const ticketRow = await this.reportRepository.getDsaTicket(ticket);
if (!ticketRow || ticketRow.expires_at.getTime() < Date.now()) {
throw new InvalidDsaTicketError();
}
await this.reportRepository.deleteDsaTicket(ticket);
return ticketRow;
}
private generateDsaVerificationCode(): string {
const segments: Array<string> = [];
for (let i = 0; i < 2; i += 1) {
let segment = '';
for (let j = 0; j < DSA_CODE_SEGMENT_LENGTH; j += 1) {
const index = randomBytes(1)[0] % DSA_CODE_CHARSET.length;
segment += DSA_CODE_CHARSET[index];
}
segments.push(segment);
}
return segments.join(DSA_CODE_SEPARATOR);
}
private hashVerificationCode(code: string): string {
return createHash('sha256').update(code).digest('hex');
}
private generateDsaTicket(): string {
return randomBytes(DSA_TICKET_BYTES).toString('hex');
}
private normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
private parseFluxerTag(tag: string): {username: string; discriminator: number} | null {
const trimmed = tag.trim();
const match = /^(.+)#(\d{4})$/.exec(trimmed);
if (!match) return null;
return {
username: match[1],
discriminator: Number.parseInt(match[2], 10),
};
}
private extractChannelAndMessageFromLink(link: string): {channelId: ChannelID; messageId: MessageID} {
let parsed: URL;
try {
parsed = new URL(link);
} catch {
throw new UnknownMessageError();
}
const segments = parsed.pathname.split('/').filter((segment) => segment.length > 0);
if (segments.length < 4 || segments[0] !== 'channels') {
throw new UnknownMessageError();
}
const channelIdSegment = segments[2];
const messageIdSegment = segments[3];
return {
channelId: createChannelID(BigInt(channelIdSegment)),
messageId: createMessageID(BigInt(messageIdSegment)),
};
}
private sanitizeInviteCode(raw: string): string {
const trimmed = raw.trim();
const segments = trimmed.split('/').filter((segment) => segment.length > 0);
const candidate = segments.length > 0 ? segments[segments.length - 1] : trimmed;
return candidate;
}
private async validateInviteForGuild(code: string, guildId: GuildID): Promise<void> {
const invite = await this.inviteRepository.findUnique(createInviteCode(code));
if (!invite) {
throw new UnknownInviteError();
}
if (invite.type !== InviteTypes.GUILD || !invite.guildId || invite.guildId !== guildId) {
throw new InvalidDsaReportTargetError();
}
}
async getReport(reportId: ReportID): Promise<IARSubmission> {
const report = await this.reportRepository.getReport(reportId);
if (!report) {
throw new UnknownReportError();
}
return report;
}
async listMyReports(reporterId: UserID, limit?: number, offset?: number): Promise<Array<IARSubmission>> {
if (!this.reportSearchService) {
throw new Error('Search service not available');
}
const {hits} = await this.reportSearchService.listReportsByReporter(reporterId, limit, offset);
const reportIds = hits.map((hit) => createReportID(BigInt(hit.id)));
const reports = await Promise.all(reportIds.map((id) => this.reportRepository.getReport(id)));
return reports.filter((report): report is IARSubmission => report !== null);
}
async listReportsByStatus(status: number, limit?: number, offset?: number): Promise<Array<IARSubmission>> {
if (!this.reportSearchService) {
throw new Error('Search service not available');
}
const {hits} = await this.reportSearchService.listReportsByStatus(status, limit, offset);
const reportIds = hits.map((hit) => createReportID(BigInt(hit.id)));
const reports = await Promise.all(reportIds.map((id) => this.reportRepository.getReport(id)));
return reports.filter((report): report is IARSubmission => report !== null);
}
async listAllReportsPaginated(limit: number, lastReportId?: ReportID): Promise<Array<IARSubmission>> {
return this.reportRepository.listAllReportsPaginated(limit, lastReportId);
}
async resolveReport(
reportId: ReportID,
adminUserId: UserID,
publicComment: string | null,
auditLogReason: string | null,
): Promise<IARSubmission> {
const report = await this.reportRepository.resolveReport(reportId, adminUserId, publicComment, auditLogReason);
emitReportMetric('reports.iar.resolved', report, {status: 'resolved'});
if (this.reportSearchService && 'updateReport' in this.reportSearchService) {
await this.reportSearchService.updateReport(report).catch((error) => {
Logger.error({error, reportId: report.reportId}, 'Failed to update report in search index');
});
}
return report;
}
private async gatherMessageContext(
channelId: ChannelID,
targetMessageId: MessageID,
): Promise<Array<IARMessageContextRow>> {
const messagesBefore = await this.channelRepository.listMessages(
channelId,
targetMessageId,
MESSAGE_CONTEXT_WINDOW,
);
const messagesAfter = await this.channelRepository.listMessages(
channelId,
undefined,
MESSAGE_CONTEXT_WINDOW,
targetMessageId,
);
const targetMessage = await this.channelRepository.getMessage(channelId, targetMessageId);
if (!targetMessage) {
return [];
}
messagesBefore.reverse();
const allMessages = [...messagesBefore, targetMessage, ...messagesAfter];
const userIds = new Set<UserID>();
for (const msg of allMessages) {
if (msg.authorId) {
userIds.add(msg.authorId);
}
}
const users = new Map<UserID, User>();
for (const userId of userIds) {
const user = await this.userRepository.findUnique(userId);
if (user) {
users.set(userId, user);
}
}
const context: Array<IARMessageContextRow> = [];
for (const message of allMessages) {
const author = message.authorId != null ? users.get(message.authorId) : null;
if (!author) continue;
const clonedAttachments = message.attachments
? await this.cloneAttachmentsForReport(message.attachments, channelId)
: [];
context.push({
message_id: message.id,
channel_id: channelId,
author_id: message.authorId!,
author_username: author.username,
author_discriminator: author.discriminator,
author_avatar_hash: author.avatarHash || null,
content: message.content || null,
timestamp: snowflakeToDate(message.id),
edited_timestamp: message.editedTimestamp || null,
type: message.type,
flags: message.flags,
mention_everyone: message.mentionEveryone,
mention_users: message.mentionedUserIds.size > 0 ? Array.from(message.mentionedUserIds) : null,
mention_roles: message.mentionedRoleIds.size > 0 ? Array.from(message.mentionedRoleIds) : null,
mention_channels: message.mentionedChannelIds.size > 0 ? Array.from(message.mentionedChannelIds) : null,
attachments: clonedAttachments.length > 0 ? clonedAttachments : null,
embeds: message.embeds.length > 0 ? message.embeds.map((embed) => embed.toMessageEmbed()) : null,
sticker_items:
message.stickers.length > 0 ? message.stickers.map((sticker) => sticker.toMessageStickerItem()) : null,
});
}
return context;
}
private async cloneAttachmentsForReport(
attachments: Array<Attachment>,
sourceChannelId: ChannelID,
): Promise<Array<MessageAttachment>> {
const clonedAttachments: Array<MessageAttachment> = [];
for (const attachment of attachments) {
const sourceKey = MessageHelpers.makeAttachmentCdnKey(sourceChannelId, attachment.id, attachment.filename);
try {
await this.storageService.copyObject({
sourceBucket: Config.s3.buckets.cdn,
sourceKey,
destinationBucket: Config.s3.buckets.reports,
destinationKey: sourceKey,
newContentType: attachment.contentType,
});
const clonedAttachment: MessageAttachment = {
attachment_id: attachment.id,
filename: attachment.filename,
size: BigInt(attachment.size),
title: attachment.title,
description: attachment.description,
width: attachment.width,
height: attachment.height,
content_type: attachment.contentType,
content_hash: attachment.contentHash,
placeholder: attachment.placeholder,
flags: attachment.flags ?? 0,
duration: attachment.duration,
nsfw: attachment.nsfw,
waveform: attachment.waveform ?? null,
};
clonedAttachments.push(clonedAttachment);
} catch (error) {
Logger.error(
{error, attachmentId: attachment.id, filename: attachment.filename, sourceChannelId},
'Failed to clone attachment for report',
);
}
}
return clonedAttachments;
}
private async checkReportBan(userId: UserID | null): Promise<void> {
if (!userId) {
return;
}
const user = await this.userRepository.findUnique(userId);
if (user && (user.flags & UserFlags.REPORT_BANNED) !== 0n) {
throw new ReportBannedError();
}
}
private getReporterRateLimitKey(reporter: ReporterMetadata): string {
if (reporter.id) {
return `user:${reporter.id.toString()}`;
}
if (reporter.email) {
return `email:${reporter.email.toLowerCase()}`;
}
return 'anonymous';
}
private async checkRateLimit(key: string): Promise<void> {
const now = Date.now();
const userReports = this.reportRateLimitMap.get(key) || [];
const recentReports = userReports.filter((timestamp) => now - timestamp < REPORT_RATE_LIMIT_WINDOW);
if (recentReports.length >= REPORT_RATE_LIMIT_MAX) {
const oldestReport = Math.min(...recentReports);
const retryAfter = Math.ceil((oldestReport + REPORT_RATE_LIMIT_WINDOW - now) / 1000);
const resetTime = new Date(oldestReport + REPORT_RATE_LIMIT_WINDOW);
throw new RateLimitError({
message: `Too many reports. Try again in ${retryAfter} seconds.`,
retryAfter,
limit: REPORT_RATE_LIMIT_MAX,
resetTime,
});
}
}
private trackRateLimit(key: string): void {
const now = Date.now();
const userReports = this.reportRateLimitMap.get(key) || [];
const recentReports = userReports.filter((timestamp) => now - timestamp < REPORT_RATE_LIMIT_WINDOW);
recentReports.push(now);
this.reportRateLimitMap.set(key, recentReports);
}
private cleanupRateLimitMap(): void {
const now = Date.now();
for (const [key, timestamps] of this.reportRateLimitMap.entries()) {
const recentReports = timestamps.filter((timestamp) => now - timestamp < REPORT_RATE_LIMIT_WINDOW);
if (recentReports.length === 0) {
this.reportRateLimitMap.delete(key);
} else {
this.reportRateLimitMap.set(key, recentReports);
}
}
}
public shutdown(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
}
}
function emitReportMetric(metricName: string, report: IARSubmission, extra: Record<string, string> = {}): void {
const dimensions: Record<string, string> = {
report_type: reportTypeLabel(report.reportType),
category: report.category || 'unknown',
...extra,
};
if (report.reportedGuildId) {
dimensions['reported_guild_id'] = report.reportedGuildId.toString();
}
if (report.reportedUserId) {
dimensions['reported_user_id'] = report.reportedUserId.toString();
}
recordCounter({
name: metricName,
dimensions,
});
}
function reportTypeLabel(type: number): string {
switch (type) {
case ReportType.MESSAGE:
return 'message';
case ReportType.USER:
return 'user';
case ReportType.GUILD:
return 'guild';
default:
return 'unknown';
}
}

View File

@@ -0,0 +1,837 @@
/*
* 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 <https://www.gnu.org/licenses/>.
*/
import {
clearTestEmails,
createTestAccount,
createUniqueEmail,
findLastTestEmail,
listTestEmails,
setUserACLs,
} from '@fluxer/api/src/auth/tests/AuthTestUtils';
import {
createDmChannel,
createFriendship,
createGuild,
getChannel,
sendChannelMessage,
setupTestGuildWithMembers,
} from '@fluxer/api/src/channel/tests/ChannelTestUtils';
import {ensureSessionStarted} from '@fluxer/api/src/message/tests/MessageTestUtils';
import {type ApiTestHarness, createApiTestHarness} from '@fluxer/api/src/test/ApiTestHarness';
import {HTTP_STATUS} from '@fluxer/api/src/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@fluxer/api/src/test/TestRequestBuilder';
import {afterEach, beforeEach, describe, expect, test} from 'vitest';
interface ReportResponse {
report_id: string;
status: string;
reported_at: string;
}
describe('Content Reporting', () => {
let harness: ApiTestHarness;
beforeEach(async () => {
harness = await createApiTestHarness();
});
afterEach(async () => {
await harness?.shutdown();
});
describe('Report User', () => {
test('should report a user with valid category', async () => {
const reporter = await createTestAccount(harness);
const targetUser = await createTestAccount(harness);
const result = await createBuilder<ReportResponse>(harness, reporter.token)
.post('/reports/user')
.body({
user_id: targetUser.userId,
category: 'harassment',
additional_info: 'User is harassing me in DMs',
})
.expect(HTTP_STATUS.OK)
.execute();
expect(result.report_id).toBeTruthy();
expect(result.status).toBe('pending');
expect(result.reported_at).toBeTruthy();
});
test('should report a user with spam category', async () => {
const reporter = await createTestAccount(harness);
const targetUser = await createTestAccount(harness);
const result = await createBuilder<ReportResponse>(harness, reporter.token)
.post('/reports/user')
.body({
user_id: targetUser.userId,
category: 'spam_account',
})
.expect(HTTP_STATUS.OK)
.execute();
expect(result.report_id).toBeTruthy();
expect(result.status).toBe('pending');
});
test('should report a user with guild context', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const targetUser = members[0];
const result = await createBuilder<ReportResponse>(harness, owner.token)
.post('/reports/user')
.body({
user_id: targetUser.userId,
category: 'harassment',
additional_info: 'User is harassing members in guild',
guild_id: guild.id,
})
.expect(HTTP_STATUS.OK)
.execute();
expect(result.report_id).toBeTruthy();
});
test('should reject report with invalid category', async () => {
const reporter = await createTestAccount(harness);
const targetUser = await createTestAccount(harness);
await createBuilder(harness, reporter.token)
.post('/reports/user')
.body({
user_id: targetUser.userId.toString(),
category: 'invalid_category',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should reject report without authentication', async () => {
const targetUser = await createTestAccount(harness);
await createBuilderWithoutAuth(harness)
.post('/reports/user')
.body({
user_id: targetUser.userId.toString(),
category: 'harassment',
})
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
});
test('should report user with impersonation category', async () => {
const reporter = await createTestAccount(harness);
const targetUser = await createTestAccount(harness);
const result = await createBuilder<ReportResponse>(harness, reporter.token)
.post('/reports/user')
.body({
user_id: targetUser.userId,
category: 'impersonation',
additional_info: 'User is impersonating a celebrity',
})
.expect(HTTP_STATUS.OK)
.execute();
expect(result.report_id).toBeTruthy();
});
test('admin report detail includes mutual DM channel when present', async () => {
const reporter = await createTestAccount(harness);
const targetUser = await createTestAccount(harness);
const admin = await createTestAccount(harness);
await setUserACLs(harness, admin, ['admin:authenticate', 'report:view']);
await createFriendship(harness, reporter, targetUser);
const mutualDm = await createDmChannel(harness, reporter.token, targetUser.userId);
const report = await createBuilder<ReportResponse>(harness, reporter.token)
.post('/reports/user')
.body({
user_id: targetUser.userId,
category: 'harassment',
additional_info: 'User is harassing me in DMs',
})
.expect(HTTP_STATUS.OK)
.execute();
const reportDetail = await createBuilder<{
report_id: string;
mutual_dm_channel_id?: string | null;
}>(harness, `Bearer ${admin.token}`)
.get(`/admin/reports/${report.report_id}`)
.expect(HTTP_STATUS.OK)
.execute();
expect(reportDetail.report_id).toBe(report.report_id);
expect(reportDetail.mutual_dm_channel_id).toBe(mutualDm.id);
});
});
describe('Report Message', () => {
test('should report a message with valid category', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const targetUser = members[0];
const channel = await getChannel(harness, owner.token, guild.system_channel_id!);
const message = await sendChannelMessage(harness, targetUser.token, channel.id, 'Offensive content');
const result = await createBuilder<ReportResponse>(harness, owner.token)
.post('/reports/message')
.body({
channel_id: channel.id,
message_id: message.id,
category: 'harassment',
additional_info: 'This message is harassing me',
})
.expect(HTTP_STATUS.OK)
.execute();
expect(result.report_id).toBeTruthy();
expect(result.status).toBe('pending');
});
test('should report message with spam category', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const targetUser = members[0];
const channel = await getChannel(harness, owner.token, guild.system_channel_id!);
const message = await sendChannelMessage(harness, targetUser.token, channel.id, 'Buy now! Click link!');
const result = await createBuilder<ReportResponse>(harness, owner.token)
.post('/reports/message')
.body({
channel_id: channel.id,
message_id: message.id,
category: 'spam',
})
.expect(HTTP_STATUS.OK)
.execute();
expect(result.report_id).toBeTruthy();
});
test('should report message with hate_speech category', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const targetUser = members[0];
const channel = await getChannel(harness, owner.token, guild.system_channel_id!);
const message = await sendChannelMessage(harness, targetUser.token, channel.id, 'Test message');
const result = await createBuilder<ReportResponse>(harness, owner.token)
.post('/reports/message')
.body({
channel_id: channel.id,
message_id: message.id,
category: 'hate_speech',
additional_info: 'Contains hate speech',
})
.expect(HTTP_STATUS.OK)
.execute();
expect(result.report_id).toBeTruthy();
});
test('should report message with illegal_activity category', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const targetUser = members[0];
const channel = await getChannel(harness, owner.token, guild.system_channel_id!);
const message = await sendChannelMessage(harness, targetUser.token, channel.id, 'Test message');
const result = await createBuilder<ReportResponse>(harness, owner.token)
.post('/reports/message')
.body({
channel_id: channel.id,
message_id: message.id,
category: 'illegal_activity',
})
.expect(HTTP_STATUS.OK)
.execute();
expect(result.report_id).toBeTruthy();
});
test('should reject message report without authentication', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const targetUser = members[0];
const channel = await getChannel(harness, owner.token, guild.system_channel_id!);
const message = await sendChannelMessage(harness, targetUser.token, channel.id, 'Test message');
await createBuilderWithoutAuth(harness)
.post('/reports/message')
.body({
channel_id: channel.id,
message_id: message.id,
category: 'harassment',
})
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
});
});
describe('Report Guild', () => {
test('should report a guild with valid category', async () => {
const reporter = await createTestAccount(harness);
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Problematic Guild');
const result = await createBuilder<ReportResponse>(harness, reporter.token)
.post('/reports/guild')
.body({
guild_id: guild.id,
category: 'harassment',
additional_info: 'Guild promotes harassment',
})
.expect(HTTP_STATUS.OK)
.execute();
expect(result.report_id).toBeTruthy();
expect(result.status).toBe('pending');
});
test('should report guild with extremist_community category', async () => {
const reporter = await createTestAccount(harness);
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const result = await createBuilder<ReportResponse>(harness, reporter.token)
.post('/reports/guild')
.body({
guild_id: guild.id,
category: 'extremist_community',
})
.expect(HTTP_STATUS.OK)
.execute();
expect(result.report_id).toBeTruthy();
});
test('should report guild with raid_coordination category', async () => {
const reporter = await createTestAccount(harness);
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const result = await createBuilder<ReportResponse>(harness, reporter.token)
.post('/reports/guild')
.body({
guild_id: guild.id,
category: 'raid_coordination',
additional_info: 'Guild is coordinating raids',
})
.expect(HTTP_STATUS.OK)
.execute();
expect(result.report_id).toBeTruthy();
});
test('should report guild with malware_distribution category', async () => {
const reporter = await createTestAccount(harness);
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
const result = await createBuilder<ReportResponse>(harness, reporter.token)
.post('/reports/guild')
.body({
guild_id: guild.id,
category: 'malware_distribution',
})
.expect(HTTP_STATUS.OK)
.execute();
expect(result.report_id).toBeTruthy();
});
test('should reject guild report with invalid category', async () => {
const reporter = await createTestAccount(harness);
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
await createBuilder(harness, reporter.token)
.post('/reports/guild')
.body({
guild_id: guild.id,
category: 'invalid_category',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should reject guild report without authentication', async () => {
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
await createBuilderWithoutAuth(harness)
.post('/reports/guild')
.body({
guild_id: guild.id,
category: 'harassment',
})
.expect(HTTP_STATUS.UNAUTHORIZED)
.execute();
});
});
describe('Report Validation', () => {
test('should reject report with additional_info exceeding max length', async () => {
const reporter = await createTestAccount(harness);
const targetUser = await createTestAccount(harness);
const longInfo = 'a'.repeat(1001);
await createBuilder(harness, reporter.token)
.post('/reports/user')
.body({
user_id: targetUser.userId.toString(),
category: 'harassment',
additional_info: longInfo,
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should accept report with maximum length additional_info', async () => {
const reporter = await createTestAccount(harness);
const targetUser = await createTestAccount(harness);
const maxLengthInfo = 'a'.repeat(1000);
const result = await createBuilder<ReportResponse>(harness, reporter.token)
.post('/reports/user')
.body({
user_id: targetUser.userId,
category: 'harassment',
additional_info: maxLengthInfo,
})
.expect(HTTP_STATUS.OK)
.execute();
expect(result.report_id).toBeTruthy();
});
test('should accept report without additional_info', async () => {
const reporter = await createTestAccount(harness);
const targetUser = await createTestAccount(harness);
const result = await createBuilder<ReportResponse>(harness, reporter.token)
.post('/reports/user')
.body({
user_id: targetUser.userId,
category: 'spam_account',
})
.expect(HTTP_STATUS.OK)
.execute();
expect(result.report_id).toBeTruthy();
});
});
describe('Report Requires Category', () => {
test('should reject user report without category', async () => {
const reporter = await createTestAccount(harness);
const targetUser = await createTestAccount(harness);
await createBuilder(harness, reporter.token)
.post('/reports/user')
.body({
user_id: targetUser.userId.toString(),
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should reject message report without category', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const targetUser = members[0];
await ensureSessionStarted(harness, targetUser.token);
const channel = await getChannel(harness, owner.token, guild.system_channel_id!);
const message = await sendChannelMessage(harness, targetUser.token, channel.id, 'Test message');
await createBuilder(harness, owner.token)
.post('/reports/message')
.body({
channel_id: channel.id,
message_id: message.id,
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should reject guild report without category', async () => {
const reporter = await createTestAccount(harness);
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Test Guild');
await createBuilder(harness, reporter.token)
.post('/reports/guild')
.body({
guild_id: guild.id,
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
});
describe('Duplicate Reports', () => {
test('should allow user to report same user multiple times', async () => {
const reporter = await createTestAccount(harness);
const targetUser = await createTestAccount(harness);
const firstReport = await createBuilder<ReportResponse>(harness, reporter.token)
.post('/reports/user')
.body({
user_id: targetUser.userId,
category: 'harassment',
additional_info: 'First report',
})
.expect(HTTP_STATUS.OK)
.execute();
expect(firstReport.report_id).toBeTruthy();
const secondReport = await createBuilder<ReportResponse>(harness, reporter.token)
.post('/reports/user')
.body({
user_id: targetUser.userId,
category: 'spam_account',
additional_info: 'Second report with different category',
})
.expect(HTTP_STATUS.OK)
.execute();
expect(secondReport.report_id).toBeTruthy();
expect(secondReport.report_id).not.toBe(firstReport.report_id);
});
test('should allow user to report same message multiple times', async () => {
const {owner, members, guild} = await setupTestGuildWithMembers(harness, 1);
const targetUser = members[0];
await ensureSessionStarted(harness, targetUser.token);
const channel = await getChannel(harness, owner.token, guild.system_channel_id!);
const message = await sendChannelMessage(harness, targetUser.token, channel.id, 'Problematic message');
const firstReport = await createBuilder<ReportResponse>(harness, owner.token)
.post('/reports/message')
.body({
channel_id: channel.id,
message_id: message.id,
category: 'harassment',
})
.expect(HTTP_STATUS.OK)
.execute();
expect(firstReport.report_id).toBeTruthy();
const secondReport = await createBuilder<ReportResponse>(harness, owner.token)
.post('/reports/message')
.body({
channel_id: channel.id,
message_id: message.id,
category: 'spam',
})
.expect(HTTP_STATUS.OK)
.execute();
expect(secondReport.report_id).toBeTruthy();
expect(secondReport.report_id).not.toBe(firstReport.report_id);
});
test('should allow user to report same guild multiple times', async () => {
const reporter = await createTestAccount(harness);
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'Problematic Guild');
const firstReport = await createBuilder<ReportResponse>(harness, reporter.token)
.post('/reports/guild')
.body({
guild_id: guild.id,
category: 'harassment',
})
.expect(HTTP_STATUS.OK)
.execute();
expect(firstReport.report_id).toBeTruthy();
const secondReport = await createBuilder<ReportResponse>(harness, reporter.token)
.post('/reports/guild')
.body({
guild_id: guild.id,
category: 'extremist_community',
})
.expect(HTTP_STATUS.OK)
.execute();
expect(secondReport.report_id).toBeTruthy();
expect(secondReport.report_id).not.toBe(firstReport.report_id);
});
test('should allow different users to report same content', async () => {
const reporter1 = await createTestAccount(harness);
const reporter2 = await createTestAccount(harness);
const targetUser = await createTestAccount(harness);
const report1 = await createBuilder<ReportResponse>(harness, reporter1.token)
.post('/reports/user')
.body({
user_id: targetUser.userId,
category: 'harassment',
})
.expect(HTTP_STATUS.OK)
.execute();
const report2 = await createBuilder<ReportResponse>(harness, reporter2.token)
.post('/reports/user')
.body({
user_id: targetUser.userId,
category: 'harassment',
})
.expect(HTTP_STATUS.OK)
.execute();
expect(report1.report_id).toBeTruthy();
expect(report2.report_id).toBeTruthy();
expect(report1.report_id).not.toBe(report2.report_id);
});
});
describe('DSA Report Flow', () => {
test('should send DSA verification email', async () => {
await clearTestEmails(harness);
const email = createUniqueEmail('dsa-reporter');
await createBuilderWithoutAuth(harness)
.post('/reports/dsa/email/send')
.body({email})
.expect(HTTP_STATUS.OK)
.execute();
const emails = await listTestEmails(harness);
const dsaEmail = findLastTestEmail(emails, 'dsa_report_verification');
expect(dsaEmail).toBeTruthy();
expect(dsaEmail!.to).toBe(email.toLowerCase());
expect(dsaEmail!.metadata.code).toBeTruthy();
});
test('should verify DSA email and return ticket', async () => {
await clearTestEmails(harness);
const email = createUniqueEmail('dsa-reporter');
await createBuilderWithoutAuth(harness).post('/reports/dsa/email/send').body({email}).execute();
const emails = await listTestEmails(harness);
const dsaEmail = findLastTestEmail(emails, 'dsa_report_verification');
expect(dsaEmail).toBeTruthy();
const code = dsaEmail!.metadata.code;
const verifyResponse = await createBuilder<{ticket: string}>(harness, '')
.post('/reports/dsa/email/verify')
.body({email, code})
.expect(HTTP_STATUS.OK)
.execute();
expect(verifyResponse.ticket).toBeTruthy();
expect(verifyResponse.ticket.length).toBeGreaterThan(0);
});
test('should reject DSA email verification with invalid code', async () => {
await clearTestEmails(harness);
const email = createUniqueEmail('dsa-reporter');
await createBuilderWithoutAuth(harness).post('/reports/dsa/email/send').body({email}).execute();
await createBuilderWithoutAuth(harness)
.post('/reports/dsa/email/verify')
.body({email, code: 'XXXX-XXXX'})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should create DSA user report with valid ticket', async () => {
await clearTestEmails(harness);
const email = createUniqueEmail('dsa-reporter');
const targetUser = await createTestAccount(harness);
await createBuilderWithoutAuth(harness).post('/reports/dsa/email/send').body({email}).execute();
const emails = await listTestEmails(harness);
const dsaEmail = findLastTestEmail(emails, 'dsa_report_verification');
const code = dsaEmail!.metadata.code;
const verifyResponse = await createBuilder<{ticket: string}>(harness, '')
.post('/reports/dsa/email/verify')
.body({email, code})
.expect(HTTP_STATUS.OK)
.execute();
const result = await createBuilder<ReportResponse>(harness, '')
.post('/reports/dsa')
.body({
ticket: verifyResponse.ticket,
report_type: 'user',
category: 'harassment',
user_id: targetUser.userId,
reporter_full_legal_name: 'John Doe',
reporter_country_of_residence: 'DE',
additional_info: 'DSA report for harassment',
})
.expect(HTTP_STATUS.OK)
.execute();
expect(result.report_id).toBeTruthy();
expect(result.status).toBe('pending');
});
test('should create DSA guild report with valid ticket', async () => {
await clearTestEmails(harness);
const email = createUniqueEmail('dsa-reporter');
const owner = await createTestAccount(harness);
const guild = await createGuild(harness, owner.token, 'DSA Test Guild');
await createBuilderWithoutAuth(harness).post('/reports/dsa/email/send').body({email}).execute();
const emails = await listTestEmails(harness);
const dsaEmail = findLastTestEmail(emails, 'dsa_report_verification');
const code = dsaEmail!.metadata.code;
const verifyResponse = await createBuilder<{ticket: string}>(harness, '')
.post('/reports/dsa/email/verify')
.body({email, code})
.expect(HTTP_STATUS.OK)
.execute();
const result = await createBuilder<ReportResponse>(harness, '')
.post('/reports/dsa')
.body({
ticket: verifyResponse.ticket,
report_type: 'guild',
category: 'illegal_activity',
guild_id: guild.id,
reporter_full_legal_name: 'Jane Doe',
reporter_country_of_residence: 'FR',
})
.expect(HTTP_STATUS.OK)
.execute();
expect(result.report_id).toBeTruthy();
expect(result.status).toBe('pending');
});
test('should reject DSA report with invalid ticket', async () => {
const targetUser = await createTestAccount(harness);
await createBuilderWithoutAuth(harness)
.post('/reports/dsa')
.body({
ticket: 'invalid-ticket-value',
report_type: 'user',
category: 'harassment',
user_id: targetUser.userId.toString(),
reporter_full_legal_name: 'John Doe',
reporter_country_of_residence: 'DE',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should reject DSA report with malformed ticket', async () => {
const targetUser = await createTestAccount(harness);
await createBuilderWithoutAuth(harness)
.post('/reports/dsa')
.body({
ticket: '',
report_type: 'user',
category: 'harassment',
user_id: targetUser.userId,
reporter_full_legal_name: 'John Doe',
reporter_country_of_residence: 'DE',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should require reporter_full_legal_name for DSA report', async () => {
await clearTestEmails(harness);
const email = createUniqueEmail('dsa-reporter');
const targetUser = await createTestAccount(harness);
await createBuilderWithoutAuth(harness).post('/reports/dsa/email/send').body({email}).execute();
const emails = await listTestEmails(harness);
const dsaEmail = findLastTestEmail(emails, 'dsa_report_verification');
const code = dsaEmail!.metadata.code;
const verifyResponse = await createBuilderWithoutAuth<{ticket: string}>(harness)
.post('/reports/dsa/email/verify')
.body({email, code})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilderWithoutAuth(harness)
.post('/reports/dsa')
.body({
ticket: verifyResponse.ticket,
report_type: 'user',
category: 'harassment',
user_id: targetUser.userId.toString(),
reporter_country_of_residence: 'DE',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
test('should require EU country for DSA report', async () => {
await clearTestEmails(harness);
const email = createUniqueEmail('dsa-reporter');
const targetUser = await createTestAccount(harness);
await createBuilderWithoutAuth(harness).post('/reports/dsa/email/send').body({email}).execute();
const emails = await listTestEmails(harness);
const dsaEmail = findLastTestEmail(emails, 'dsa_report_verification');
const code = dsaEmail!.metadata.code;
const verifyResponse = await createBuilderWithoutAuth<{ticket: string}>(harness)
.post('/reports/dsa/email/verify')
.body({email, code})
.expect(HTTP_STATUS.OK)
.execute();
await createBuilderWithoutAuth(harness)
.post('/reports/dsa')
.body({
ticket: verifyResponse.ticket,
report_type: 'user',
category: 'harassment',
user_id: targetUser.userId.toString(),
reporter_full_legal_name: 'John Doe',
reporter_country_of_residence: 'US',
})
.expect(HTTP_STATUS.BAD_REQUEST)
.execute();
});
});
});