This repository has no description
Something went wrong. Try again.
TypeScript
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248// SPDX-License-Identifier: AGPL-3.0-or-later
import type {WorkerJobPayload} from '@pkgs/worker/src/contracts/WorkerTypes';import type {ApiContext} from '../../ApiContext';import {createGuildID, createUserID, type UserID} from '../../BrandedTypes';import type {IGuildRepositoryAggregate} from '../../guild/repositories/IGuildRepositoryAggregate';import {Logger} from '../../Logger';import {getGuildSearchService, getUserSearchService} from '../../SearchFactory';import {mapGuildToAdminResponse} from '../models/GuildTypes';import {mapUserToAdminResponse} from '../models/UserTypes';import type {AdminAuditService} from './AdminAuditService';
interface RefreshSearchIndexJobPayload extends WorkerJobPayload { index_type: | 'guilds' | 'users' | 'reports' | 'audit_logs' | 'channel_messages' | 'favorite_memes' | 'guild_members' | 'discovery'; admin_user_id: string; audit_log_reason: string | null; job_id: string; guild_id?: string; user_id?: string;}
interface AdminSearchServiceDeps { apiContext: ApiContext; guildRepository: IGuildRepositoryAggregate; auditService: AdminAuditService;}
export class AdminSearchService { constructor(private readonly deps: AdminSearchServiceDeps) {}
async searchGuilds(data: {query?: string; limit: number; offset: number}) { const {guildRepository} = this.deps; Logger.debug( {query: data.query, limit: data.limit, offset: data.offset}, '[AdminSearchService] searchGuilds called', ); const guildSearchService = getGuildSearchService(); if (!guildSearchService) { Logger.error('[AdminSearchService] searchGuilds - Search service not enabled'); throw new Error('Search is not enabled'); } const query = data.query?.trim() || ''; const isIdQuery = /^\d+$/.test(query); Logger.debug('[AdminSearchService] searchGuilds - Calling search service'); const [searchResult, directGuild] = await Promise.all([ guildSearchService.searchGuilds( query, {sortBy: 'createdAt', sortOrder: 'asc'}, {limit: data.limit, offset: data.offset}, ), isIdQuery && data.offset === 0 ? guildRepository.findUnique(createGuildID(BigInt(query))).catch(() => null) : Promise.resolve(null), ]); const {hits, total} = searchResult; const guildIds = hits.map((hit) => createGuildID(BigInt(hit.id))); Logger.debug( {guild_ids: guildIds.map((id) => id.toString())}, '[AdminSearchService] searchGuilds - Fetching from DB', ); const guilds = await guildRepository.listGuilds(guildIds); Logger.debug({guilds_count: guilds.length}, '[AdminSearchService] searchGuilds - Got guilds from DB'); const orderIndex = new Map<string, number>(); guildIds.forEach((id, idx) => orderIndex.set(id.toString(), idx)); guilds.sort((a, b) => (orderIndex.get(a.id.toString()) ?? 0) - (orderIndex.get(b.id.toString()) ?? 0)); const response = guilds.map((guild) => mapGuildToAdminResponse(guild)); if (directGuild && data.offset === 0) { const directId = directGuild.id.toString(); if (!response.some((g) => g.id === directId)) { response.unshift(mapGuildToAdminResponse(directGuild)); } } Logger.debug({response_count: response.length}, '[AdminSearchService] searchGuilds - Mapped to response'); return { guilds: response, total: directGuild && !hits.some((h) => h.id === query) ? total + 1 : total, }; }
async searchUsers( data: { query?: string; email?: string; last_active_ip?: string; limit: number; offset: number; }, acls: ReadonlySet<string>, ) { const {users: userRepository, cache: cacheService} = this.deps.apiContext.services; const email = data.email?.trim(); if (email) { const user = await userRepository.findByEmail(email); if (!user) { return {users: [], total: 0}; } const response = await mapUserToAdminResponse(user, cacheService, acls); return {users: [response], total: 1}; } const lastActiveIp = data.last_active_ip?.trim(); if (lastActiveIp) { const {userIds, total} = await userRepository.listUserIdsByLastActiveIp(lastActiveIp, data.limit, data.offset); const users = await userRepository.listUsers(userIds); const usersById = new Map(users.map((user) => [user.id.toString(), user])); const orderedUsers = []; for (const userId of userIds) { const user = usersById.get(userId.toString()); if (user) { orderedUsers.push(user); } } const response = await Promise.all(orderedUsers.map((user) => mapUserToAdminResponse(user, cacheService, acls))); return { users: response, total, }; } const userSearchService = getUserSearchService(); if (!userSearchService) { throw new Error('Search is not enabled'); } const query = data.query?.trim() || ''; const isIdQuery = /^\d+$/.test(query); const [searchResult, directUser] = await Promise.all([ userSearchService.search(query, {}, {limit: data.limit, offset: data.offset}), isIdQuery && data.offset === 0 ? userRepository.findUnique(createUserID(BigInt(query))).catch(() => null) : Promise.resolve(null), ]); const {hits, total} = searchResult; const userIds = hits.map((hit) => createUserID(BigInt(hit.id))); const users = await userRepository.listUsers(userIds); const response = await Promise.all(users.map((user) => mapUserToAdminResponse(user, cacheService, acls))); if (directUser && data.offset === 0) { const directId = directUser.id.toString(); if (!response.some((u) => u.id === directId)) { response.unshift(await mapUserToAdminResponse(directUser, cacheService, acls)); } } return { users: response, total: directUser && !hits.some((h) => h.id === query) ? total + 1 : total, }; }
async refreshSearchIndex( data: { index_type: | 'guilds' | 'users' | 'reports' | 'audit_logs' | 'channel_messages' | 'guild_members' | 'favorite_memes' | 'discovery'; guild_id?: bigint; user_id?: bigint; }, adminUserId: UserID, auditLogReason: string | null, ) { const {auditService} = this.deps; const {worker: workerService, snowflake: snowflakeService} = this.deps.apiContext.services; const jobId = (await snowflakeService.generate()).toString(); const payload: RefreshSearchIndexJobPayload = { index_type: data.index_type, admin_user_id: adminUserId.toString(), audit_log_reason: auditLogReason, job_id: jobId, }; if (data.index_type === 'channel_messages') { if (!data.guild_id) { throw new Error('guild_id is required for the channel_messages index type'); } payload.guild_id = data.guild_id.toString(); } if (data.index_type === 'guild_members') { if (!data.guild_id) { throw new Error('guild_id is required for the guild_members index type'); } payload.guild_id = data.guild_id.toString(); } if (data.index_type === 'favorite_memes') { if (!data.user_id) { throw new Error('user_id is required for favorite_memes index type'); } payload.user_id = data.user_id.toString(); } await workerService.addJob('refreshSearchIndex', payload, { jobKey: `refreshSearchIndex_${data.index_type}_${jobId}`, maxAttempts: 1, }); Logger.debug({index_type: data.index_type, job_id: jobId}, 'Queued search index refresh job'); const metadata = new Map([ ['index_type', data.index_type], ['job_id', jobId], ]); if (data.guild_id) { metadata.set('guild_id', data.guild_id.toString()); } if (data.user_id) { metadata.set('user_id', data.user_id.toString()); } await auditService.createAuditLog({ adminUserId, targetType: 'search_index', targetId: BigInt(0), action: 'queue_refresh_index', auditLogReason, metadata, }); return { success: true, job_id: jobId, }; }
async getIndexRefreshStatus(jobId: string) { const {cache: cacheService} = this.deps.apiContext.services; const statusKey = `index_refresh_status:${jobId}`; const status = await cacheService.get<{ status: 'in_progress' | 'completed' | 'failed'; index_type: string; total?: number; indexed?: number; started_at?: string; completed_at?: string; failed_at?: string; error?: string; }>(statusKey); if (!status) { return { status: 'not_found' as const, }; } return status; }}