import { Injectable } from '@nestjs/common'; import { SEARCH_HIGHLIGHT_END, SEARCH_HIGHLIGHT_START, SEARCH_RESULT_LIMIT, SearchQuery, SearchResultView, normalizeForSearch, } from '@dorfteich/shared'; import { Prisma, User } from '@prisma/client'; import { PermissionService } from '../permissions/permission.service'; import { PrismaService } from '../prisma/prisma.service'; import { SearchProvider } from './search.provider'; /** ts_headline options — one fragment, matches wrapped in the shared sentinels. */ const HEADLINE_OPTIONS = `StartSel=${SEARCH_HIGHLIGHT_START}, StopSel=${SEARCH_HIGHLIGHT_END}, ` + 'MaxFragments=1, MaxWords=30, MinWords=8, ShortWord=0'; /** Raw title/labels/body for one page, before normalization. */ interface IndexSource { title: string; labels: string | null; plain_text: string | null; } interface SearchRow { pageId: string; title: string; slug: string; pondId: string; pondSlug: string; pondName: string; labelIds: string[]; snippet: string; } /** * PostgreSQL full-text search (ADR 0010, issue #49). The weighted `tsvector` * (title A, labels B, body C) is stored on `page_content_cache.search_vector` * and maintained here (and by the collab persistence hook on content changes); * both fold text through {@link normalizeForSearch} before `to_tsvector` so * matching is diacritic-insensitive without the `unaccent` extension. Queries * are folded the same way; results are filtered to the ponds the user may read. */ @Injectable() export class PostgresSearchProvider extends SearchProvider { constructor( private readonly prisma: PrismaService, private readonly permissions: PermissionService, ) { super(); } async indexPage(pageId: string): Promise { const rows = await this.prisma.$queryRaw` SELECT p.title, c.plain_text, (SELECT string_agg(l.name, ' ') FROM page_labels pl JOIN labels l ON l.id = pl.label_id WHERE pl.page_id = p.id) AS labels FROM pages p LEFT JOIN page_content_cache c ON c.page_id = p.id WHERE p.id = ${pageId}`; const source = rows[0]; if (!source) return; await this.writeVector(pageId, source); } async removePage(pageId: string): Promise { await this.prisma .$executeRaw`UPDATE page_content_cache SET search_vector = NULL WHERE page_id = ${pageId}`; } async removePond(pondId: string): Promise { await this.prisma.$executeRaw` UPDATE page_content_cache SET search_vector = NULL WHERE page_id IN (SELECT id FROM pages WHERE pond_id = ${pondId})`; } async reindexPond(pondId: string): Promise { const sources = await this.prisma.$queryRaw<(IndexSource & { page_id: string })[]>` SELECT p.id AS page_id, p.title, c.plain_text, (SELECT string_agg(l.name, ' ') FROM page_labels pl JOIN labels l ON l.id = pl.label_id WHERE pl.page_id = p.id) AS labels FROM page_content_cache c JOIN pages p ON p.id = c.page_id WHERE p.pond_id = ${pondId} AND p.deleted_at IS NULL`; for (const source of sources) await this.writeVector(source.page_id, source); return sources.length; } async reindexAll(): Promise { // Converge to the issue-#195 invariant: trashed content leaves the // index entirely, live content is rebuilt. await this.prisma.$executeRaw` UPDATE page_content_cache c SET search_vector = NULL FROM pages p LEFT JOIN ponds po ON po.id = p.pond_id WHERE p.id = c.page_id AND (p.deleted_at IS NOT NULL OR po.deleted_at IS NOT NULL)`; const sources = await this.prisma.$queryRaw<(IndexSource & { page_id: string })[]>` SELECT p.id AS page_id, p.title, c.plain_text, (SELECT string_agg(l.name, ' ') FROM page_labels pl JOIN labels l ON l.id = pl.label_id WHERE pl.page_id = p.id) AS labels FROM page_content_cache c JOIN pages p ON p.id = c.page_id JOIN ponds po ON po.id = p.pond_id AND po.deleted_at IS NULL WHERE p.deleted_at IS NULL`; for (const source of sources) await this.writeVector(source.page_id, source); return sources.length; } /** Writes the weighted, normalized vector for one page (shared by index/reindex). */ private async writeVector(pageId: string, source: IndexSource): Promise { const title = normalizeForSearch(source.title ?? ''); const labels = normalizeForSearch(source.labels ?? ''); const body = normalizeForSearch(source.plain_text ?? ''); await this.prisma.$executeRaw` UPDATE page_content_cache SET search_vector = setweight(to_tsvector('simple', ${title}), 'A') || setweight(to_tsvector('simple', ${labels}), 'B') || setweight(to_tsvector('simple', ${body}), 'C') WHERE page_id = ${pageId}`; } async search(query: SearchQuery, user: User): Promise { const normalized = normalizeForSearch(query.q); // A phrase that folds to nothing (e.g. only punctuation) matches nothing. if (normalized.trim() === '') return []; // Substring fallback (M10 follow-up): the tsquery only matches whole // words, so a plain LIKE over title/body catches partial words too. The // pattern keeps the raw (lowercased) input — umlauts etc. match verbatim; // diacritic-insensitive matching stays the FTS branch's job. FTS hits // still rank first (ts_rank is 0 for LIKE-only matches). const likePattern = `%${query.q .trim() .toLowerCase() .replace(/[\\%_]/g, (char) => `\\${char}`)}%`; // Pond-level prefilter (visible ponds only) keeps the LIMIT meaningful; // the exact per-page resolution happens below (issue #52, ADR 0010). const visiblePondIds = await this.permissions.visiblePondIds(user); if (visiblePondIds !== null && visiblePondIds.length === 0) return []; const visibility = visiblePondIds === null ? Prisma.empty : Prisma.sql`AND p.pond_id = ANY(${visiblePondIds}::text[])`; const scope = query.pondId ? Prisma.sql`AND p.pond_id = ${query.pondId}` : Prisma.empty; const labelFilter = query.labels && query.labels.length > 0 ? Prisma.sql`AND EXISTS (SELECT 1 FROM page_labels pl WHERE pl.page_id = p.id AND pl.label_id = ANY(${query.labels}))` : Prisma.empty; const rows = await this.prisma.$queryRaw(Prisma.sql` SELECT p.id AS "pageId", p.title, p.slug, p.pond_id AS "pondId", po.slug AS "pondSlug", po.name AS "pondName", COALESCE( ARRAY(SELECT pl.label_id FROM page_labels pl WHERE pl.page_id = p.id), '{}' ) AS "labelIds", ts_headline('simple', c.plain_text, websearch_to_tsquery('simple', ${query.q}), ${HEADLINE_OPTIONS}) AS snippet FROM page_content_cache c JOIN pages p ON p.id = c.page_id AND p.deleted_at IS NULL JOIN ponds po ON po.id = p.pond_id AND po.deleted_at IS NULL, websearch_to_tsquery('simple', ${normalized}) q WHERE (c.search_vector @@ q OR lower(p.title) LIKE ${likePattern} OR lower(c.plain_text) LIKE ${likePattern}) ${visibility} ${scope} ${labelFilter} ORDER BY ts_rank(c.search_vector, q) DESC, p.updated_at DESC LIMIT ${SEARCH_RESULT_LIMIT}`); // Per-page resolution (label-/page-scope grants, deny-wins) per pond. const rowsByPond = new Map(); for (const row of rows) { const group = rowsByPond.get(row.pondId) ?? []; group.push(row); rowsByPond.set(row.pondId, group); } const readableByPond = new Map>(); for (const [pondId, group] of rowsByPond) { readableByPond.set( pondId, await this.permissions.filterPages( user, pondId, group.map((row) => ({ id: row.pageId, labelIds: row.labelIds })), 'read', ), ); } return rows .filter((row) => readableByPond.get(row.pondId)?.has(row.pageId)) .map((row) => ({ pageId: row.pageId, title: row.title, slug: row.slug, pondId: row.pondId, pondSlug: row.pondSlug, pondName: row.pondName, labelIds: row.labelIds, snippet: row.snippet, })); } }