import { Inject, Injectable } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver'; import { MESSAGE_SEARCH_PORT, type MessageSearchPort, type SearchDoc, type SearchHit } from './message-search.port'; /** * Message search. The permission fence lives HERE: a query only ever runs against the caller's own * scope AND the set of threads the caller currently belongs to (resolved live from participant * rows, so membership changes are reflected immediately). The engine never sees a thread the caller * isn't in. Text is drawn from the message's TEXT part; every conversation kind (chat, mail, sms…) * is a message with parts, so all are searchable through one index. */ @Injectable() export class SearchService { constructor( @Inject(MESSAGE_SEARCH_PORT) private readonly engine: MessageSearchPort, private readonly prisma: PrismaService, private readonly actors: ActorResolver, ) {} async search(principal: MessagePrincipal, opts: { query: string; limit?: number }): Promise { const q = (opts.query ?? '').trim(); if (!q || !this.engine.ready()) return []; const scope = await this.actors.findScope(principal); if (!scope) return []; const actor = await this.actors.resolveActor(scope.id, principal); const memberships = await this.prisma.iiosThreadParticipant.findMany({ where: { actorId: actor.id }, select: { threadId: true } }); const threadIds = memberships.map((m) => m.threadId); if (threadIds.length === 0) return []; return this.engine.search(scope.id, threadIds, q, Math.min(opts.limit ?? 20, 50)); } /** Build + index the search doc for one interaction (called by the projector on message.sent). */ async indexInteraction(interactionId: string): Promise { if (!this.engine.ready()) return; const doc = await this.buildDoc(interactionId); if (doc) await this.engine.index([doc]); } /** Remove an interaction from the index (retention / redaction hook). */ async removeInteraction(interactionId: string): Promise { if (this.engine.ready()) await this.engine.remove([interactionId]); } /** Backfill: (re)index every text message, optionally for one scope. Returns the count indexed. */ async reindexAll(scopeId?: string, batch = 500): Promise { if (!this.engine.ready()) return 0; const interactions = await this.prisma.iiosInteraction.findMany({ where: { ...(scopeId ? { scopeId } : {}), threadId: { not: null }, parts: { some: { kind: 'TEXT' } } }, select: { id: true }, }); let indexed = 0; for (let i = 0; i < interactions.length; i += batch) { const docs = (await Promise.all(interactions.slice(i, i + batch).map((x) => this.buildDoc(x.id)))).filter((d): d is SearchDoc => d !== null); if (docs.length > 0) { await this.engine.index(docs); indexed += docs.length; } } return indexed; } private async buildDoc(interactionId: string): Promise { const i = await this.prisma.iiosInteraction.findUnique({ where: { id: interactionId }, include: { parts: { where: { kind: 'TEXT' }, take: 1 }, thread: true }, }); if (!i || !i.threadId || !i.thread) return null; const text = i.parts[0]?.bodyText?.trim(); if (!text) return null; // nothing searchable const meta = (i.thread.metadata as { source?: string } | null) ?? {}; return { id: i.id, scopeId: i.scopeId, threadId: i.threadId, text, subject: i.thread.subject ?? null, source: meta.source ?? null, actorId: i.actorId ?? null, at: i.occurredAt.getTime(), }; } }