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,36 @@
import { BadRequestException, Body, Controller, Headers, Post } from '@nestjs/common';
import { SessionVerifier } from '../platform/session.verifier';
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
import { SearchService } from './search.service';
interface SearchDto { query?: string; limit?: number }
/** Message search (session-auth). Results are permission-scoped inside the service to the caller's
* own scope + threads. Reindex backfills the caller's scope (idempotent). */
@Controller('v1/search')
export class SearchController {
constructor(
private readonly search: SearchService,
private readonly session: SessionVerifier,
private readonly actors: ActorResolver,
) {}
@Post()
async run(@Body() body: SearchDto, @Headers('authorization') authorization?: string) {
const principal = this.principal(authorization);
return this.search.search(principal, { query: body.query ?? '', ...(body.limit != null ? { limit: body.limit } : {}) });
}
@Post('reindex')
async reindex(@Headers('authorization') authorization?: string) {
const principal = this.principal(authorization);
const scope = await this.actors.findScope(principal);
return { indexed: await this.search.reindexAll(scope?.id) };
}
private principal(authorization?: string): MessagePrincipal {
const token = (authorization ?? '').replace(/^Bearer\s+/i, '');
if (!token) throw new BadRequestException('Authorization bearer token is required');
return this.session.verify(token);
}
}