diff --git a/apps/api/src/search/postgres-search.provider.ts b/apps/api/src/search/postgres-search.provider.ts index 8202037..4106bbc 100644 --- a/apps/api/src/search/postgres-search.provider.ts +++ b/apps/api/src/search/postgres-search.provider.ts @@ -103,6 +103,16 @@ export class PostgresSearchProvider extends SearchProvider { // 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); @@ -132,7 +142,9 @@ export class PostgresSearchProvider extends SearchProvider { 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 + WHERE (c.search_vector @@ q + OR lower(p.title) LIKE ${likePattern} + OR lower(c.plain_text) LIKE ${likePattern}) ${visibility} ${scope} ${labelFilter} diff --git a/apps/api/src/search/search.service.db.test.ts b/apps/api/src/search/search.service.db.test.ts index 4aa32f3..7e73ebd 100644 --- a/apps/api/src/search/search.service.db.test.ts +++ b/apps/api/src/search/search.service.db.test.ts @@ -102,6 +102,21 @@ describe.skipIf(!hasTestDb)('PostgresSearchProvider (db, issue #49)', () => { expect(results.map((r) => r.pageId)).toContain(page); }); + it('matches partial words in title and body (M10 follow-up)', async () => { + const titlePage = await makePage(`Quakfrosch${suffix} Titel`, 'nichts weiter'); + const bodyPage = await makePage('anderer Titel', `hier lebt ein Teichmolch${suffix}`); + + // A mid-word fragment matches nothing via the tsquery — the LIKE branch + // has to find both pages (title and body). + const byTitle = await search.search({ q: `akfrosch${suffix}` }, owner); + expect(byTitle.map((r) => r.pageId)).toContain(titlePage); + const byBody = await search.search({ q: `eichmolch${suffix}` }, owner); + expect(byBody.map((r) => r.pageId)).toContain(bodyPage); + + // The outsider still sees nothing through the substring branch. + expect(await search.search({ q: `eichmolch${suffix}` }, outsider)).toEqual([]); + }); + it('never returns pages the requester may not read', async () => { const results = await search.search({ q: term }, outsider); expect(results).toEqual([]); diff --git a/apps/api/src/versions/versions.service.ts b/apps/api/src/versions/versions.service.ts index 7fc196d..3635f85 100644 --- a/apps/api/src/versions/versions.service.ts +++ b/apps/api/src/versions/versions.service.ts @@ -45,7 +45,7 @@ export class VersionsService { this.logger.setContext(VersionsService.name); } - viewOf(version: Omit): PageVersionView { + viewOf(version: Omit, names: Map): PageVersionView { return { id: version.id, pageId: version.pageId, @@ -53,10 +53,26 @@ export class VersionsService { label: version.label, createdBy: version.createdBy, contributorIds: version.contributorIds, + contributors: version.contributorIds + .filter((id) => names.has(id)) + .map((id) => ({ id, name: names.get(id)! })), createdAt: version.createdAt.toISOString(), }; } + /** Display names for every contributor across `versions` (deleted users drop out). */ + private async contributorNames( + versions: { contributorIds: string[] }[], + ): Promise> { + const ids = [...new Set(versions.flatMap((version) => version.contributorIds))]; + if (ids.length === 0) return new Map(); + const users = await this.prisma.user.findMany({ + where: { id: { in: ids } }, + select: { id: true, displayName: true }, + }); + return new Map(users.map((user) => [user.id, user.displayName])); + } + /** * Load a live page. Viewing history requires the same permission as * editing (ADR 0013) — the guard enforces write access on every history @@ -77,7 +93,8 @@ export class VersionsService { // Exclude the (potentially large) snapshot bytes from the list. omit: { ydocSnapshot: true }, }); - return versions.map((version) => this.viewOf(version)); + const names = await this.contributorNames(versions); + return versions.map((version) => this.viewOf(version, names)); } /** A single version rendered read-only (HTML) with its Markdown for diffing. */ @@ -92,7 +109,8 @@ export class VersionsService { }); if (!version) throw new NotFoundException(); const derived = deriveContent(new Uint8Array(version.ydocSnapshot)); - return { ...this.viewOf(version), html: derived.html, markdown: derived.markdown }; + const names = await this.contributorNames([version]); + return { ...this.viewOf(version, names), html: derived.html, markdown: derived.markdown }; } /** @@ -118,7 +136,7 @@ export class VersionsService { { event: 'audit: version restore requested', pageId, versionId, userId: user.id }, 'version restore requested', ); - return this.viewOf(version); + return this.viewOf(version, await this.contributorNames([version])); } /** @@ -159,7 +177,7 @@ export class VersionsService { ); // A named snapshot is a meaningful change unit — notify watchers (#94). await this.notifications.fanoutPageEvent('page_changed', pageId, [user.id]); - return this.viewOf(created); + return this.viewOf(created, await this.contributorNames([created])); } /** Reconstruct the page's full current Yjs state (base + update log). */ diff --git a/apps/web/src/access/AccessRulesManager.tsx b/apps/web/src/access/AccessRulesManager.tsx index e208256..e91674a 100644 --- a/apps/web/src/access/AccessRulesManager.tsx +++ b/apps/web/src/access/AccessRulesManager.tsx @@ -8,6 +8,7 @@ import type { import { isRuleShadowed } from '@dorfteich/shared'; import { useQuery } from '@tanstack/react-query'; import type { TFunction } from 'i18next'; +import { Plus, Trash2 } from 'lucide-react'; import { useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -237,8 +238,14 @@ export function AccessRulesManager({ pondId }: { pondId: string }): React.JSX.El {error}
{t('picker.empty')} {t('picker.manageHint')}
{t('picker.empty')}