8af25def83
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>
84 lines
3.7 KiB
TypeScript
84 lines
3.7 KiB
TypeScript
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(),
|
|
};
|
|
}
|
|
}
|