feat(search): Meilisearch message search — indexer + permission-scoped query

New search module: a message index (Meilisearch, MEILI_URL-gated; no-op when unset),
a SearchProjector that indexes on message.sent, and a SearchService whose permission
fence runs server-side — a query only touches the caller's own scope AND the threads
they currently belong to (resolved live from participant rows, so membership changes
reflect immediately). Every conversation kind (chat/mail/sms) is a message with a TEXT
part, so all are searchable through one index. POST /v1/search + /reindex (backfill).
5 fence tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 13:57:52 +05:30
parent 23c43acf8f
commit 8af25def83
10 changed files with 386 additions and 0 deletions
@@ -0,0 +1,83 @@
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<SearchHit[]> {
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<void> {
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<void> {
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<number> {
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<SearchDoc | null> {
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(),
};
}
}