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:
@@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user