mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-18 21:45:31 +09:00
feat(reports): add server-driven report flows (#3330)
This commit is contained in:
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,
|
||||
|
||||
Reference in new issue
Block a user