refactor: squash branch changes
This commit is contained in:
42
packages/elasticsearch_search/src/ElasticsearchClient.tsx
Normal file
42
packages/elasticsearch_search/src/ElasticsearchClient.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* 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 {Client} from '@elastic/elasticsearch';
|
||||
|
||||
export interface ElasticsearchClientConfig {
|
||||
node: string;
|
||||
auth?: {
|
||||
apiKey?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
};
|
||||
requestTimeoutMs: number;
|
||||
}
|
||||
|
||||
export function createElasticsearchClient(config: ElasticsearchClientConfig): Client {
|
||||
return new Client({
|
||||
node: config.node,
|
||||
auth: config.auth?.apiKey
|
||||
? {apiKey: config.auth.apiKey}
|
||||
: config.auth?.username
|
||||
? {username: config.auth.username, password: config.auth.password ?? ''}
|
||||
: undefined,
|
||||
requestTimeout: config.requestTimeoutMs,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
export type ElasticsearchFilter = Record<string, unknown>;
|
||||
|
||||
export interface ElasticsearchRangeOptions {
|
||||
gte?: number;
|
||||
lte?: number;
|
||||
gt?: number;
|
||||
lt?: number;
|
||||
}
|
||||
|
||||
export function esTermFilter(field: string, value: string | number | boolean): ElasticsearchFilter {
|
||||
return {term: {[field]: value}};
|
||||
}
|
||||
|
||||
export function esTermsFilter(field: string, values: Array<string | number | boolean>): ElasticsearchFilter {
|
||||
return {terms: {[field]: values}};
|
||||
}
|
||||
|
||||
export function esRangeFilter(field: string, opts: ElasticsearchRangeOptions): ElasticsearchFilter {
|
||||
return {range: {[field]: opts}};
|
||||
}
|
||||
|
||||
export function esExistsFilter(field: string): ElasticsearchFilter {
|
||||
return {exists: {field}};
|
||||
}
|
||||
|
||||
export function esNotExistsFilter(field: string): ElasticsearchFilter {
|
||||
return {bool: {must_not: [{exists: {field}}]}};
|
||||
}
|
||||
|
||||
export function esMustNotTerm(field: string, value: string | number | boolean): ElasticsearchFilter {
|
||||
return {bool: {must_not: [{term: {[field]: value}}]}};
|
||||
}
|
||||
|
||||
export function esMustNotTerms(field: string, values: Array<string | number | boolean>): ElasticsearchFilter {
|
||||
return {bool: {must_not: [{terms: {[field]: values}}]}};
|
||||
}
|
||||
|
||||
export function esAndTerms(field: string, values: Array<string | number | boolean>): Array<ElasticsearchFilter> {
|
||||
return values.map((v) => esTermFilter(field, v));
|
||||
}
|
||||
|
||||
export function esExcludeAny(field: string, values: Array<string | number | boolean>): Array<ElasticsearchFilter> {
|
||||
return values.map((v) => esMustNotTerm(field, v));
|
||||
}
|
||||
|
||||
export function compactFilters(filters: Array<ElasticsearchFilter | undefined>): Array<ElasticsearchFilter> {
|
||||
return filters.filter((f): f is ElasticsearchFilter => f !== undefined);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
/*
|
||||
* 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/>.
|
||||
*/
|
||||
|
||||
type ElasticsearchFieldType = 'text' | 'keyword' | 'boolean' | 'long' | 'integer' | 'date' | 'float';
|
||||
|
||||
export type FluxerSearchIndexName = 'messages' | 'guilds' | 'users' | 'reports' | 'audit_logs' | 'guild_members';
|
||||
|
||||
export interface ElasticsearchFieldMapping {
|
||||
type: ElasticsearchFieldType;
|
||||
index?: boolean;
|
||||
fields?: Record<string, ElasticsearchFieldMapping>;
|
||||
}
|
||||
|
||||
export interface ElasticsearchIndexSettings {
|
||||
number_of_shards?: number;
|
||||
number_of_replicas?: number;
|
||||
}
|
||||
|
||||
export interface ElasticsearchIndexDefinition {
|
||||
indexName: FluxerSearchIndexName;
|
||||
mappings: {
|
||||
properties: Record<string, ElasticsearchFieldMapping>;
|
||||
};
|
||||
settings?: ElasticsearchIndexSettings;
|
||||
}
|
||||
|
||||
function textWithKeyword(): ElasticsearchFieldMapping {
|
||||
return {type: 'text', fields: {keyword: {type: 'keyword'}}};
|
||||
}
|
||||
|
||||
function keyword(): ElasticsearchFieldMapping {
|
||||
return {type: 'keyword'};
|
||||
}
|
||||
|
||||
function bool(): ElasticsearchFieldMapping {
|
||||
return {type: 'boolean'};
|
||||
}
|
||||
|
||||
function long(): ElasticsearchFieldMapping {
|
||||
return {type: 'long'};
|
||||
}
|
||||
|
||||
function integer(): ElasticsearchFieldMapping {
|
||||
return {type: 'integer'};
|
||||
}
|
||||
|
||||
export const ELASTICSEARCH_INDEX_DEFINITIONS: Record<FluxerSearchIndexName, ElasticsearchIndexDefinition> = {
|
||||
messages: {
|
||||
indexName: 'messages',
|
||||
mappings: {
|
||||
properties: {
|
||||
id: keyword(),
|
||||
channelId: keyword(),
|
||||
guildId: keyword(),
|
||||
authorId: keyword(),
|
||||
authorType: keyword(),
|
||||
content: textWithKeyword(),
|
||||
createdAt: long(),
|
||||
editedAt: long(),
|
||||
isPinned: bool(),
|
||||
mentionedUserIds: keyword(),
|
||||
mentionEveryone: bool(),
|
||||
hasLink: bool(),
|
||||
hasEmbed: bool(),
|
||||
hasPoll: bool(),
|
||||
hasFile: bool(),
|
||||
hasVideo: bool(),
|
||||
hasImage: bool(),
|
||||
hasSound: bool(),
|
||||
hasSticker: bool(),
|
||||
hasForward: bool(),
|
||||
embedTypes: keyword(),
|
||||
embedProviders: keyword(),
|
||||
linkHostnames: keyword(),
|
||||
attachmentFilenames: keyword(),
|
||||
attachmentExtensions: keyword(),
|
||||
},
|
||||
},
|
||||
},
|
||||
guilds: {
|
||||
indexName: 'guilds',
|
||||
mappings: {
|
||||
properties: {
|
||||
id: keyword(),
|
||||
ownerId: keyword(),
|
||||
name: textWithKeyword(),
|
||||
vanityUrlCode: textWithKeyword(),
|
||||
discoveryDescription: textWithKeyword(),
|
||||
iconHash: keyword(),
|
||||
bannerHash: keyword(),
|
||||
splashHash: keyword(),
|
||||
features: keyword(),
|
||||
verificationLevel: integer(),
|
||||
mfaLevel: integer(),
|
||||
nsfwLevel: integer(),
|
||||
createdAt: long(),
|
||||
discoveryCategory: integer(),
|
||||
isDiscoverable: bool(),
|
||||
},
|
||||
},
|
||||
},
|
||||
users: {
|
||||
indexName: 'users',
|
||||
mappings: {
|
||||
properties: {
|
||||
id: textWithKeyword(),
|
||||
username: textWithKeyword(),
|
||||
email: textWithKeyword(),
|
||||
phone: textWithKeyword(),
|
||||
discriminator: integer(),
|
||||
isBot: bool(),
|
||||
isSystem: bool(),
|
||||
flags: keyword(),
|
||||
premiumType: integer(),
|
||||
emailVerified: bool(),
|
||||
emailBounced: bool(),
|
||||
suspiciousActivityFlags: integer(),
|
||||
acls: keyword(),
|
||||
createdAt: long(),
|
||||
lastActiveAt: long(),
|
||||
tempBannedUntil: long(),
|
||||
pendingDeletionAt: long(),
|
||||
stripeSubscriptionId: keyword(),
|
||||
stripeCustomerId: keyword(),
|
||||
},
|
||||
},
|
||||
},
|
||||
reports: {
|
||||
indexName: 'reports',
|
||||
mappings: {
|
||||
properties: {
|
||||
id: keyword(),
|
||||
reporterId: keyword(),
|
||||
reportedAt: long(),
|
||||
status: integer(),
|
||||
reportType: integer(),
|
||||
category: textWithKeyword(),
|
||||
additionalInfo: textWithKeyword(),
|
||||
reportedUserId: keyword(),
|
||||
reportedGuildId: keyword(),
|
||||
reportedGuildName: textWithKeyword(),
|
||||
reportedMessageId: keyword(),
|
||||
reportedChannelId: keyword(),
|
||||
reportedChannelName: textWithKeyword(),
|
||||
guildContextId: keyword(),
|
||||
resolvedAt: long(),
|
||||
resolvedByAdminId: keyword(),
|
||||
publicComment: keyword(),
|
||||
createdAt: long(),
|
||||
},
|
||||
},
|
||||
},
|
||||
audit_logs: {
|
||||
indexName: 'audit_logs',
|
||||
mappings: {
|
||||
properties: {
|
||||
id: keyword(),
|
||||
logId: keyword(),
|
||||
adminUserId: keyword(),
|
||||
targetType: textWithKeyword(),
|
||||
targetId: textWithKeyword(),
|
||||
action: textWithKeyword(),
|
||||
auditLogReason: textWithKeyword(),
|
||||
createdAt: long(),
|
||||
},
|
||||
},
|
||||
},
|
||||
guild_members: {
|
||||
indexName: 'guild_members',
|
||||
mappings: {
|
||||
properties: {
|
||||
id: keyword(),
|
||||
guildId: keyword(),
|
||||
userId: textWithKeyword(),
|
||||
username: textWithKeyword(),
|
||||
discriminator: textWithKeyword(),
|
||||
globalName: textWithKeyword(),
|
||||
nickname: textWithKeyword(),
|
||||
roleIds: keyword(),
|
||||
joinedAt: long(),
|
||||
joinSourceType: integer(),
|
||||
sourceInviteCode: keyword(),
|
||||
inviterId: keyword(),
|
||||
userCreatedAt: long(),
|
||||
isBot: bool(),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* 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 {Client} from '@elastic/elasticsearch';
|
||||
import {ElasticsearchIndexAdapter} from '@fluxer/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
|
||||
import type {ElasticsearchFilter} from '@fluxer/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {compactFilters, esTermFilter} from '@fluxer/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@fluxer/elasticsearch_search/src/ElasticsearchIndexDefinitions';
|
||||
import type {AuditLogSearchFilters, SearchableAuditLog} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
|
||||
function buildAuditLogFilters(filters: AuditLogSearchFilters): Array<ElasticsearchFilter | undefined> {
|
||||
const clauses: Array<ElasticsearchFilter | undefined> = [];
|
||||
|
||||
if (filters.adminUserId) clauses.push(esTermFilter('adminUserId', filters.adminUserId));
|
||||
if (filters.targetType) clauses.push(esTermFilter('targetType', filters.targetType));
|
||||
if (filters.targetId) clauses.push(esTermFilter('targetId', filters.targetId));
|
||||
if (filters.action) clauses.push(esTermFilter('action', filters.action));
|
||||
|
||||
return compactFilters(clauses);
|
||||
}
|
||||
|
||||
function buildAuditLogSort(filters: AuditLogSearchFilters): Array<Record<string, unknown>> | undefined {
|
||||
const sortBy = filters.sortBy ?? 'createdAt';
|
||||
if (sortBy === 'relevance') return undefined;
|
||||
const sortOrder = filters.sortOrder ?? 'desc';
|
||||
return [{createdAt: {order: sortOrder}}];
|
||||
}
|
||||
|
||||
export interface ElasticsearchAuditLogAdapterOptions {
|
||||
client: Client;
|
||||
}
|
||||
|
||||
export class ElasticsearchAuditLogAdapter extends ElasticsearchIndexAdapter<AuditLogSearchFilters, SearchableAuditLog> {
|
||||
constructor(options: ElasticsearchAuditLogAdapterOptions) {
|
||||
super({
|
||||
client: options.client,
|
||||
index: ELASTICSEARCH_INDEX_DEFINITIONS.audit_logs,
|
||||
searchableFields: ['action', 'targetType', 'targetId', 'auditLogReason'],
|
||||
buildFilters: buildAuditLogFilters,
|
||||
buildSort: buildAuditLogSort,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* 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 {Client} from '@elastic/elasticsearch';
|
||||
import {ElasticsearchIndexAdapter} from '@fluxer/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
|
||||
import type {ElasticsearchFilter} from '@fluxer/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {compactFilters, esAndTerms, esTermFilter} from '@fluxer/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@fluxer/elasticsearch_search/src/ElasticsearchIndexDefinitions';
|
||||
import type {GuildSearchFilters, SearchableGuild} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
|
||||
function buildGuildFilters(filters: GuildSearchFilters): Array<ElasticsearchFilter | undefined> {
|
||||
const clauses: Array<ElasticsearchFilter | undefined> = [];
|
||||
|
||||
if (filters.ownerId) clauses.push(esTermFilter('ownerId', filters.ownerId));
|
||||
if (filters.verificationLevel !== undefined)
|
||||
clauses.push(esTermFilter('verificationLevel', filters.verificationLevel));
|
||||
if (filters.mfaLevel !== undefined) clauses.push(esTermFilter('mfaLevel', filters.mfaLevel));
|
||||
if (filters.nsfwLevel !== undefined) clauses.push(esTermFilter('nsfwLevel', filters.nsfwLevel));
|
||||
|
||||
if (filters.hasFeature && filters.hasFeature.length > 0) {
|
||||
clauses.push(...esAndTerms('features', filters.hasFeature));
|
||||
}
|
||||
|
||||
if (filters.isDiscoverable !== undefined) clauses.push(esTermFilter('isDiscoverable', filters.isDiscoverable));
|
||||
if (filters.discoveryCategory !== undefined)
|
||||
clauses.push(esTermFilter('discoveryCategory', filters.discoveryCategory));
|
||||
|
||||
return compactFilters(clauses);
|
||||
}
|
||||
|
||||
function buildGuildSort(filters: GuildSearchFilters): Array<Record<string, unknown>> | undefined {
|
||||
const sortBy = filters.sortBy ?? 'createdAt';
|
||||
if (sortBy === 'relevance') return undefined;
|
||||
const sortOrder = filters.sortOrder ?? 'desc';
|
||||
return [{[sortBy]: {order: sortOrder}}];
|
||||
}
|
||||
|
||||
export interface ElasticsearchGuildAdapterOptions {
|
||||
client: Client;
|
||||
}
|
||||
|
||||
export class ElasticsearchGuildAdapter extends ElasticsearchIndexAdapter<GuildSearchFilters, SearchableGuild> {
|
||||
constructor(options: ElasticsearchGuildAdapterOptions) {
|
||||
super({
|
||||
client: options.client,
|
||||
index: ELASTICSEARCH_INDEX_DEFINITIONS.guilds,
|
||||
searchableFields: ['name', 'vanityUrlCode', 'discoveryDescription'],
|
||||
buildFilters: buildGuildFilters,
|
||||
buildSort: buildGuildSort,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* 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 {Client} from '@elastic/elasticsearch';
|
||||
import {ElasticsearchIndexAdapter} from '@fluxer/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
|
||||
import type {ElasticsearchFilter} from '@fluxer/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {
|
||||
compactFilters,
|
||||
esAndTerms,
|
||||
esRangeFilter,
|
||||
esTermFilter,
|
||||
esTermsFilter,
|
||||
} from '@fluxer/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@fluxer/elasticsearch_search/src/ElasticsearchIndexDefinitions';
|
||||
import type {
|
||||
GuildMemberSearchFilters,
|
||||
SearchableGuildMember,
|
||||
} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
|
||||
function buildGuildMemberFilters(filters: GuildMemberSearchFilters): Array<ElasticsearchFilter | undefined> {
|
||||
const clauses: Array<ElasticsearchFilter | undefined> = [];
|
||||
|
||||
clauses.push(esTermFilter('guildId', filters.guildId));
|
||||
|
||||
if (filters.roleIds && filters.roleIds.length > 0) {
|
||||
clauses.push(...esAndTerms('roleIds', filters.roleIds));
|
||||
}
|
||||
|
||||
if (filters.joinedAtGte !== undefined) clauses.push(esRangeFilter('joinedAt', {gte: filters.joinedAtGte}));
|
||||
if (filters.joinedAtLte !== undefined) clauses.push(esRangeFilter('joinedAt', {lte: filters.joinedAtLte}));
|
||||
|
||||
if (filters.joinSourceType && filters.joinSourceType.length > 0) {
|
||||
clauses.push(esTermsFilter('joinSourceType', filters.joinSourceType));
|
||||
}
|
||||
|
||||
if (filters.sourceInviteCode && filters.sourceInviteCode.length > 0) {
|
||||
clauses.push(esTermsFilter('sourceInviteCode', filters.sourceInviteCode));
|
||||
}
|
||||
|
||||
if (filters.userCreatedAtGte !== undefined)
|
||||
clauses.push(esRangeFilter('userCreatedAt', {gte: filters.userCreatedAtGte}));
|
||||
if (filters.userCreatedAtLte !== undefined)
|
||||
clauses.push(esRangeFilter('userCreatedAt', {lte: filters.userCreatedAtLte}));
|
||||
|
||||
if (filters.isBot !== undefined) clauses.push(esTermFilter('isBot', filters.isBot));
|
||||
|
||||
return compactFilters(clauses);
|
||||
}
|
||||
|
||||
function buildGuildMemberSort(filters: GuildMemberSearchFilters): Array<Record<string, unknown>> | undefined {
|
||||
const sortBy = filters.sortBy ?? 'joinedAt';
|
||||
if (sortBy === 'relevance') return undefined;
|
||||
const sortOrder = filters.sortOrder ?? 'desc';
|
||||
return [{[sortBy]: {order: sortOrder}}];
|
||||
}
|
||||
|
||||
export interface ElasticsearchGuildMemberAdapterOptions {
|
||||
client: Client;
|
||||
}
|
||||
|
||||
export class ElasticsearchGuildMemberAdapter extends ElasticsearchIndexAdapter<
|
||||
GuildMemberSearchFilters,
|
||||
SearchableGuildMember
|
||||
> {
|
||||
constructor(options: ElasticsearchGuildMemberAdapterOptions) {
|
||||
super({
|
||||
client: options.client,
|
||||
index: ELASTICSEARCH_INDEX_DEFINITIONS.guild_members,
|
||||
searchableFields: ['username', 'discriminator', 'globalName', 'nickname', 'userId'],
|
||||
buildFilters: buildGuildMemberFilters,
|
||||
buildSort: buildGuildMemberSort,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* 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 {Client} from '@elastic/elasticsearch';
|
||||
import type {ElasticsearchFilter} from '@fluxer/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {compactFilters} from '@fluxer/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import type {ElasticsearchIndexDefinition} from '@fluxer/elasticsearch_search/src/ElasticsearchIndexDefinitions';
|
||||
import type {ISearchAdapter, SearchOptions, SearchResult} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
|
||||
|
||||
export interface ElasticsearchIndexAdapterOptions<TFilters> {
|
||||
client: Client;
|
||||
index: ElasticsearchIndexDefinition;
|
||||
searchableFields: Array<string>;
|
||||
buildFilters: (filters: TFilters) => Array<ElasticsearchFilter | undefined>;
|
||||
buildSort?: (filters: TFilters) => Array<Record<string, unknown>> | undefined;
|
||||
}
|
||||
|
||||
export class ElasticsearchIndexAdapter<TFilters, TResult extends {id: string}>
|
||||
implements ISearchAdapter<TFilters, TResult>
|
||||
{
|
||||
protected readonly client: Client;
|
||||
protected readonly indexDefinition: ElasticsearchIndexDefinition;
|
||||
protected readonly searchableFields: Array<string>;
|
||||
protected readonly buildFilters: (filters: TFilters) => Array<ElasticsearchFilter | undefined>;
|
||||
protected readonly buildSort: ((filters: TFilters) => Array<Record<string, unknown>> | undefined) | undefined;
|
||||
|
||||
private initialized = false;
|
||||
|
||||
constructor(options: ElasticsearchIndexAdapterOptions<TFilters>) {
|
||||
this.client = options.client;
|
||||
this.indexDefinition = options.index;
|
||||
this.searchableFields = options.searchableFields;
|
||||
this.buildFilters = options.buildFilters;
|
||||
this.buildSort = options.buildSort;
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
const indexName = this.indexDefinition.indexName;
|
||||
|
||||
const exists = await this.client.indices.exists({index: indexName});
|
||||
if (!exists) {
|
||||
try {
|
||||
await this.client.indices.create({
|
||||
index: indexName,
|
||||
settings: this.indexDefinition.settings ?? {},
|
||||
mappings: this.indexDefinition.mappings,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isResourceAlreadyExistsError(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.client.indices.putMapping({
|
||||
index: indexName,
|
||||
...this.indexDefinition.mappings,
|
||||
});
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
this.initialized = false;
|
||||
}
|
||||
|
||||
isAvailable(): boolean {
|
||||
return this.initialized;
|
||||
}
|
||||
|
||||
async indexDocument(doc: TResult): Promise<void> {
|
||||
await this.indexDocuments([doc]);
|
||||
}
|
||||
|
||||
async indexDocuments(docs: Array<TResult>): Promise<void> {
|
||||
if (docs.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.assertInitialised();
|
||||
|
||||
const operations = docs.flatMap((doc) => [{index: {_index: this.indexDefinition.indexName, _id: doc.id}}, doc]);
|
||||
await this.client.bulk({operations, refresh: 'wait_for'});
|
||||
}
|
||||
|
||||
async updateDocument(doc: TResult): Promise<void> {
|
||||
this.assertInitialised();
|
||||
|
||||
await this.client.index({
|
||||
index: this.indexDefinition.indexName,
|
||||
id: doc.id,
|
||||
document: doc,
|
||||
refresh: 'wait_for',
|
||||
});
|
||||
}
|
||||
|
||||
async deleteDocument(id: string): Promise<void> {
|
||||
await this.deleteDocuments([id]);
|
||||
}
|
||||
|
||||
async deleteDocuments(ids: Array<string>): Promise<void> {
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
this.assertInitialised();
|
||||
|
||||
const operations = ids.map((id) => ({delete: {_index: this.indexDefinition.indexName, _id: id}}));
|
||||
await this.client.bulk({operations, refresh: 'wait_for'});
|
||||
}
|
||||
|
||||
async deleteAllDocuments(): Promise<void> {
|
||||
this.assertInitialised();
|
||||
|
||||
await this.client.deleteByQuery({
|
||||
index: this.indexDefinition.indexName,
|
||||
query: {match_all: {}},
|
||||
refresh: true,
|
||||
});
|
||||
}
|
||||
|
||||
async search(query: string, filters: TFilters, options?: SearchOptions): Promise<SearchResult<TResult>> {
|
||||
this.assertInitialised();
|
||||
|
||||
const limit = options?.limit ?? options?.hitsPerPage ?? 25;
|
||||
const offset = options?.offset ?? (options?.page ? (options.page - 1) * (options.hitsPerPage ?? 25) : 0);
|
||||
|
||||
const filterClauses = compactFilters(this.buildFilters(filters));
|
||||
const sort = this.buildSort?.(filters);
|
||||
|
||||
const must: Array<Record<string, unknown>> = query
|
||||
? [{multi_match: {query, fields: this.searchableFields, type: 'best_fields'}}]
|
||||
: [{match_all: {}}];
|
||||
|
||||
const searchParams: Record<string, unknown> = {
|
||||
index: this.indexDefinition.indexName,
|
||||
query: {
|
||||
bool: {
|
||||
must,
|
||||
filter: filterClauses.length > 0 ? filterClauses : undefined,
|
||||
},
|
||||
},
|
||||
from: offset,
|
||||
size: limit,
|
||||
};
|
||||
|
||||
if (sort && sort.length > 0) {
|
||||
searchParams.sort = sort;
|
||||
}
|
||||
|
||||
const result = await this.client.search<TResult>(searchParams);
|
||||
|
||||
const totalValue = result.hits.total;
|
||||
const total = typeof totalValue === 'number' ? totalValue : (totalValue?.value ?? 0);
|
||||
const hits = result.hits.hits.map((hit) => ({...hit._source!, id: hit._id!}));
|
||||
|
||||
return {hits, total};
|
||||
}
|
||||
|
||||
private assertInitialised(): void {
|
||||
if (!this.initialized) {
|
||||
throw new Error('Elasticsearch adapter not initialised');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isResourceAlreadyExistsError(error: unknown): boolean {
|
||||
if (error == null || typeof error !== 'object') {
|
||||
return false;
|
||||
}
|
||||
const meta = (error as {meta?: {body?: {error?: {type?: string}}}}).meta;
|
||||
if (meta?.body?.error?.type === 'resource_already_exists_exception') {
|
||||
return true;
|
||||
}
|
||||
const message = (error as {message?: string}).message ?? '';
|
||||
return message.includes('resource_already_exists_exception');
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
/*
|
||||
* 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 {Client} from '@elastic/elasticsearch';
|
||||
import {ElasticsearchIndexAdapter} from '@fluxer/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
|
||||
import type {ElasticsearchFilter} from '@fluxer/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {
|
||||
compactFilters,
|
||||
esAndTerms,
|
||||
esExcludeAny,
|
||||
esTermFilter,
|
||||
esTermsFilter,
|
||||
} from '@fluxer/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@fluxer/elasticsearch_search/src/ElasticsearchIndexDefinitions';
|
||||
import type {SearchOptions, SearchResult} from '@fluxer/schema/src/contracts/search/SearchAdapterTypes';
|
||||
import type {MessageSearchFilters, SearchableMessage} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
|
||||
const DEFAULT_HITS_PER_PAGE = 25;
|
||||
const FETCH_MULTIPLIER = 3;
|
||||
|
||||
const HAS_FIELD_MAP: Record<string, string> = {
|
||||
image: 'hasImage',
|
||||
sound: 'hasSound',
|
||||
video: 'hasVideo',
|
||||
file: 'hasFile',
|
||||
sticker: 'hasSticker',
|
||||
embed: 'hasEmbed',
|
||||
link: 'hasLink',
|
||||
poll: 'hasPoll',
|
||||
snapshot: 'hasForward',
|
||||
};
|
||||
|
||||
function buildMessageFilters(filters: MessageSearchFilters): Array<ElasticsearchFilter | undefined> {
|
||||
const clauses: Array<ElasticsearchFilter | undefined> = [];
|
||||
|
||||
if (filters.guildId) {
|
||||
clauses.push(esTermFilter('guildId', filters.guildId));
|
||||
}
|
||||
|
||||
if (filters.channelId) {
|
||||
clauses.push(esTermFilter('channelId', filters.channelId));
|
||||
}
|
||||
|
||||
if (filters.channelIds && filters.channelIds.length > 0) {
|
||||
clauses.push(esTermsFilter('channelId', filters.channelIds));
|
||||
}
|
||||
|
||||
if (filters.excludeChannelIds && filters.excludeChannelIds.length > 0) {
|
||||
clauses.push(...esExcludeAny('channelId', filters.excludeChannelIds));
|
||||
}
|
||||
|
||||
if (filters.authorId && filters.authorId.length > 0) {
|
||||
clauses.push(esTermsFilter('authorId', filters.authorId));
|
||||
}
|
||||
|
||||
if (filters.excludeAuthorIds && filters.excludeAuthorIds.length > 0) {
|
||||
clauses.push(...esExcludeAny('authorId', filters.excludeAuthorIds));
|
||||
}
|
||||
|
||||
if (filters.authorType && filters.authorType.length > 0) {
|
||||
clauses.push(esTermsFilter('authorType', filters.authorType));
|
||||
}
|
||||
|
||||
if (filters.excludeAuthorType && filters.excludeAuthorType.length > 0) {
|
||||
clauses.push(...esExcludeAny('authorType', filters.excludeAuthorType));
|
||||
}
|
||||
|
||||
if (filters.mentions && filters.mentions.length > 0) {
|
||||
clauses.push(...esAndTerms('mentionedUserIds', filters.mentions));
|
||||
}
|
||||
|
||||
if (filters.excludeMentions && filters.excludeMentions.length > 0) {
|
||||
clauses.push(...esExcludeAny('mentionedUserIds', filters.excludeMentions));
|
||||
}
|
||||
|
||||
if (filters.mentionEveryone !== undefined) {
|
||||
clauses.push(esTermFilter('mentionEveryone', filters.mentionEveryone));
|
||||
}
|
||||
|
||||
if (filters.pinned !== undefined) {
|
||||
clauses.push(esTermFilter('isPinned', filters.pinned));
|
||||
}
|
||||
|
||||
if (filters.has && filters.has.length > 0) {
|
||||
for (const hasType of filters.has) {
|
||||
const field = HAS_FIELD_MAP[hasType];
|
||||
if (field) {
|
||||
clauses.push(esTermFilter(field, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.excludeHas && filters.excludeHas.length > 0) {
|
||||
for (const hasType of filters.excludeHas) {
|
||||
const field = HAS_FIELD_MAP[hasType];
|
||||
if (field) {
|
||||
clauses.push(esTermFilter(field, false));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (filters.embedType && filters.embedType.length > 0) {
|
||||
clauses.push(...esAndTerms('embedTypes', filters.embedType));
|
||||
}
|
||||
|
||||
if (filters.excludeEmbedTypes && filters.excludeEmbedTypes.length > 0) {
|
||||
clauses.push(...esExcludeAny('embedTypes', filters.excludeEmbedTypes));
|
||||
}
|
||||
|
||||
if (filters.embedProvider && filters.embedProvider.length > 0) {
|
||||
clauses.push(...esAndTerms('embedProviders', filters.embedProvider));
|
||||
}
|
||||
|
||||
if (filters.excludeEmbedProviders && filters.excludeEmbedProviders.length > 0) {
|
||||
clauses.push(...esExcludeAny('embedProviders', filters.excludeEmbedProviders));
|
||||
}
|
||||
|
||||
if (filters.linkHostname && filters.linkHostname.length > 0) {
|
||||
clauses.push(...esAndTerms('linkHostnames', filters.linkHostname));
|
||||
}
|
||||
|
||||
if (filters.excludeLinkHostnames && filters.excludeLinkHostnames.length > 0) {
|
||||
clauses.push(...esExcludeAny('linkHostnames', filters.excludeLinkHostnames));
|
||||
}
|
||||
|
||||
if (filters.attachmentFilename && filters.attachmentFilename.length > 0) {
|
||||
clauses.push(...esAndTerms('attachmentFilenames', filters.attachmentFilename));
|
||||
}
|
||||
|
||||
if (filters.excludeAttachmentFilenames && filters.excludeAttachmentFilenames.length > 0) {
|
||||
clauses.push(...esExcludeAny('attachmentFilenames', filters.excludeAttachmentFilenames));
|
||||
}
|
||||
|
||||
if (filters.attachmentExtension && filters.attachmentExtension.length > 0) {
|
||||
clauses.push(...esAndTerms('attachmentExtensions', filters.attachmentExtension));
|
||||
}
|
||||
|
||||
if (filters.excludeAttachmentExtensions && filters.excludeAttachmentExtensions.length > 0) {
|
||||
clauses.push(...esExcludeAny('attachmentExtensions', filters.excludeAttachmentExtensions));
|
||||
}
|
||||
|
||||
return compactFilters(clauses);
|
||||
}
|
||||
|
||||
function buildMessageSort(filters: MessageSearchFilters): Array<Record<string, unknown>> | undefined {
|
||||
const sortBy = filters.sortBy ?? 'timestamp';
|
||||
if (sortBy === 'relevance') {
|
||||
return undefined;
|
||||
}
|
||||
const sortOrder = filters.sortOrder ?? 'desc';
|
||||
return [{createdAt: {order: sortOrder}}];
|
||||
}
|
||||
|
||||
function getLimit(options?: SearchOptions): number {
|
||||
return options?.limit ?? options?.hitsPerPage ?? DEFAULT_HITS_PER_PAGE;
|
||||
}
|
||||
|
||||
function getOffset(options?: SearchOptions): number {
|
||||
return options?.offset ?? (options?.page ? (options.page - 1) * (options.hitsPerPage ?? DEFAULT_HITS_PER_PAGE) : 0);
|
||||
}
|
||||
|
||||
function applyMaxMinIdFilters(hits: Array<SearchableMessage>, filters: MessageSearchFilters): Array<SearchableMessage> {
|
||||
let filtered = hits;
|
||||
if (filters.maxId != null) {
|
||||
const maxId = BigInt(filters.maxId);
|
||||
filtered = filtered.filter((message) => BigInt(message.id) < maxId);
|
||||
}
|
||||
if (filters.minId != null) {
|
||||
const minId = BigInt(filters.minId);
|
||||
filtered = filtered.filter((message) => BigInt(message.id) > minId);
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
function applyExactPhraseFilter(hits: Array<SearchableMessage>, phrases: Array<string>): Array<SearchableMessage> {
|
||||
return hits.filter((hit) => {
|
||||
if (!hit.content) return false;
|
||||
return phrases.every((phrase) => hit.content!.includes(phrase));
|
||||
});
|
||||
}
|
||||
|
||||
function applySortByIdTiebreaker(
|
||||
hits: Array<SearchableMessage>,
|
||||
filters: MessageSearchFilters,
|
||||
): Array<SearchableMessage> {
|
||||
const sortBy = filters.sortBy ?? 'timestamp';
|
||||
if (sortBy === 'relevance') {
|
||||
return hits;
|
||||
}
|
||||
const sortOrder = filters.sortOrder ?? 'desc';
|
||||
return [...hits].sort((messageA, messageB) => {
|
||||
if (messageA.createdAt !== messageB.createdAt) {
|
||||
return sortOrder === 'asc' ? messageA.createdAt - messageB.createdAt : messageB.createdAt - messageA.createdAt;
|
||||
}
|
||||
const messageAId = BigInt(messageA.id);
|
||||
const messageBId = BigInt(messageB.id);
|
||||
if (sortOrder === 'asc') {
|
||||
return messageAId < messageBId ? -1 : messageAId > messageBId ? 1 : 0;
|
||||
}
|
||||
return messageBId < messageAId ? -1 : messageBId > messageAId ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
export interface ElasticsearchMessageAdapterOptions {
|
||||
client: Client;
|
||||
}
|
||||
|
||||
export class ElasticsearchMessageAdapter extends ElasticsearchIndexAdapter<MessageSearchFilters, SearchableMessage> {
|
||||
constructor(options: ElasticsearchMessageAdapterOptions) {
|
||||
super({
|
||||
client: options.client,
|
||||
index: ELASTICSEARCH_INDEX_DEFINITIONS.messages,
|
||||
searchableFields: ['content'],
|
||||
buildFilters: buildMessageFilters,
|
||||
buildSort: buildMessageSort,
|
||||
});
|
||||
}
|
||||
|
||||
override async search(
|
||||
query: string,
|
||||
filters: MessageSearchFilters,
|
||||
options?: SearchOptions,
|
||||
): Promise<SearchResult<SearchableMessage>> {
|
||||
const limit = getLimit(options);
|
||||
const offset = getOffset(options);
|
||||
|
||||
const fetchLimit = Math.max((limit + offset) * FETCH_MULTIPLIER, limit);
|
||||
|
||||
const exactPhrases = filters.exactPhrases ?? [];
|
||||
const contents = filters.contents ?? [];
|
||||
|
||||
if (contents.length > 0) {
|
||||
const resultMap = new Map<string, SearchableMessage>();
|
||||
const searchResults = await Promise.all(
|
||||
contents.map((term) =>
|
||||
super.search(
|
||||
term,
|
||||
{...filters, contents: undefined, exactPhrases: undefined},
|
||||
{...options, limit: fetchLimit, offset: 0},
|
||||
),
|
||||
),
|
||||
);
|
||||
for (const result of searchResults) {
|
||||
for (const hit of result.hits) {
|
||||
if (!resultMap.has(hit.id)) {
|
||||
resultMap.set(hit.id, hit);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mergedHits = Array.from(resultMap.values());
|
||||
mergedHits = applyMaxMinIdFilters(mergedHits, filters);
|
||||
if (exactPhrases.length > 0) {
|
||||
mergedHits = applyExactPhraseFilter(mergedHits, exactPhrases);
|
||||
}
|
||||
const sorted = applySortByIdTiebreaker(mergedHits, filters);
|
||||
return {
|
||||
hits: sorted.slice(offset, offset + limit),
|
||||
total: mergedHits.length,
|
||||
};
|
||||
}
|
||||
|
||||
if (exactPhrases.length > 0) {
|
||||
const result = await this.searchWithPhrases(query, exactPhrases, filters, {
|
||||
...options,
|
||||
limit: fetchLimit,
|
||||
offset: 0,
|
||||
});
|
||||
const filteredHits = applyMaxMinIdFilters(result.hits, filters);
|
||||
const sorted = applySortByIdTiebreaker(filteredHits, filters);
|
||||
return {
|
||||
hits: sorted.slice(offset, offset + limit),
|
||||
total: filteredHits.length,
|
||||
};
|
||||
}
|
||||
|
||||
const result = await super.search(query, filters, {...options, limit: fetchLimit, offset: 0});
|
||||
const filtered = applyMaxMinIdFilters(result.hits, filters);
|
||||
const sorted = applySortByIdTiebreaker(filtered, filters);
|
||||
return {
|
||||
hits: sorted.slice(offset, offset + limit),
|
||||
total: filtered.length,
|
||||
};
|
||||
}
|
||||
|
||||
private async searchWithPhrases(
|
||||
query: string,
|
||||
exactPhrases: Array<string>,
|
||||
filters: MessageSearchFilters,
|
||||
options?: SearchOptions,
|
||||
): Promise<SearchResult<SearchableMessage>> {
|
||||
const limit = options?.limit ?? DEFAULT_HITS_PER_PAGE;
|
||||
const offset = options?.offset ?? 0;
|
||||
|
||||
const filterClauses = compactFilters(buildMessageFilters({...filters, exactPhrases: undefined}));
|
||||
const sort = buildMessageSort(filters);
|
||||
|
||||
const must: Array<Record<string, unknown>> = [];
|
||||
|
||||
if (query) {
|
||||
must.push({multi_match: {query, fields: ['content'], type: 'best_fields'}});
|
||||
}
|
||||
|
||||
for (const phrase of exactPhrases) {
|
||||
must.push({match_phrase: {content: phrase}});
|
||||
}
|
||||
|
||||
if (must.length === 0) {
|
||||
must.push({match_all: {}});
|
||||
}
|
||||
|
||||
const searchParams: Record<string, unknown> = {
|
||||
index: 'messages',
|
||||
query: {
|
||||
bool: {
|
||||
must,
|
||||
filter: filterClauses.length > 0 ? filterClauses : undefined,
|
||||
},
|
||||
},
|
||||
from: offset,
|
||||
size: limit,
|
||||
};
|
||||
|
||||
if (sort && sort.length > 0) {
|
||||
searchParams.sort = sort;
|
||||
}
|
||||
|
||||
const result = await this.client.search<SearchableMessage>(searchParams);
|
||||
|
||||
const totalValue = result.hits.total;
|
||||
const total = typeof totalValue === 'number' ? totalValue : (totalValue?.value ?? 0);
|
||||
const hits = result.hits.hits.map((hit) => ({...hit._source!, id: hit._id!}));
|
||||
|
||||
return {hits, total};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* 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 {Client} from '@elastic/elasticsearch';
|
||||
import {ElasticsearchIndexAdapter} from '@fluxer/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
|
||||
import type {ElasticsearchFilter} from '@fluxer/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {
|
||||
compactFilters,
|
||||
esExistsFilter,
|
||||
esNotExistsFilter,
|
||||
esTermFilter,
|
||||
} from '@fluxer/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@fluxer/elasticsearch_search/src/ElasticsearchIndexDefinitions';
|
||||
import type {ReportSearchFilters, SearchableReport} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
|
||||
function buildReportFilters(filters: ReportSearchFilters): Array<ElasticsearchFilter | undefined> {
|
||||
const clauses: Array<ElasticsearchFilter | undefined> = [];
|
||||
|
||||
if (filters.reporterId) clauses.push(esTermFilter('reporterId', filters.reporterId));
|
||||
if (filters.status !== undefined) clauses.push(esTermFilter('status', filters.status));
|
||||
if (filters.reportType !== undefined) clauses.push(esTermFilter('reportType', filters.reportType));
|
||||
if (filters.category) clauses.push(esTermFilter('category', filters.category));
|
||||
if (filters.reportedUserId) clauses.push(esTermFilter('reportedUserId', filters.reportedUserId));
|
||||
if (filters.reportedGuildId) clauses.push(esTermFilter('reportedGuildId', filters.reportedGuildId));
|
||||
if (filters.reportedMessageId) clauses.push(esTermFilter('reportedMessageId', filters.reportedMessageId));
|
||||
if (filters.guildContextId) clauses.push(esTermFilter('guildContextId', filters.guildContextId));
|
||||
if (filters.resolvedByAdminId) clauses.push(esTermFilter('resolvedByAdminId', filters.resolvedByAdminId));
|
||||
|
||||
if (filters.isResolved !== undefined) {
|
||||
clauses.push(filters.isResolved ? esExistsFilter('resolvedAt') : esNotExistsFilter('resolvedAt'));
|
||||
}
|
||||
|
||||
return compactFilters(clauses);
|
||||
}
|
||||
|
||||
function buildReportSort(filters: ReportSearchFilters): Array<Record<string, unknown>> | undefined {
|
||||
const sortBy = filters.sortBy ?? 'reportedAt';
|
||||
if (sortBy === 'relevance') return undefined;
|
||||
const sortOrder = filters.sortOrder ?? 'desc';
|
||||
return [{[sortBy]: {order: sortOrder}}];
|
||||
}
|
||||
|
||||
export interface ElasticsearchReportAdapterOptions {
|
||||
client: Client;
|
||||
}
|
||||
|
||||
export class ElasticsearchReportAdapter extends ElasticsearchIndexAdapter<ReportSearchFilters, SearchableReport> {
|
||||
constructor(options: ElasticsearchReportAdapterOptions) {
|
||||
super({
|
||||
client: options.client,
|
||||
index: ELASTICSEARCH_INDEX_DEFINITIONS.reports,
|
||||
searchableFields: ['category', 'additionalInfo', 'reportedGuildName', 'reportedChannelName'],
|
||||
buildFilters: buildReportFilters,
|
||||
buildSort: buildReportSort,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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 {Client} from '@elastic/elasticsearch';
|
||||
import {ElasticsearchIndexAdapter} from '@fluxer/elasticsearch_search/src/adapters/ElasticsearchIndexAdapter';
|
||||
import type {ElasticsearchFilter} from '@fluxer/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {
|
||||
compactFilters,
|
||||
esAndTerms,
|
||||
esExistsFilter,
|
||||
esNotExistsFilter,
|
||||
esRangeFilter,
|
||||
esTermFilter,
|
||||
} from '@fluxer/elasticsearch_search/src/ElasticsearchFilterUtils';
|
||||
import {ELASTICSEARCH_INDEX_DEFINITIONS} from '@fluxer/elasticsearch_search/src/ElasticsearchIndexDefinitions';
|
||||
import type {SearchableUser, UserSearchFilters} from '@fluxer/schema/src/contracts/search/SearchDocumentTypes';
|
||||
|
||||
function buildUserFilters(filters: UserSearchFilters): Array<ElasticsearchFilter | undefined> {
|
||||
const clauses: Array<ElasticsearchFilter | undefined> = [];
|
||||
|
||||
if (filters.isBot !== undefined) clauses.push(esTermFilter('isBot', filters.isBot));
|
||||
if (filters.isSystem !== undefined) clauses.push(esTermFilter('isSystem', filters.isSystem));
|
||||
if (filters.emailVerified !== undefined) clauses.push(esTermFilter('emailVerified', filters.emailVerified));
|
||||
if (filters.emailBounced !== undefined) clauses.push(esTermFilter('emailBounced', filters.emailBounced));
|
||||
|
||||
if (filters.hasPremium !== undefined) {
|
||||
clauses.push(filters.hasPremium ? esExistsFilter('premiumType') : esNotExistsFilter('premiumType'));
|
||||
}
|
||||
if (filters.isTempBanned !== undefined) {
|
||||
clauses.push(filters.isTempBanned ? esExistsFilter('tempBannedUntil') : esNotExistsFilter('tempBannedUntil'));
|
||||
}
|
||||
if (filters.isPendingDeletion !== undefined) {
|
||||
clauses.push(
|
||||
filters.isPendingDeletion ? esExistsFilter('pendingDeletionAt') : esNotExistsFilter('pendingDeletionAt'),
|
||||
);
|
||||
}
|
||||
|
||||
if (filters.hasAcl && filters.hasAcl.length > 0) {
|
||||
clauses.push(...esAndTerms('acls', filters.hasAcl));
|
||||
}
|
||||
|
||||
if (filters.minSuspiciousActivityFlags !== undefined) {
|
||||
clauses.push(esRangeFilter('suspiciousActivityFlags', {gte: filters.minSuspiciousActivityFlags}));
|
||||
}
|
||||
|
||||
if (filters.createdAtGreaterThanOrEqual !== undefined) {
|
||||
clauses.push(esRangeFilter('createdAt', {gte: filters.createdAtGreaterThanOrEqual}));
|
||||
}
|
||||
if (filters.createdAtLessThanOrEqual !== undefined) {
|
||||
clauses.push(esRangeFilter('createdAt', {lte: filters.createdAtLessThanOrEqual}));
|
||||
}
|
||||
|
||||
return compactFilters(clauses);
|
||||
}
|
||||
|
||||
function buildUserSort(filters: UserSearchFilters): Array<Record<string, unknown>> | undefined {
|
||||
const sortBy = filters.sortBy ?? 'createdAt';
|
||||
if (sortBy === 'relevance') return undefined;
|
||||
const sortOrder = filters.sortOrder ?? 'desc';
|
||||
return [{[sortBy]: {order: sortOrder}}];
|
||||
}
|
||||
|
||||
export interface ElasticsearchUserAdapterOptions {
|
||||
client: Client;
|
||||
}
|
||||
|
||||
export class ElasticsearchUserAdapter extends ElasticsearchIndexAdapter<UserSearchFilters, SearchableUser> {
|
||||
constructor(options: ElasticsearchUserAdapterOptions) {
|
||||
super({
|
||||
client: options.client,
|
||||
index: ELASTICSEARCH_INDEX_DEFINITIONS.users,
|
||||
searchableFields: ['username', 'email', 'phone', 'id'],
|
||||
buildFilters: buildUserFilters,
|
||||
buildSort: buildUserSort,
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user