feat(reports): add server-driven report flows (#3330)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-09 01:38:00 +02:00
1 parent bc17922b02
commit ece622e800
471 files changed
+56262 -35932

No files matched your search

@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {GuildID, MessageID, ReportID, UserID} from '@app/api/BrandedTypes';
import type {ReportID} from '@app/api/BrandedTypes';
import type {IARSubmission} from '@app/api/report/IReportRepository';
import type {
ISearchAdapter as SchemaISearchAdapter,
@@ -22,26 +22,5 @@ export interface IReportSearchService extends SchemaISearchAdapter<ReportSearchF
offset?: number;
},
): Promise<SchemaSearchResult<SearchableReport>>;
listReportsByReporter(
reporterId: UserID,
limit?: number,
offset?: number,
): Promise<SchemaSearchResult<SearchableReport>>;
listReportsByStatus(status: number, limit?: number, offset?: number): Promise<SchemaSearchResult<SearchableReport>>;
listReportsByType(reportType: number, limit?: number, offset?: number): Promise<SchemaSearchResult<SearchableReport>>;
listReportsByReportedUser(
reportedUserId: UserID,
limit?: number,
offset?: number,
): Promise<SchemaSearchResult<SearchableReport>>;
listReportsByReportedGuild(
reportedGuildId: GuildID,
limit?: number,
offset?: number,
): Promise<SchemaSearchResult<SearchableReport>>;
listReportsByReportedMessage(
reportedMessageId: MessageID,
limit?: number,
offset?: number,
): Promise<SchemaSearchResult<SearchableReport>>;
}
@@ -0,0 +1,80 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {ReportSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
import {
ElasticsearchReportAdapter,
type ElasticsearchReportAdapterOptions,
} from '@pkgs/elasticsearch_search/src/adapters/ElasticsearchReportAdapter';
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@pkgs/elasticsearch_search/src/ElasticsearchIndexDefinitions';
import {describe, expect, it} from 'vitest';
interface RecordedSearchRequest {
index?: unknown;
query?: {
bool?: {
filter?: unknown;
};
};
}
class FakeElasticsearchClient {
readonly searches: Array<RecordedSearchRequest> = [];
readonly indices = {
exists: async (): Promise<boolean> => true,
putMapping: async (): Promise<void> => {},
};
async search(request: RecordedSearchRequest): Promise<unknown> {
this.searches.push(request);
return {hits: {total: {value: 1}, hits: [{_id: 'report-1', _source: {id: 'report-1'}, sort: [1, 'report-1']}]}};
}
}
async function searchFilter(filters: ReportSearchFilters): Promise<unknown> {
const client = new FakeElasticsearchClient();
const adapter = new ElasticsearchReportAdapter({
client: client as unknown as ElasticsearchReportAdapterOptions['client'],
});
await adapter.initialize();
await adapter.search('', filters, {limit: 10, offset: 0});
expect(client.searches).toHaveLength(1);
expect(client.searches[0].index).toBe('reports');
return client.searches[0].query?.bool?.filter;
}
describe('ElasticsearchReportAdapter', () => {
it('maps the reported channel as a keyword', () => {
expect(ELASTICSEARCH_INDEX_DEFINITIONS.reports.mappings.properties['reportedChannelId']).toEqual({
type: 'keyword',
});
});
it('emits a reported channel term', async () => {
expect(await searchFilter({reportedChannelId: '1234567890'})).toEqual([{term: {reportedChannelId: '1234567890'}}]);
});
it('combines the reported channel term with the other report filters', async () => {
expect(
await searchFilter({
status: 0,
reportType: 0,
reportedGuildId: '11',
reportedMessageId: '22',
reportedChannelId: '33',
guildContextId: '44',
}),
).toEqual([
{term: {status: 0}},
{term: {reportType: 0}},
{term: {reportedGuildId: '11'}},
{term: {reportedMessageId: '22'}},
{term: {reportedChannelId: '33'}},
{term: {guildContextId: '44'}},
]);
});
it('omits the reported channel term when no channel is given', async () => {
expect(await searchFilter({status: 1})).toEqual([{term: {status: 1}}]);
});
});
@@ -1,6 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {GuildID, MessageID, ReportID, UserID} from '@app/api/BrandedTypes';
import type {ReportID} from '@app/api/BrandedTypes';
import type {IARSubmission} from '@app/api/report/IReportRepository';
import type {IReportSearchService} from '@app/api/search/IReportSearchService';
import {convertToSearchableReport} from '@app/api/search/report/ReportSearchSerializer';
@@ -54,47 +54,7 @@ export class ElasticsearchReportSearchService
return this.search(query, filters, options);
}
listReportsByReporter(
reporterId: UserID,
limit?: number,
offset?: number,
): Promise<SchemaSearchResult<SearchableReport>> {
return this.searchReports('', {reporterId: reporterId.toString()}, {limit, offset});
}
listReportsByStatus(status: number, limit?: number, offset?: number): Promise<SchemaSearchResult<SearchableReport>> {
return this.searchReports('', {status}, {limit, offset});
}
listReportsByType(
reportType: number,
limit?: number,
offset?: number,
): Promise<SchemaSearchResult<SearchableReport>> {
return this.searchReports('', {reportType}, {limit, offset});
}
listReportsByReportedUser(
reportedUserId: UserID,
limit?: number,
offset?: number,
): Promise<SchemaSearchResult<SearchableReport>> {
return this.searchReports('', {reportedUserId: reportedUserId.toString()}, {limit, offset});
}
listReportsByReportedGuild(
reportedGuildId: GuildID,
limit?: number,
offset?: number,
): Promise<SchemaSearchResult<SearchableReport>> {
return this.searchReports('', {reportedGuildId: reportedGuildId.toString()}, {limit, offset});
}
listReportsByReportedMessage(
reportedMessageId: MessageID,
limit?: number,
offset?: number,
): Promise<SchemaSearchResult<SearchableReport>> {
return this.searchReports('', {reportedMessageId: reportedMessageId.toString()}, {limit, offset});
}
}
@@ -189,9 +189,12 @@ function buildReportFilters(filters: ReportSearchFilters): Array<MeilisearchFilt
if (filters.status !== undefined) clauses.push(meiliTermFilter('status', filters.status));
if (filters.reportType !== undefined) clauses.push(meiliTermFilter('reportType', filters.reportType));
if (filters.category) clauses.push(meiliTermFilter('category', filters.category));
if (filters.reason) clauses.push(meiliTermFilter('reason', filters.reason));
if (filters.reportedUserId) clauses.push(meiliTermFilter('reportedUserId', filters.reportedUserId));
if (filters.reportedWebhookId) clauses.push(meiliTermFilter('reportedWebhookId', filters.reportedWebhookId));
if (filters.reportedGuildId) clauses.push(meiliTermFilter('reportedGuildId', filters.reportedGuildId));
if (filters.reportedMessageId) clauses.push(meiliTermFilter('reportedMessageId', filters.reportedMessageId));
if (filters.reportedChannelId) clauses.push(meiliTermFilter('reportedChannelId', filters.reportedChannelId));
if (filters.guildContextId) clauses.push(meiliTermFilter('guildContextId', filters.guildContextId));
if (filters.resolvedByAdminId) clauses.push(meiliTermFilter('resolvedByAdminId', filters.resolvedByAdminId));
if (filters.isResolved !== undefined)
@@ -52,11 +52,13 @@ export function meiliRangeFilter(field: string, options: MeilisearchRangeOptions
}
export function meiliExistsFilter(field: string): MeilisearchFilter {
return `${quoteIdentifier(field)} EXISTS`;
const fieldName = quoteIdentifier(field);
return `(${fieldName} EXISTS AND ${fieldName} IS NOT NULL)`;
}
export function meiliNotExistsFilter(field: string): MeilisearchFilter {
return `NOT ${meiliExistsFilter(field)}`;
const fieldName = quoteIdentifier(field);
return `(${fieldName} NOT EXISTS OR ${fieldName} IS NULL)`;
}
export function meiliAndTerms(field: string, values: Array<ScalarFilterValue>): Array<MeilisearchFilter> {
@@ -85,9 +85,12 @@ export const MEILISEARCH_INDEX_DEFINITIONS: Record<FluxerSearchIndexName, Meilis
'status',
'reportType',
'category',
'reason',
'reportedUserId',
'reportedWebhookId',
'reportedGuildId',
'reportedMessageId',
'reportedChannelId',
'guildContextId',
'resolvedByAdminId',
'resolvedAt',
@@ -0,0 +1,109 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import type {MeilisearchClient, MeilisearchTask} from '@app/api/search/meilisearch/MeilisearchClient';
import {MeilisearchReportAdapter} from '@app/api/search/meilisearch/MeilisearchDomainAdapters';
import type {ReportSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
import {describe, expect, it} from 'vitest';
interface RecordedMeilisearchRequest {
method: string;
path: string;
body: unknown;
}
interface MeilisearchSearchBody {
q: string;
filter: string | undefined;
sort: Array<string> | undefined;
}
class FakeMeilisearchClient implements MeilisearchClient {
readonly requests: Array<RecordedMeilisearchRequest> = [];
private nextTaskUid = 1;
async request<TResponse>(method: string, path: string, body?: unknown): Promise<TResponse> {
this.requests.push({method, path, body});
if (method === 'GET' && path.startsWith('/indexes/')) {
return {uid: path.slice('/indexes/'.length)} as TResponse;
}
if ((method === 'PUT' || method === 'PATCH') && path.includes('/settings/')) {
const task: MeilisearchTask = {taskUid: this.nextTaskUid++, status: 'enqueued'};
return task as TResponse;
}
if (method === 'POST' && path.endsWith('/search')) {
return {hits: [{id: 'report-1'}], estimatedTotalHits: 1} as TResponse;
}
throw new Error(`Unhandled fake Meilisearch request: ${method} ${path}`);
}
async waitForTask(_taskUid: number): Promise<void> {}
}
async function searchBody(filters: ReportSearchFilters): Promise<MeilisearchSearchBody> {
const client = new FakeMeilisearchClient();
const adapter = new MeilisearchReportAdapter({client});
await adapter.initialize();
await adapter.search('', filters, {limit: 10, offset: 0});
const request = client.requests.find((candidate) => candidate.path === '/indexes/reports/search');
return request!.body as MeilisearchSearchBody;
}
describe('MeilisearchReportAdapter', () => {
it('declares the reported channel as a filterable attribute', async () => {
const client = new FakeMeilisearchClient();
const adapter = new MeilisearchReportAdapter({client});
await adapter.initialize();
const settings = client.requests.find(
(request) => request.method === 'PUT' && request.path === '/indexes/reports/settings/filterable-attributes',
);
expect(settings?.body).toContain('reportedChannelId');
});
it('emits a reported channel term', async () => {
const body = await searchBody({reportedChannelId: '1234567890'});
expect(body.filter).toBe('(reportedChannelId = "1234567890")');
expect(body.sort).toEqual(['reportedAt:desc', 'id:desc']);
});
it('combines the reported channel term with the other report filters', async () => {
const body = await searchBody({
status: 0,
reportType: 0,
reportedGuildId: '11',
reportedMessageId: '22',
reportedChannelId: '33',
guildContextId: '44',
});
expect(body.filter).toBe(
'(status = 0) AND (reportType = 0) AND (reportedGuildId = "11") AND (reportedMessageId = "22") AND (reportedChannelId = "33") AND (guildContextId = "44")',
);
});
it('omits the reported channel term when no channel is given', async () => {
const body = await searchBody({status: 1});
expect(body.filter).toBe('(status = 1)');
});
it('matches resolved reports on a resolution time that is present and not null', async () => {
const body = await searchBody({isResolved: true});
expect(body.filter).toBe('((resolvedAt EXISTS AND resolvedAt IS NOT NULL))');
});
it('matches unresolved reports on a resolution time that is absent or null', async () => {
const body = await searchBody({isResolved: false});
expect(body.filter).toBe('((resolvedAt NOT EXISTS OR resolvedAt IS NULL))');
});
it('keeps the resolution clause grouped when combined with other filters', async () => {
const body = await searchBody({reportedChannelId: '33', isResolved: false});
expect(body.filter).toBe('(reportedChannelId = "33") AND ((resolvedAt NOT EXISTS OR resolvedAt IS NULL))');
});
});
@@ -264,49 +264,9 @@ export class MeilisearchReportSearchService
return this.search(query, filters, options);
}
listReportsByReporter(
reporterId: UserID,
limit?: number,
offset?: number,
): Promise<SchemaSearchResult<SearchableReport>> {
return this.searchReports('', {reporterId: reporterId.toString()}, {limit, offset});
}
listReportsByStatus(status: number, limit?: number, offset?: number): Promise<SchemaSearchResult<SearchableReport>> {
return this.searchReports('', {status}, {limit, offset});
}
listReportsByType(
reportType: number,
limit?: number,
offset?: number,
): Promise<SchemaSearchResult<SearchableReport>> {
return this.searchReports('', {reportType}, {limit, offset});
}
listReportsByReportedUser(
reportedUserId: UserID,
limit?: number,
offset?: number,
): Promise<SchemaSearchResult<SearchableReport>> {
return this.searchReports('', {reportedUserId: reportedUserId.toString()}, {limit, offset});
}
listReportsByReportedGuild(
reportedGuildId: GuildID,
limit?: number,
offset?: number,
): Promise<SchemaSearchResult<SearchableReport>> {
return this.searchReports('', {reportedGuildId: reportedGuildId.toString()}, {limit, offset});
}
listReportsByReportedMessage(
reportedMessageId: MessageID,
limit?: number,
offset?: number,
): Promise<SchemaSearchResult<SearchableReport>> {
return this.searchReports('', {reportedMessageId: reportedMessageId.toString()}, {limit, offset});
}
}
export class MeilisearchAuditLogSearchService
@@ -15,8 +15,10 @@ export function convertToSearchableReport(report: IARSubmission): SearchableRepo
status: report.status,
reportType: report.reportType,
category: report.category,
reason: report.reason,
additionalInfo: report.additionalInfo,
reportedUserId: report.reportedUserId?.toString() || null,
reportedWebhookId: report.reportedWebhookId?.toString() || null,
reportedGuildId: report.reportedGuildId?.toString() || null,
reportedGuildName: report.reportedGuildName,
reportedMessageId: report.reportedMessageId?.toString() || null,