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>
37 lines
1.5 KiB
TypeScript
37 lines
1.5 KiB
TypeScript
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);
|
|
}
|
|
}
|