import { Logger } from '@nestjs/common'; import { MeiliSearch } from 'meilisearch'; import type { MessageSearchPort, SearchDoc, SearchHit } from './message-search.port'; const INDEX = 'iios_messages'; /** A no-op search port — bound when Meilisearch isn't configured, so search degrades to empty. */ export const noopMessageSearch: MessageSearchPort = { ready: () => false, index: async () => undefined, remove: async () => undefined, search: async () => [], }; /** * Meilisearch-backed message search. Configured via MEILI_URL (+ optional MEILI_KEY). The index is * created + configured on first use (searchable text/subject; filterable scopeId/threadId/source; * sortable at). Filtering by scopeId AND threadId is the permission fence — the caller only ever * passes thread ids they belong to. */ export class MeiliMessageSearch implements MessageSearchPort { private readonly client: MeiliSearch; private readonly logger = new Logger(MeiliMessageSearch.name); private settingsReady?: Promise; constructor(host: string, apiKey?: string) { this.client = new MeiliSearch({ host, ...(apiKey ? { apiKey } : {}) }); } ready(): boolean { return true; } /** Idempotently ensure the index + its attribute settings exist (runs once, memoised). */ private ensure(): Promise { if (!this.settingsReady) { this.settingsReady = (async () => { await this.client.createIndex(INDEX, { primaryKey: 'id' }).catch(() => undefined); await this.client.index(INDEX).updateSettings({ searchableAttributes: ['text', 'subject'], filterableAttributes: ['scopeId', 'threadId', 'source'], sortableAttributes: ['at'], }); })().catch((err) => { this.settingsReady = undefined; // let a later call retry throw err; }); } return this.settingsReady; } async index(docs: SearchDoc[]): Promise { if (docs.length === 0) return; await this.ensure(); await this.client.index(INDEX).addDocuments(docs, { primaryKey: 'id' }); } async remove(ids: string[]): Promise { if (ids.length === 0) return; await this.client.index(INDEX).deleteDocuments(ids); } async search(scopeId: string, threadIds: string[], query: string, limit: number): Promise { if (threadIds.length === 0 || !query.trim()) return []; await this.ensure(); const quoted = threadIds.map((t) => JSON.stringify(t)).join(', '); const res = await this.client.index(INDEX).search(query, { filter: `scopeId = ${JSON.stringify(scopeId)} AND threadId IN [${quoted}]`, limit, sort: ['at:desc'], attributesToHighlight: ['text'], highlightPreTag: '', highlightPostTag: '', attributesToCrop: ['text'], cropLength: 30, }); return res.hits.map((h) => { const doc = h as unknown as SearchDoc & { _formatted?: { text?: string } }; return { id: doc.id, threadId: doc.threadId, text: doc.text, subject: doc.subject ?? null, source: doc.source ?? null, at: doc.at, snippet: doc._formatted?.text ?? doc.text, }; }); } } /** Build the search port from env: MEILI_URL set → Meilisearch; else the no-op. */ export function messageSearchFromEnv(env: NodeJS.ProcessEnv = process.env): MessageSearchPort { const host = env.MEILI_URL?.trim(); if (!host) { new Logger('MessageSearch').log('MEILI_URL unset — message search disabled (no-op)'); return noopMessageSearch; } new Logger('MessageSearch').log(`using Meilisearch @ ${host}`); return new MeiliMessageSearch(host, env.MEILI_KEY?.trim() || undefined); }