fluxer/fluxer_api/src/report/ReportRepository.ts
2026-01-01 21:05:54 +00:00

207 lines
7.4 KiB
TypeScript

/*
* 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 '~/BrandedTypes';
import {createChannelID, createGuildID, createMessageID, createReportID, createUserID} from '~/BrandedTypes';
import {Db, executeConditional, fetchMany, fetchOne, upsertOne} from '~/database/Cassandra';
import type {DSAReportEmailVerificationRow, DSAReportTicketRow} from '~/database/types/ReportTypes';
import {ReportAlreadyResolvedError, UnknownReportError} from '~/Errors';
import {DSAReportEmailVerifications, DSAReportTickets, IARSubmissions} from '~/Tables';
import type {
IARMessageContext,
IARMessageContextRow,
IARSubmission,
IARSubmissionRow,
IReportRepository,
} from './IReportRepository';
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,
});
const createFetchAllReportsFirstPageQuery = (limit: number) => 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 ?? [],
}));
}
}