/*
* 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 .
*/
import type {Index, MeiliSearch} from 'meilisearch';
import type {UserID} from '~/BrandedTypes';
import {Logger} from '~/Logger';
import type {User} from '~/Models';
import {SEARCH_MAX_TOTAL_HITS} from '~/search/constants';
import {extractTimestamp} from '~/utils/SnowflakeUtils';
const USER_INDEX_NAME = 'users';
interface SearchableUser {
id: string;
username: string;
discriminator: number;
email: string | null;
phone: string | null;
isBot: boolean;
isSystem: boolean;
flags: string;
premiumType: number | null;
emailVerified: boolean;
emailBounced: boolean;
suspiciousActivityFlags: number;
acls: Array;
createdAt: number;
lastActiveAt: number | null;
tempBannedUntil: number | null;
pendingDeletionAt: number | null;
stripeSubscriptionId: string | null;
stripeCustomerId: string | null;
}
interface UserSearchFilters {
isBot?: boolean;
isSystem?: boolean;
emailVerified?: boolean;
emailBounced?: boolean;
hasPremium?: boolean;
isTempBanned?: boolean;
isPendingDeletion?: boolean;
hasAcl?: Array;
minSuspiciousActivityFlags?: number;
sortBy?: 'createdAt' | 'lastActiveAt' | 'relevance';
sortOrder?: 'asc' | 'desc';
}
export class UserSearchService {
private meilisearch: MeiliSearch;
private index: Index | null = null;
constructor(meilisearch: MeiliSearch) {
this.meilisearch = meilisearch;
}
async initialize(): Promise {
try {
this.index = this.meilisearch.index(USER_INDEX_NAME);
await this.index.updateSettings({
searchableAttributes: ['username', 'id', 'email', 'phone', 'stripeSubscriptionId', 'stripeCustomerId'],
filterableAttributes: [
'discriminator',
'isBot',
'isSystem',
'emailVerified',
'emailBounced',
'premiumType',
'suspiciousActivityFlags',
'acls',
'createdAt',
'lastActiveAt',
'tempBannedUntil',
'pendingDeletionAt',
],
sortableAttributes: ['createdAt', 'lastActiveAt', 'discriminator'],
rankingRules: ['words', 'typo', 'proximity', 'attribute', 'sort', 'exactness'],
pagination: {
maxTotalHits: SEARCH_MAX_TOTAL_HITS,
},
});
Logger.debug('User search index initialized successfully');
} catch (error) {
Logger.error({error}, 'Failed to initialize user search index');
throw error;
}
}
async indexUser(user: User): Promise {
if (!this.index) {
throw new Error('User search index not initialized');
}
const searchableUser = this.convertToSearchableUser(user);
try {
await this.index.addDocuments([searchableUser], {primaryKey: 'id'});
} catch (error) {
Logger.error({userId: user.id, error}, 'Failed to index user');
throw error;
}
}
async indexUsers(users: Array): Promise {
if (!this.index) {
throw new Error('User search index not initialized');
}
if (users.length === 0) return;
const searchableUsers = users.map((user) => this.convertToSearchableUser(user));
try {
await this.index.addDocuments(searchableUsers, {primaryKey: 'id'});
} catch (error) {
Logger.error({count: users.length, error}, 'Failed to index users');
throw error;
}
}
async updateUser(user: User): Promise {
if (!this.index) {
throw new Error('User search index not initialized');
}
const searchableUser = this.convertToSearchableUser(user);
try {
await this.index.updateDocuments([searchableUser], {primaryKey: 'id'});
} catch (error) {
Logger.error({userId: user.id, error}, 'Failed to update user in search index');
throw error;
}
}
async deleteUser(userId: UserID): Promise {
if (!this.index) {
throw new Error('User search index not initialized');
}
try {
await this.index.deleteDocument(userId.toString());
} catch (error) {
Logger.error({userId, error}, 'Failed to delete user from search index');
throw error;
}
}
async deleteUsers(userIds: Array): Promise {
if (!this.index) {
throw new Error('User search index not initialized');
}
if (userIds.length === 0) return;
try {
await this.index.deleteDocuments(userIds.map((id) => id.toString()));
} catch (error) {
Logger.error({count: userIds.length, error}, 'Failed to delete users from search index');
throw error;
}
}
async searchUsers(
query: string,
filters: UserSearchFilters,
options?: {
limit?: number;
offset?: number;
},
): Promise<{hits: Array; total: number}> {
if (!this.index) {
throw new Error('User search index not initialized');
}
const filterStrings = this.buildFilterStrings(filters);
const sortField = this.buildSortField(filters);
try {
const result = await this.index.search(query, {
filter: filterStrings.length > 0 ? filterStrings : undefined,
limit: options?.limit ?? 50,
offset: options?.offset ?? 0,
sort: sortField,
});
return {
hits: result.hits,
total: result.estimatedTotalHits ?? 0,
};
} catch (error) {
Logger.error({query, filters, error}, 'Failed to search users');
throw error;
}
}
async deleteAllDocuments(): Promise {
if (!this.index) {
throw new Error('User search index not initialized');
}
try {
await this.index.deleteAllDocuments();
Logger.debug('All user documents deleted from search index');
} catch (error) {
Logger.error({error}, 'Failed to delete all user documents');
throw error;
}
}
private buildFilterStrings(filters: UserSearchFilters): Array {
const filterStrings: Array = [];
if (filters.isBot !== undefined) {
filterStrings.push(`isBot = ${filters.isBot}`);
}
if (filters.isSystem !== undefined) {
filterStrings.push(`isSystem = ${filters.isSystem}`);
}
if (filters.emailVerified !== undefined) {
filterStrings.push(`emailVerified = ${filters.emailVerified}`);
}
if (filters.emailBounced !== undefined) {
filterStrings.push(`emailBounced = ${filters.emailBounced}`);
}
if (filters.hasPremium !== undefined) {
if (filters.hasPremium) {
filterStrings.push(`premiumType IS NOT NULL`);
} else {
filterStrings.push(`premiumType IS NULL`);
}
}
if (filters.isTempBanned !== undefined) {
if (filters.isTempBanned) {
filterStrings.push(`tempBannedUntil IS NOT NULL`);
} else {
filterStrings.push(`tempBannedUntil IS NULL`);
}
}
if (filters.isPendingDeletion !== undefined) {
if (filters.isPendingDeletion) {
filterStrings.push(`pendingDeletionAt IS NOT NULL`);
} else {
filterStrings.push(`pendingDeletionAt IS NULL`);
}
}
if (filters.hasAcl && filters.hasAcl.length > 0) {
const aclFilters = filters.hasAcl.map((acl) => `acls = "${acl}"`).join(' OR ');
filterStrings.push(`(${aclFilters})`);
}
if (filters.minSuspiciousActivityFlags !== undefined) {
filterStrings.push(`suspiciousActivityFlags >= ${filters.minSuspiciousActivityFlags}`);
}
return filterStrings;
}
private buildSortField(filters: UserSearchFilters): Array {
const sortBy = filters.sortBy ?? 'createdAt';
const sortOrder = filters.sortOrder ?? 'desc';
if (sortBy === 'relevance') {
return [];
}
return [`${sortBy}:${sortOrder}`];
}
private convertToSearchableUser(user: User): SearchableUser {
const createdAt = Math.floor(extractTimestamp(BigInt(user.id)) / 1000);
const lastActiveAt = user.lastActiveAt ? Math.floor(user.lastActiveAt.getTime() / 1000) : null;
const tempBannedUntil = user.tempBannedUntil ? Math.floor(user.tempBannedUntil.getTime() / 1000) : null;
const pendingDeletionAt = user.pendingDeletionAt ? Math.floor(user.pendingDeletionAt.getTime() / 1000) : null;
return {
id: user.id.toString(),
username: user.username,
discriminator: user.discriminator,
email: user.email,
phone: user.phone,
isBot: user.isBot,
isSystem: user.isSystem,
flags: user.flags.toString(),
premiumType: user.premiumType,
emailVerified: user.emailVerified,
emailBounced: user.emailBounced,
suspiciousActivityFlags: user.suspiciousActivityFlags,
acls: Array.from(user.acls),
createdAt,
lastActiveAt,
tempBannedUntil,
pendingDeletionAt,
stripeSubscriptionId: user.stripeSubscriptionId,
stripeCustomerId: user.stripeCustomerId,
};
}
}