Merge branch 'v1422/search' into release/v1.42.2

This commit is contained in:
Marc-André Bombeck committed 2026-10-11 13:06:07 +02:00
commit 334cb4e979
40 files changed
+3913 -326

No files matched your search

+194 -17
View File
@@ -23728,6 +23728,51 @@ paths:
"404": *a69
"422": *a4
"429": *a5
/api/search:
get:
tags:
- Records
summary: Search the record
description: "One search over the record: visits and procedures, conditions, documents (title, file name and indexed
text), vaccinations, lab results (analyte names and notes), medications, symptoms, workouts by sport, and the
notes on mood entries, readings and illness days. Hits are grouped by kind, the best group first, and each
carries the in-app path that opens the record. Matching ignores case and accents and reads German umlauts typed
as ae, oe, ue or ss. A source whose module the record has switched off is not read. The search reads each source
newest first under a per-source row cap and a time budget; `truncated` says when that cut it short. The query is
never logged. Rate limited per acting account (60 a minute). A grant scoped to some sections of the record does
not open it, like the timeline."
parameters:
- in: query
name: q
schema:
type: string
minLength: 2
maxLength: 100
description: What to look for. Matched without regard to case or accents, and German umlauts may be typed as ae, oe, ue
or ss.
required: true
description: What to look for. Matched without regard to case or accents, and German umlauts may be typed as ae, oe, ue
or ss.
- in: query
name: limit
schema:
default: 5
description: Most hits per group.
type: integer
minimum: 1
maximum: 20
description: Most hits per group.
responses:
"200":
description: The hits, grouped by kind.
content:
application/json:
schema:
$ref: "#/components/schemas/SearchEnvelope"
"401": *a1
"403": *a11
"422": *a4
"429": *a5
/api/bugreport:
post:
security: []
@@ -34447,23 +34492,24 @@ components:
profile.update.invalidBody, profile.update.nothingSaved; profile_claim — profile_claim.invalid,
profile_claim.oidc_only_unsupported, profile_claim.taken; record_write — record_write.rate_limited;
report-selection — report-selection.body.invalid_json, report-selection.body.invalid_shape,
report-selection.leaves.unknown; route — route.retired; share-link — share-link.document.decryptFailed,
share-link.document.notFound, share-link.document.rateLimited, share-link.documents.invalid,
share-link.report.notFound, share-link.report.rateLimited, share-link.selection.forbidden_leaf,
share-link.selection.unknown_leaf; sharing — sharing.accept.expired, sharing.accept.not_found,
sharing.accept.not_pending, sharing.accept.revoked, sharing.access.denied, sharing.invite.duplicate,
sharing.invite.invalid, sharing.invite.invalid_scope, sharing.invite.manage_browser_only,
sharing.invite.refused, sharing.invite.self, sharing.not_permitted, sharing.renounce.already_ended,
sharing.renounce.not_found, sharing.revoke.already_ended, sharing.revoke.not_found,
sharing.session.changed, sharing.switch.invalid, sharing.switch.wrong_transport; source-priority —
source-priority.body.invalid_json, source-priority.body.invalid_shape; symptoms —
symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen,
symptoms.event.notFound, symptoms.invalid; timeline — timeline.invalid; tokens —
tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached, tokens.workouts.ceiling_reached;
vaccination — vaccination.booster-invalid, vaccination.booster-no-antigen,
vaccination.booster-out-of-scope, vaccination.custom-vaccine-not-found, vaccination.custom.invalid,
vaccination.custom.name-taken, vaccination.custom.not-found, vaccination.encounter-not-found,
vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found,
report-selection.leaves.unknown; route — route.retired; search — search.invalid; share-link —
share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited,
share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited,
share-link.selection.forbidden_leaf, share-link.selection.unknown_leaf; sharing —
sharing.accept.expired, sharing.accept.not_found, sharing.accept.not_pending, sharing.accept.revoked,
sharing.access.denied, sharing.invite.duplicate, sharing.invite.invalid, sharing.invite.invalid_scope,
sharing.invite.manage_browser_only, sharing.invite.refused, sharing.invite.self, sharing.not_permitted,
sharing.renounce.already_ended, sharing.renounce.not_found, sharing.revoke.already_ended,
sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid,
sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json,
source-priority.body.invalid_shape; symptoms — symptoms.definition.limitReached,
symptoms.definition.notFound, symptoms.episode.notOpen, symptoms.event.notFound, symptoms.invalid;
timeline — timeline.invalid; tokens — tokens.documents.ceiling_reached,
tokens.measurements.ceiling_reached, tokens.workouts.ceiling_reached; vaccination —
vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope,
vaccination.custom-vaccine-not-found, vaccination.custom.invalid, vaccination.custom.name-taken,
vaccination.custom.not-found, vaccination.encounter-not-found, vaccination.identity-required,
vaccination.invalid, vaccination.link.invalid, vaccination.not-found,
vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large,
workout.batch.source_not_permitted, workout.batch.too_large, workout.delete.synced_source; (unprefixed)
— about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file,
@@ -82317,6 +82363,137 @@ components:
- data
- error
additionalProperties: false
SearchEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/SearchResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
SearchResponse:
type: object
properties:
groups:
type: array
items:
type: object
properties:
kind:
type: string
enum:
- visit
- condition
- document
- vaccination
- lab
- medication
- symptom
- workout
- note
hits:
type: array
items:
$ref: "#/components/schemas/SearchHit"
more:
type: boolean
required:
- kind
- hits
- more
additionalProperties: false
truncated:
type: boolean
description: The search stopped before it had read every entry (a per-kind row cap or the time budget), so older entries
may match and are not listed.
required:
- groups
- truncated
additionalProperties: false
SearchHit:
type: object
properties:
id:
type: string
kind:
type: string
enum:
- visit
- condition
- document
- vaccination
- lab
- medication
- symptom
- workout
- note
title:
anyOf:
- $ref: "#/components/schemas/SearchMarkedText"
- type: "null"
context:
type: array
items:
$ref: "#/components/schemas/SearchMarkedText"
snippet:
anyOf:
- $ref: "#/components/schemas/SearchMarkedText"
- type: "null"
day:
anyOf:
- type: string
- type: "null"
path:
type: string
description: In-app path that opens the record, never an API path.
source:
anyOf:
- type: string
enum:
- mood
- measurement
- illness
- type: "null"
required:
- id
- kind
- title
- context
- snippet
- day
- path
- source
additionalProperties: false
SearchMarkedText:
type: object
properties:
text:
type: string
marks:
type: array
items:
type: array
prefixItems:
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
- type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: Half-open [start, end) character ranges of `text` a query word matched.
required:
- text
- marks
additionalProperties: false
__schema0:
anyOf:
- type: string
+108 -1
View File
@@ -15,7 +15,11 @@ import { expect, test } from "./setup/test";
* - the top bar carries a magnifier at every width, named "Search",
* with a tooltip that gives the shortcut; on a phone it opens a
* full-height sheet with the field focused;
* - the open palette passes axe.
* - the open palette passes axe;
* - a hit from the record search ("In your records", answered by a route
* mock so the shared account's records never move) lists after the local
* results, Enter on it opens its page, and a visit lands on the visits
* tab; on a phone the hit fits the width.
*
* Stable hooks only: `command-palette` (+ `data-variant`),
* `command-palette-input`, `command-palette-trigger`,
@@ -26,6 +30,49 @@ import { expect, test } from "./setup/test";
test.use({ storageState: STORAGE_STATE_PATH });
const palette = (page: Page) => page.locator('[data-slot="command-palette"]');
/** Answer `GET /api/search` with one visit hit, and count the requests. */
async function mockRecordSearch(page: Page): Promise<string[]> {
const queries: string[] = [];
await page.route("**/api/search?*", async (route) => {
queries.push(new URL(route.request().url()).searchParams.get("q") ?? "");
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: {
groups: [
{
kind: "visit",
more: false,
hits: [
{
id: "visit:e2e-visit",
kind: "visit",
title: { text: "Arthroscopy", marks: [] },
context: [
{ text: "Shoulder (left)", marks: [[0, 8]] },
{ text: "A practitioner with a long name", marks: [] },
],
snippet: {
text: "Follow-up for the shoulder after the operation, physiotherapy twice a week",
marks: [[18, 26]],
},
day: "2026-03-02",
path: "/checkups?visit=e2e-visit",
source: null,
},
],
},
],
truncated: false,
},
error: null,
}),
});
});
return queries;
}
const input = (page: Page) =>
page.locator('[data-slot="command-palette-input"]');
@@ -110,6 +157,47 @@ test.describe("command palette", () => {
await expect(input(page)).toHaveValue("timeline");
});
test("a hit from the record opens its page", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
const queries = await mockRecordSearch(page);
// Start on the page the hit opens, on another tab: the link has to move
// the open page, not only a fresh one.
await page.goto("/checkups");
await shellReady(page);
await expect(
page.locator('[data-testid="checkups-tab-visits"]'),
).toHaveAttribute("aria-selected", "false");
await page.keyboard.press("ControlOrMeta+k");
await input(page).fill("shoulder");
const hit = page.locator('[data-entry="record:visit:e2e-visit"]');
await expect(hit).toBeVisible();
// Asked once the typing paused, not once per letter.
expect(queries).toEqual(["shoulder"]);
await expect(
page.locator('[data-group="records"] mark').first(),
).toHaveText("Shoulder");
const axe = await new AxeBuilder({ page })
.include('[data-slot="command-palette"]')
.withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"])
.analyze();
expect(axe.violations).toEqual([]);
// The arrows reach it after the local options, and Enter opens it.
for (let i = 0; i < 40; i++) {
const active = await input(page).getAttribute("aria-activedescendant");
if (active && (await hit.getAttribute("id")) === active) break;
await page.keyboard.press("ArrowDown");
}
await expect(hit).toHaveAttribute("aria-selected", "true");
await page.keyboard.press("Enter");
await expect(page).toHaveURL(/\/checkups\?visit=e2e-visit$/);
await expect(palette(page)).toHaveCount(0);
await expect(
page.locator('[data-testid="checkups-tab-visits"]'),
).toHaveAttribute("aria-selected", "true");
});
test("the top bar: a magnifier on a wide desktop, with the shortcut in its tooltip", async ({
page,
}) => {
@@ -161,4 +249,23 @@ test.describe("command palette", () => {
await input(page).fill("weight");
await expect(page.locator('[data-entry="insights:weight"]')).toBeVisible();
});
test("on a phone: a hit from the record fits the width", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await mockRecordSearch(page);
await page.goto("/");
await shellReady(page);
await page.locator('[data-slot="command-palette-trigger"]').click();
await input(page).fill("shoulder");
const hit = page.locator('[data-entry="record:visit:e2e-visit"]');
await expect(hit).toBeVisible();
const box = await hit.boundingBox();
expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(390);
const overflow = await page
.locator('[data-slot="command-palette-results"]')
.evaluate((el) => el.scrollWidth - el.clientWidth);
expect(overflow).toBeLessThanOrEqual(0);
await hit.click();
await expect(page).toHaveURL(/\/checkups\?visit=e2e-visit$/);
});
});
+26 -3
View File
@@ -12587,8 +12587,8 @@
},
"palette": {
"title": "Suche",
"description": "Finde eine Seite, eine Einstellung, ein Modul oder eine Aktion.",
"placeholder": "Seiten, Einstellungen, Module suchen…",
"description": "Finde eine Seite, eine Einstellung, ein Modul, eine Aktion oder einen Eintrag in deinen Daten.",
"placeholder": "Seiten, Einstellungen, deine Einträge suchen…",
"results": "Treffer",
"noResults": "Nichts gefunden für „{query}“.",
"moduleOn": "An",
@@ -12602,7 +12602,8 @@
"insights": "Insights",
"settings": "Einstellungen",
"modules": "Module",
"actions": "Aktionen"
"actions": "Aktionen",
"records": "In deinen Einträgen"
},
"actions": {
"capture": "Neuer Eintrag",
@@ -12610,6 +12611,28 @@
"backup": "Backup erstellen",
"today": "Heute öffnen",
"shortcuts": "Tastenkürzel zeigen"
},
"records": {
"loading": "Deine Einträge werden durchsucht…",
"empty": "Nichts in deinen Einträgen für „{query}“.",
"error": "Deine Einträge konnten nicht durchsucht werden. Versuch es gleich noch einmal.",
"truncated": "Nur die neuesten Einträge wurden durchsucht.",
"kind": {
"visit": "Arztbesuch",
"condition": "Erkrankung",
"document": "Dokument",
"vaccination": "Impfung",
"lab": "Laborwert",
"medication": "Medikament",
"symptom": "Symptom",
"workout": "Training",
"note": "Notiz"
},
"source": {
"mood": "Stimmungsnotiz",
"measurement": "Notiz zu einem Messwert",
"illness": "Krankheitstagebuch"
}
}
},
"shortcuts": {
+26 -3
View File
@@ -12587,8 +12587,8 @@
},
"palette": {
"title": "Search",
"description": "Find a page, a setting, a module or an action.",
"placeholder": "Search pages, settings, modules…",
"description": "Find a page, a setting, a module, an action or an entry in your records.",
"placeholder": "Search pages, settings, your records…",
"results": "Results",
"noResults": "Nothing found for “{query}”.",
"moduleOn": "On",
@@ -12602,7 +12602,8 @@
"insights": "Insights",
"settings": "Settings",
"modules": "Modules",
"actions": "Actions"
"actions": "Actions",
"records": "In your records"
},
"actions": {
"capture": "New entry",
@@ -12610,6 +12611,28 @@
"backup": "Create a backup",
"today": "Open today",
"shortcuts": "Show keyboard shortcuts"
},
"records": {
"loading": "Searching your records…",
"empty": "Nothing in your records for “{query}”.",
"error": "Your records could not be searched. Try again in a moment.",
"truncated": "Only the most recent entries were searched.",
"kind": {
"visit": "Visit",
"condition": "Condition",
"document": "Document",
"vaccination": "Vaccination",
"lab": "Lab result",
"medication": "Medication",
"symptom": "Symptom",
"workout": "Workout",
"note": "Note"
},
"source": {
"mood": "Mood note",
"measurement": "Note on a reading",
"illness": "Illness diary"
}
}
},
"shortcuts": {
+26 -3
View File
@@ -12587,8 +12587,8 @@
},
"palette": {
"title": "Buscar",
"description": "Encuentra una página, un ajuste, un módulo o una acción.",
"placeholder": "Buscar páginas, ajustes, módulos…",
"description": "Encuentra una página, un ajuste, un módulo, una acción o una entrada de tus registros.",
"placeholder": "Buscar páginas, ajustes, tus registros…",
"results": "Resultados",
"noResults": "No hay resultados para «{query}».",
"moduleOn": "Activado",
@@ -12602,7 +12602,8 @@
"insights": "Insights",
"settings": "Ajustes",
"modules": "Módulos",
"actions": "Acciones"
"actions": "Acciones",
"records": "En tus registros"
},
"actions": {
"capture": "Nueva entrada",
@@ -12610,6 +12611,28 @@
"backup": "Crear una copia de seguridad",
"today": "Abrir hoy",
"shortcuts": "Mostrar atajos de teclado"
},
"records": {
"loading": "Buscando en tus registros…",
"empty": "Nada en tus registros para «{query}».",
"error": "No se pudo buscar en tus registros. Inténtalo de nuevo en un momento.",
"truncated": "Solo se buscaron las entradas más recientes.",
"kind": {
"visit": "Consulta",
"condition": "Enfermedad",
"document": "Documento",
"vaccination": "Vacuna",
"lab": "Resultado de laboratorio",
"medication": "Medicamento",
"symptom": "Síntoma",
"workout": "Entrenamiento",
"note": "Nota"
},
"source": {
"mood": "Nota de ánimo",
"measurement": "Nota de una medición",
"illness": "Diario de enfermedad"
}
}
},
"shortcuts": {
+26 -3
View File
@@ -12587,8 +12587,8 @@
},
"palette": {
"title": "Recherche",
"description": "Trouver une page, un réglage, un module ou une action.",
"placeholder": "Rechercher des pages, réglages, modules…",
"description": "Trouver une page, un réglage, un module, une action ou une entrée de votre dossier.",
"placeholder": "Rechercher des pages, réglages, votre dossier…",
"results": "Résultats",
"noResults": "Aucun résultat pour « {query} ».",
"moduleOn": "Activé",
@@ -12602,7 +12602,8 @@
"insights": "Insights",
"settings": "Paramètres",
"modules": "Modules",
"actions": "Actions"
"actions": "Actions",
"records": "Dans votre dossier"
},
"actions": {
"capture": "Nouvelle saisie",
@@ -12610,6 +12611,28 @@
"backup": "Créer une sauvegarde",
"today": "Ouvrir aujourd’hui",
"shortcuts": "Afficher les raccourcis clavier"
},
"records": {
"loading": "Recherche dans votre dossier…",
"empty": "Rien dans votre dossier pour « {query} ».",
"error": "Votre dossier n’a pas pu être parcouru. Réessayez dans un instant.",
"truncated": "Seules les entrées les plus récentes ont été parcourues.",
"kind": {
"visit": "Consultation",
"condition": "Maladie",
"document": "Document",
"vaccination": "Vaccination",
"lab": "Résultat d’analyse",
"medication": "Médicament",
"symptom": "Symptôme",
"workout": "Entraînement",
"note": "Note"
},
"source": {
"mood": "Note d’humeur",
"measurement": "Note sur une mesure",
"illness": "Journal de maladie"
}
}
},
"shortcuts": {
+26 -3
View File
@@ -12587,8 +12587,8 @@
},
"palette": {
"title": "Cerca",
"description": "Trova una pagina, un’impostazione, un modulo o un’azione.",
"placeholder": "Cerca pagine, impostazioni, moduli…",
"description": "Trova una pagina, un’impostazione, un modulo, un’azione o una voce dei tuoi dati.",
"placeholder": "Cerca pagine, impostazioni, i tuoi dati…",
"results": "Risultati",
"noResults": "Nessun risultato per «{query}».",
"moduleOn": "Attivo",
@@ -12602,7 +12602,8 @@
"insights": "Insights",
"settings": "Impostazioni",
"modules": "Moduli",
"actions": "Azioni"
"actions": "Azioni",
"records": "Nei tuoi dati"
},
"actions": {
"capture": "Nuova voce",
@@ -12610,6 +12611,28 @@
"backup": "Crea un backup",
"today": "Apri oggi",
"shortcuts": "Mostra scorciatoie da tastiera"
},
"records": {
"loading": "Ricerca nei tuoi dati…",
"empty": "Niente nei tuoi dati per «{query}».",
"error": "Non è stato possibile cercare nei tuoi dati. Riprova tra un momento.",
"truncated": "Sono state cercate solo le voci più recenti.",
"kind": {
"visit": "Visita",
"condition": "Malattia",
"document": "Documento",
"vaccination": "Vaccinazione",
"lab": "Esame di laboratorio",
"medication": "Farmaco",
"symptom": "Sintomo",
"workout": "Allenamento",
"note": "Nota"
},
"source": {
"mood": "Nota sull’umore",
"measurement": "Nota su una misurazione",
"illness": "Diario di malattia"
}
}
},
"shortcuts": {
+26 -3
View File
@@ -12587,8 +12587,8 @@
},
"palette": {
"title": "검색",
"description": "페이지, 설정, 모듈 또는 작업을 찾습니다.",
"placeholder": "페이지, 설정, 모듈 검색…",
"description": "페이지, 설정, 모듈, 작업 또는 내 기록의 항목을 찾습니다.",
"placeholder": "페이지, 설정, 내 기록 검색…",
"results": "결과",
"noResults": "“{query}”에 대한 결과가 없습니다.",
"moduleOn": "켜짐",
@@ -12602,7 +12602,8 @@
"insights": "인사이트",
"settings": "설정",
"modules": "모듈",
"actions": "작업"
"actions": "작업",
"records": "내 기록에서"
},
"actions": {
"capture": "새 항목",
@@ -12610,6 +12611,28 @@
"backup": "백업 만들기",
"today": "오늘 열기",
"shortcuts": "키보드 단축키 보기"
},
"records": {
"loading": "내 기록을 검색하는 중…",
"empty": "내 기록에서 “{query}”에 대한 결과가 없습니다.",
"error": "내 기록을 검색할 수 없습니다. 잠시 후 다시 시도하세요.",
"truncated": "최근 항목만 검색했습니다.",
"kind": {
"visit": "진료",
"condition": "질환",
"document": "문서",
"vaccination": "예방접종",
"lab": "검사 결과",
"medication": "약",
"symptom": "증상",
"workout": "운동",
"note": "메모"
},
"source": {
"mood": "기분 메모",
"measurement": "측정값 메모",
"illness": "질병 일지"
}
}
},
"shortcuts": {
+26 -3
View File
@@ -12587,8 +12587,8 @@
},
"palette": {
"title": "Szukaj",
"description": "Znajdź stronę, ustawienie, moduł lub akcję.",
"placeholder": "Szukaj stron, ustawień, modułów…",
"description": "Znajdź stronę, ustawienie, moduł, akcję lub wpis w swoich danych.",
"placeholder": "Szukaj stron, ustawień, swoich wpisów…",
"results": "Wyniki",
"noResults": "Brak wyników dla „{query}”.",
"moduleOn": "Wł.",
@@ -12602,7 +12602,8 @@
"insights": "Insights",
"settings": "Ustawienia",
"modules": "Moduły",
"actions": "Akcje"
"actions": "Akcje",
"records": "W twoich wpisach"
},
"actions": {
"capture": "Nowy wpis",
@@ -12610,6 +12611,28 @@
"backup": "Utwórz kopię zapasową",
"today": "Otwórz dzisiaj",
"shortcuts": "Pokaż skróty klawiszowe"
},
"records": {
"loading": "Przeszukiwanie twoich wpisów…",
"empty": "Brak wyników w twoich wpisach dla „{query}”.",
"error": "Nie udało się przeszukać twoich wpisów. Spróbuj ponownie za chwilę.",
"truncated": "Przeszukano tylko najnowsze wpisy.",
"kind": {
"visit": "Wizyta",
"condition": "Choroba",
"document": "Dokument",
"vaccination": "Szczepienie",
"lab": "Wynik badania",
"medication": "Lek",
"symptom": "Objaw",
"workout": "Trening",
"note": "Notatka"
},
"source": {
"mood": "Notatka o nastroju",
"measurement": "Notatka do pomiaru",
"illness": "Dziennik choroby"
}
}
},
"shortcuts": {
@@ -0,0 +1,16 @@
-- v1.42.2 — the record search reads the notes on readings newest first.
--
-- A record can hold millions of readings and a handful of notes. Without an
-- index that holds only the noted rows, the search walks the account's whole
-- `(user_id, measured_at)` range to find them. This partial index holds the
-- live rows that carry a note (the encrypted column, or the legacy plaintext
-- one the boot backfill has not migrated yet), so the read is the handful.
--
-- Additive only, no table rewrite, no data change. Non-concurrent for the
-- reason migration 0243 gives: Prisma Migrate runs a migration file in a
-- transaction, and the entrypoint applies migrations before the app serves
-- traffic. The predicate is tiny, so the build is short.
CREATE INDEX IF NOT EXISTS "measurements_noted_live_idx"
ON "measurements" ("user_id", "measured_at" DESC, "id" DESC)
WHERE "deleted_at" IS NULL
AND ("notes_encrypted" IS NOT NULL OR "notes" IS NOT NULL);
+6
View File
@@ -2095,6 +2095,12 @@ model Measurement {
// without this one the purge planned a sequential scan and could not finish
// inside statement_timeout. Raw SQL only (partial predicate not expressible
// in Prisma).
// `measurements_noted_live_idx` is a partial index over `(user_id,
// measured_at DESC, id DESC) WHERE deleted_at IS NULL AND (notes_encrypted
// IS NOT NULL OR notes IS NOT NULL)` — see migration 0397 (v1.42.2). The
// record search reads the notes on readings newest first; without it a
// record of millions of readings with a few notes walks every reading.
// Raw SQL only (partial predicate not expressible in Prisma).
@@map("measurements")
}
+5 -1
View File
@@ -1045,6 +1045,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "record",
why: "Counts per timeline lane with an in-app link per gap. Counts only, no record content; scoped to the resolved user and module-gated like the timeline.",
},
"app/api/search/route.ts": {
domain: "record",
why: "One search over the record: hits from every section with an in-app path each. Read-only, scoped to the resolved user, every source narrowed to the record's module switches and to the sections the grant covers, the query never logged.",
},
"app/api/workouts/route.ts": {
domain: "measurements",
why: "The record's workout list. Verified to carry no AI-written paragraph: the single-workout route beside it does, and stays refused for exactly that reason.",
@@ -2094,7 +2098,7 @@ const ACTOR_ROUTES: Record<string, string> = {
* literal (a score's daily course, MANAGE like the derived score itself).
* 252 -> 254.
*/
const FROZEN_ENTRY_COUNT = 254;
const FROZEN_ENTRY_COUNT = 255;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
+176
View File
@@ -0,0 +1,176 @@
/**
* `GET /api/search` — admission, validation, the rate limit, and what the
* route hands the search and the log.
*
* The search itself is `searchRecords`, covered by its own unit tests and by
* `tests/integration/record-search.test.ts` against Postgres; here it is a
* spy, so the test can assert exactly what the route passes it.
*
* Mutation checks (each run, each seen red):
* - admit on `("read", "profile")` instead of the whole record → "admits
* the whole record only" goes red;
* - drop `.min(SEARCH_QUERY_MIN)` → "refuses a one-letter query" goes red;
* - put the query into the annotation → "never logs the query" goes red;
* - pass `actor.id` as the record → "searches the record the session acts
* for" goes red.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const mocks = vi.hoisted(() => ({
requireRecordAuth: vi.fn(),
checkRateLimit: vi.fn(),
searchRecords: vi.fn(),
annotate: vi.fn(),
resolveModuleMap: vi.fn(),
domainVisible: vi.fn(() => true),
}));
vi.mock("@/lib/api-handler", () => ({
apiHandler: (handler: unknown) => handler,
requireRecordAuth: mocks.requireRecordAuth,
}));
vi.mock("@/lib/logging/context", () => ({
annotate: mocks.annotate,
getEvent: () => undefined,
}));
vi.mock("@/lib/db", () => ({ prisma: {} }));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: mocks.checkRateLimit }));
vi.mock("@/lib/modules/gate", () => ({
resolveModuleMap: mocks.resolveModuleMap,
}));
vi.mock("@/lib/sharing/acting-domains", () => ({
actingDomainVisibility: vi.fn(async () => mocks.domainVisible),
}));
vi.mock("@/lib/tz/resolver", () => ({
resolveUserTimezone: vi.fn(async () => "Europe/Berlin"),
}));
vi.mock("@/lib/search/search-records", () => ({
searchRecords: mocks.searchRecords,
}));
import { GET } from "../route";
const handler = GET as unknown as (req: NextRequest) => Promise<Response>;
function request(query: string): NextRequest {
return new NextRequest(`http://localhost/api/search?${query}`);
}
const OWNER = { id: "owner-1", locale: "de" };
const ACTOR = { id: "actor-1", locale: "en" };
beforeEach(() => {
vi.clearAllMocks();
mocks.requireRecordAuth.mockResolvedValue({
user: OWNER,
actor: ACTOR,
grantId: "grant-1",
});
mocks.checkRateLimit.mockResolvedValue({ allowed: true });
mocks.resolveModuleMap.mockResolvedValue({ illness: false });
mocks.searchRecords.mockResolvedValue({
groups: [
{
kind: "visit",
hits: [
{
id: "visit:v1",
kind: "visit",
title: { text: "Schulter", marks: [[0, 8]] },
context: [],
snippet: null,
day: "2026-01-02",
path: "/checkups?visit=v1",
source: null,
},
],
more: false,
},
],
truncated: false,
stats: { sources: 12, rows: 40, candidates: 1, elapsedMs: 3 },
});
});
describe("GET /api/search", () => {
it("admits the whole record only, at read", async () => {
await handler(request("q=schulter"));
expect(mocks.requireRecordAuth).toHaveBeenCalledWith("read", "record");
});
it("propagates a refused admission without searching", async () => {
mocks.requireRecordAuth.mockRejectedValueOnce(new Error("denied"));
await expect(handler(request("q=schulter"))).rejects.toThrow("denied");
expect(mocks.searchRecords).not.toHaveBeenCalled();
});
it("searches the record the session acts for, with its masks", async () => {
const res = await handler(request("q=Schulter&limit=7"));
expect(res.status).toBe(200);
expect(mocks.searchRecords).toHaveBeenCalledTimes(1);
const args = mocks.searchRecords.mock.calls[0][0];
expect(args.recordId).toBe("owner-1");
expect(args.query).toBe("Schulter");
expect(args.limit).toBe(7);
expect(args.modules).toEqual({ illness: false });
expect(args.domainVisible).toBe(mocks.domainVisible);
expect(args.timezone).toBe("Europe/Berlin");
// Labels follow the reader, not the record.
expect(args.displayLocale).toBe("en");
const body = await res.json();
expect(body.data.groups[0].hits[0].path).toBe("/checkups?visit=v1");
expect(body.data.truncated).toBe(false);
expect(body.data.stats).toBeUndefined();
});
it("defaults the limit", async () => {
await handler(request("q=knie"));
expect(mocks.searchRecords.mock.calls[0][0].limit).toBe(5);
});
it.each([
["a one-letter query", "q=a"],
["a blank query", "q=%20%20%20"],
["a missing query", "limit=5"],
["an over-long query", `q=${"x".repeat(101)}`],
["a zero limit", "q=knie&limit=0"],
["a limit above the cap", "q=knie&limit=21"],
["a non-numeric limit", "q=knie&limit=lots"],
])("refuses %s with 422 and never searches", async (_label, query) => {
const res = await handler(request(query));
expect(res.status).toBe(422);
expect(mocks.searchRecords).not.toHaveBeenCalled();
});
it("does not echo a refused query", async () => {
const secret = "Psychotherapie".repeat(8);
const res = await handler(request(`q=${secret}`));
expect(res.status).toBe(422);
expect(await res.text()).not.toContain("Psychotherapie");
});
it("rate limits per actor before reading anything", async () => {
mocks.checkRateLimit.mockResolvedValueOnce({ allowed: false });
const res = await handler(request("q=schulter"));
expect(res.status).toBe(429);
expect(mocks.checkRateLimit.mock.calls[0][0]).toBe("search:actor-1");
expect(mocks.searchRecords).not.toHaveBeenCalled();
});
it("never logs the query, only its length and the counts", async () => {
await handler(request("q=Psychotherapie"));
const logged = JSON.stringify(mocks.annotate.mock.calls);
expect(logged).not.toMatch(/psychotherapie/i);
const run = mocks.annotate.mock.calls.find(
([e]) => e.action.name === "search.query.run",
);
expect(run?.[0].meta).toMatchObject({
query_length: 14,
hits: 1,
groups: 1,
truncated: false,
delegated: true,
});
});
});
+101
View File
@@ -0,0 +1,101 @@
/**
* `GET /api/search?q=&limit=` — one search over the record (Discussion #1117):
* visits and procedures, conditions, documents (title and indexed text),
* vaccinations, lab results, medications, symptoms, workouts and the notes on
* mood entries, readings and illness days. Each hit carries the in-app path
* that opens it. The work is `searchRecords` in `src/lib/search/`.
*
* Admission declares the whole record, like the timeline: the search lays
* every section side by side, so a grant scoped to some of them does not open
* it. The sources are narrowed to the grant's sections all the same
* (`actingDomainVisibility`), and to the record's module switches, so a
* switched-off module is not read at all.
*
* The query is health data ("Schulter", a drug, a diagnosis). It never
* reaches a log: the annotation carries its length and the counts only, and a
* refused query is answered without echoing it.
*
* Rate limited per actor: the palette asks once per pause in typing, and a
* manager searching someone's record spends their own allowance.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { defaultLocale, locales, type Locale } from "@/lib/i18n/config";
import { annotate } from "@/lib/logging/context";
import { resolveModuleMap } from "@/lib/modules/gate";
import { checkRateLimit } from "@/lib/rate-limit";
import { searchRecords } from "@/lib/search/search-records";
import { searchQuerySchema } from "@/lib/search/wire-schemas";
import { actingDomainVisibility } from "@/lib/sharing/acting-domains";
import { resolveUserTimezone } from "@/lib/tz/resolver";
/** Searches per actor per minute: a few per typed word, with room to spare. */
const SEARCH_RATE_LIMIT = 60;
const SEARCH_RATE_WINDOW_MS = 60_000;
function displayLocale(value: string | null | undefined): Locale {
return locales.includes(value as Locale) ? (value as Locale) : defaultLocale;
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user, actor, grantId } = await requireRecordAuth("read", "record");
const rl = await checkRateLimit(
`search:${actor.id}`,
SEARCH_RATE_LIMIT,
SEARCH_RATE_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many searches. Please retry shortly.", 429);
}
const params = new URL(request.url).searchParams;
const parsed = searchQuerySchema.safeParse({
q: params.get("q") ?? undefined,
limit: params.get("limit") ?? undefined,
});
if (!parsed.success) {
annotate({
action: { name: "search.query.invalid" },
meta: { issue_count: parsed.error.issues.length },
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "search.invalid",
});
}
const [modules, domainVisible, timezone] = await Promise.all([
resolveModuleMap(user.id),
actingDomainVisibility(prisma, grantId),
resolveUserTimezone(user.id),
]);
const result = await searchRecords({
recordId: user.id,
query: parsed.data.q,
limit: parsed.data.limit,
modules,
domainVisible,
displayLocale: displayLocale(actor.locale),
timezone,
});
annotate({
action: { name: "search.query.run" },
meta: {
query_length: parsed.data.q.length,
limit: parsed.data.limit,
sources: result.stats.sources,
rows_scanned: result.stats.rows,
matches: result.stats.candidates,
groups: result.groups.length,
hits: result.groups.reduce((n, g) => n + g.hits.length, 0),
truncated: result.truncated,
elapsed_ms: result.stats.elapsedMs,
delegated: grantId !== null,
},
});
return apiSuccess({ groups: result.groups, truncated: result.truncated });
});
+8
View File
@@ -69,6 +69,14 @@ export default function CheckupsPageClient() {
const [view, setView] = useState(
linkedVisit ? "visits" : linkedSite ? "body-sites" : "vorsorge",
);
// A link to a visit can arrive while the page is already open (the search
// opens one from wherever the person is): it moves to the visits tab then
// too, rather than only on the first render.
const [observedVisit, setObservedVisit] = useState(linkedVisit);
if (linkedVisit !== observedVisit) {
setObservedVisit(linkedVisit);
if (linkedVisit) setView("visits");
}
const refresh = useCallback(
() =>
@@ -4,6 +4,7 @@ import { Activity, Moon, Plus, Smile } from "lucide-react";
import { I18nProvider } from "@/lib/i18n/context";
import type { PaletteEntry } from "@/lib/command-palette/build-index";
import type { SearchResponse } from "@/lib/search/contract";
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn() }),
@@ -69,6 +70,18 @@ vi.mock("../use-palette-index", () => ({
usePaletteIndex: () => ENTRIES,
}));
const recordSearch = vi.hoisted(() => ({
state: {
query: null as string | null,
data: undefined as SearchResponse | undefined,
loading: false,
error: false,
},
}));
vi.mock("../use-record-search", () => ({
useRecordSearch: () => recordSearch.state,
}));
import { PaletteBody, arrangeResults } from "../command-palette";
function render(isMobile = false) {
@@ -122,7 +135,9 @@ describe("<PaletteBody>", () => {
const html = render();
expect(html).toContain('role="group"');
expect(html).toContain('aria-label="Search"');
expect(html).toContain('placeholder="Search pages, settings, modules…"');
expect(html).toContain(
'placeholder="Search pages, settings, your records…"',
);
expect(html).toContain(">Actions<");
});
@@ -133,3 +148,104 @@ describe("<PaletteBody>", () => {
expect(phone).toContain(">Cancel<");
});
});
const HITS: SearchResponse = {
groups: [
{
kind: "visit",
more: false,
hits: [
{
id: "visit:v1",
kind: "visit",
title: { text: "Arthroskopie", marks: [] },
context: [{ text: "Schulter (links)", marks: [[0, 8]] }],
snippet: null,
day: "2026-03-02",
path: "/checkups?visit=v1",
source: null,
},
],
},
{
kind: "note",
more: false,
hits: [
{
id: "moodNote:m1",
kind: "note",
title: null,
context: [],
snippet: { text: "Die <b>SCHULTER</b> zwickt", marks: [[7, 15]] },
day: "2026-03-05",
path: "/mood?day=2026-03-05",
source: "mood",
},
],
},
],
truncated: true,
};
describe("<PaletteBody> in your records", () => {
it("lists the record hits after the local results as options", () => {
recordSearch.state = {
query: "schulter",
data: HITS,
loading: false,
error: false,
};
const html = render();
expect(html).toContain('data-group="records"');
expect(html).toContain(">In your records<");
expect(html).toContain('data-entry="record:visit:v1"');
expect(html).toContain('data-entry="record:moodNote:m1"');
// After every local option.
expect(html.indexOf('data-entry="action:today"')).toBeLessThan(
html.indexOf('data-entry="record:visit:v1"'),
);
// The matched part is marked, as text.
expect(html).toContain("<mark");
expect(html).toContain(">Schulter</mark> (links)");
// A note without a name is called by what it was written on.
expect(html).toContain("Mood note");
// Record text is never parsed as markup.
expect(html).not.toContain("<b>");
expect(html).toContain("&lt;b&gt;");
expect(html).toContain("Only the most recent entries were searched.");
});
it("says when it is searching, and when nothing matched", () => {
recordSearch.state = {
query: "schulter",
data: undefined,
loading: true,
error: false,
};
expect(render()).toContain("Searching your records…");
recordSearch.state = {
query: "schulter",
data: { groups: [], truncated: false },
loading: false,
error: false,
};
expect(render()).toContain("Nothing in your records for “schulter”.");
recordSearch.state = {
query: "schulter",
data: undefined,
loading: false,
error: true,
};
expect(render()).toContain("Your records could not be searched.");
});
it("shows nothing of the records while no search applies", () => {
recordSearch.state = {
query: null,
data: undefined,
loading: false,
error: false,
};
expect(render()).not.toContain('data-group="records"');
});
});
@@ -36,10 +36,13 @@ import { pushRecent, readRecent } from "@/lib/command-palette/recent";
import { scrollToAnchorWhenReady } from "@/lib/dom/scroll-to-anchor";
import { useCoachLaunch } from "@/lib/insights/coach-launch-context";
import { useTranslations } from "@/lib/i18n/context";
import type { SearchHit } from "@/lib/search/contract";
import { cn } from "@/lib/utils";
import { setCommandPaletteOpen, useCommandPaletteOpen } from "./palette-store";
import { RecordHitContent } from "./record-hit";
import { usePaletteIndex } from "./use-palette-index";
import { useRecordSearch } from "./use-record-search";
/**
* The command palette: one field, and below it the places, settings, module
@@ -47,6 +50,11 @@ import { usePaletteIndex } from "./use-palette-index";
* Escape closes. A module's entry is a switch: Enter flips it and the palette
* stays open.
*
* After the local results comes "In your records": the server's search over
* the record (`useRecordSearch`, `GET /api/search`), once the typing pauses.
* Its hits are options like any other, after the local ones, so the arrows
* and Enter work across both; each opens the page that shows the record.
*
* Accessibility follows the combobox pattern: the field is the
* `role="combobox"` that keeps focus, the results are a `listbox` of grouped
* `option`s, and `aria-activedescendant` names the highlighted one.
@@ -177,8 +185,15 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) {
() => sections.flatMap((section) => section.entries),
[sections],
);
const activeIndex =
flat.length === 0 ? -1 : Math.min(active, flat.length - 1);
const records = useRecordSearch(query);
const recordHits = useMemo(
() => records.data?.groups.flatMap((group) => group.hits) ?? [],
[records.data],
);
const showRecords = records.query !== null;
const shownHits = showRecords ? recordHits : [];
const total = flat.length + shownHits.length;
const activeIndex = total === 0 ? -1 : Math.min(active, total - 1);
const optionId = (i: number) => `${listId}-option-${i}`;
// Keep the highlighted option in view as the arrows move through a list
@@ -231,19 +246,33 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) {
}
}
function openRecord(path: string) {
setCommandPaletteOpen(false);
router.push(path);
}
function runAt(index: number) {
if (index < 0) return;
if (index < flat.length) {
run(flat[index]);
return;
}
const hit = shownHits[index - flat.length];
if (hit) openRecord(hit.path);
}
function onKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.nativeEvent.isComposing) return;
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
if (flat.length === 0) return;
if (total === 0) return;
const step = event.key === "ArrowDown" ? 1 : -1;
setActive((activeIndex + step + flat.length) % flat.length);
setActive((activeIndex + step + total) % total);
return;
}
if (event.key === "Enter") {
event.preventDefault();
const entry = flat[activeIndex];
if (entry) run(entry);
runAt(activeIndex);
return;
}
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
@@ -278,7 +307,7 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) {
autoFocus
type="text"
role="combobox"
aria-expanded={flat.length > 0}
aria-expanded={total > 0}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={
@@ -319,7 +348,9 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) {
data-slot="command-palette-results"
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-2"
>
{sections.length === 0 ? (
{sections.length === 0 &&
(!showRecords ||
(!records.loading && !records.error && shownHits.length === 0)) ? (
<p
data-slot="command-palette-empty"
className="text-muted-foreground px-3 py-8 text-center text-sm"
@@ -414,6 +445,27 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) {
);
})
)}
{showRecords &&
!(
sections.length === 0 &&
!records.loading &&
!records.error &&
shownHits.length === 0
) ? (
<RecordSection
headingId={`${listId}-group-records`}
hits={shownHits}
firstIndex={flat.length}
activeIndex={activeIndex}
optionId={optionId}
loading={records.loading}
error={records.error}
truncated={records.data?.truncated === true}
query={records.query ?? ""}
onHover={setActive}
onOpen={openRecord}
/>
) : null}
</div>
{isMobile ? null : (
@@ -444,3 +496,104 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) {
</div>
);
}
/**
* "In your records": the server's hits after the local results. A status line
* (searching, nothing found, failed, older entries not read) is plain text,
* not an option, so the arrows skip it.
*/
function RecordSection({
headingId,
hits,
firstIndex,
activeIndex,
optionId,
loading,
error,
truncated,
query,
onHover,
onOpen,
}: {
headingId: string;
hits: ReadonlyArray<SearchHit>;
firstIndex: number;
activeIndex: number;
optionId: (i: number) => string;
loading: boolean;
error: boolean;
truncated: boolean;
query: string;
onHover: (i: number) => void;
onOpen: (path: string) => void;
}) {
const { t } = useTranslations();
const status = error
? t("palette.records.error")
: hits.length === 0
? loading
? t("palette.records.loading")
: t("palette.records.empty", { query })
: null;
return (
<div
role="group"
aria-labelledby={headingId}
aria-busy={loading}
data-group="records"
className="pb-1"
>
<div
id={headingId}
className="text-muted-foreground px-3 pt-3 pb-1 text-xs font-medium tracking-wide uppercase"
>
{t("palette.groups.records")}
</div>
{hits.map((hit, n) => {
const i = firstIndex + n;
const selected = i === activeIndex;
return (
<div
key={hit.id}
id={optionId(i)}
role="option"
aria-selected={selected}
data-entry={`record:${hit.id}`}
data-kind={hit.kind}
onMouseMove={() => {
if (!selected) onHover(i);
}}
onMouseDown={(event) => {
// Keep focus in the field; the click opens the record.
event.preventDefault();
}}
onClick={() => onOpen(hit.path)}
className={cn(
"group flex min-h-11 cursor-pointer items-start gap-3 rounded-md px-3 py-2 text-sm",
selected && "bg-accent text-accent-foreground",
)}
>
<RecordHitContent hit={hit} />
</div>
);
})}
{status ? (
<p
data-slot="command-palette-records-status"
aria-live="polite"
className="text-muted-foreground px-3 py-2 text-xs"
>
{status}
</p>
) : null}
{truncated && !error ? (
<p
data-slot="command-palette-records-truncated"
className="text-muted-foreground px-3 pt-1 pb-2 text-xs"
>
{t("palette.records.truncated")}
</p>
) : null}
</div>
);
}
@@ -0,0 +1,107 @@
"use client";
import {
Activity,
Dumbbell,
FileText,
FlaskConical,
NotebookPen,
Pill,
Stethoscope,
Syringe,
Thermometer,
type LucideIcon,
} from "lucide-react";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import type {
SearchHit,
SearchHitKind,
SearchMarkedText,
} from "@/lib/search/contract";
export const RECORD_HIT_ICON: Record<SearchHitKind, LucideIcon> = {
visit: Stethoscope,
condition: Thermometer,
document: FileText,
vaccination: Syringe,
lab: FlaskConical,
medication: Pill,
symptom: Activity,
workout: Dumbbell,
note: NotebookPen,
};
/**
* Text with the ranges the query matched set in `<mark>`. Plain React text
* children only: the ranges are character offsets the server computed, and
* nothing here is parsed as markup.
*/
export function Marked({ value }: { value: SearchMarkedText }) {
const parts: React.ReactNode[] = [];
let at = 0;
for (const [start, end] of value.marks) {
if (start < at || end > value.text.length || end <= start) continue;
if (start > at) parts.push(value.text.slice(at, start));
parts.push(
<mark
key={start}
className="bg-primary/15 text-foreground rounded-[2px] font-medium"
>
{value.text.slice(start, end)}
</mark>,
);
at = end;
}
if (at < value.text.length) parts.push(value.text.slice(at));
return <>{parts}</>;
}
/** What a hit is called when its record has no name of its own. */
export function useRecordHitLabel(): (hit: SearchHit) => string {
const { t } = useTranslations();
return (hit) =>
hit.source
? t(`palette.records.source.${hit.source}`)
: t(`palette.records.kind.${hit.kind}`);
}
/** The body of one record option: title, kind and date, context, snippet. */
export function RecordHitContent({ hit }: { hit: SearchHit }) {
const fmt = useFormatters();
const label = useRecordHitLabel()(hit);
const Icon = RECORD_HIT_ICON[hit.kind];
return (
<>
<Icon
className="text-muted-foreground mt-0.5 size-4 shrink-0 self-start"
aria-hidden="true"
/>
<span className="min-w-0 flex-1 space-y-0.5">
<span className="block truncate">
{hit.title ? <Marked value={hit.title} /> : label}
</span>
<span className="block truncate text-xs">
{/* Muted reads too faint on the highlighted row's accent fill, so
the highlighted option sets it in the accent's own ink. */}
<span className="text-muted-foreground group-aria-selected:text-accent-foreground">
{hit.title ? label : null}
{hit.title && hit.day ? " · " : null}
{hit.day ? fmt.dateShortSmartCalendar(hit.day) : null}
</span>
{hit.context.map((part, i) => (
<span key={i}>
{hit.title || hit.day || i > 0 ? " · " : null}
<Marked value={part} />
</span>
))}
</span>
{hit.snippet ? (
<span className="line-clamp-2 block text-xs break-words">
<Marked value={hit.snippet} />
</span>
) : null}
</span>
</>
);
}
@@ -0,0 +1,77 @@
"use client";
import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { useDebouncedValue } from "@/hooks/use-debounced-value";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { apiGet } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import {
SEARCH_QUERY_MAX,
SEARCH_QUERY_MIN,
type SearchResponse,
} from "@/lib/search/contract";
/** Hits per group the palette asks for. */
export const PALETTE_RECORD_LIMIT = 4;
/** Pause in typing before the record search runs. */
const DEBOUNCE_MS = 200;
export interface RecordSearchState {
/** The query the shown answer belongs to, or null while none applies. */
query: string | null;
data: SearchResponse | undefined;
/**
* The answer for the typed text is not in yet (still typing, or the
* request in flight). `data` may hold the previous query's answer.
*/
loading: boolean;
error: boolean;
}
/**
* The palette's search over the record, after the local results.
*
* Runs once the typing pauses, for a query of at least two characters, and
* only where the server answers it: on the person's own record, or on a
* record shared whole. A grant over some sections never opens the search, so
* it is not asked. The request carries the query's abort signal: when the
* next keystroke changes the key, the previous query loses its observer and
* its request is cancelled rather than left to land late.
*/
export function useRecordSearch(rawQuery: string): RecordSearchState {
const { inSharedRecord, sections } = useRecordCapabilities();
const available = !inSharedRecord || sections === null;
const typed = rawQuery.trim().slice(0, SEARCH_QUERY_MAX);
const query = useDebouncedValue(typed, DEBOUNCE_MS);
const wanted = available && typed.length >= SEARCH_QUERY_MIN;
const enabled = wanted && query.length >= SEARCH_QUERY_MIN;
const result = useQuery({
queryKey: queryKeys.recordSearch(query, PALETTE_RECORD_LIMIT),
queryFn: ({ signal }) =>
apiGet<SearchResponse>(
`/api/search?${new URLSearchParams({
q: query,
limit: String(PALETTE_RECORD_LIMIT),
})}`,
{ signal },
),
enabled,
// The previous answer stays while the next one loads, so typing does not
// flash the section empty between keystrokes.
placeholderData: keepPreviousData,
staleTime: 30_000,
gcTime: 60_000,
retry: false,
});
// Still typing, or the answer for the typed text not in yet.
const current = typed === query && !result.isPlaceholderData;
return {
query: wanted ? typed : null,
data: wanted && enabled ? result.data : undefined,
loading: wanted && (!current || result.isFetching),
error: wanted && current && result.isError,
};
}
@@ -27,10 +27,18 @@ import { Skeleton } from "@/components/ui/skeleton";
import { useDeepLinkedRecord } from "@/hooks/use-deep-linked-record";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useEncounters, type Encounter } from "@/hooks/use-encounters";
import { apiGet } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { EncounterSheet } from "./encounter-sheet";
import { VisitCard } from "./visit-card";
/** One visit by id, for a link to a visit older than the list reads. */
function fetchVisit(id: string): Promise<Encounter | null> {
return apiGet<Encounter>(`/api/encounters/${encodeURIComponent(id)}`).catch(
() => null,
);
}
export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
const { t } = useTranslations();
const { canWriteDomain, canManageDomain } = useRecordCapabilities();
@@ -60,6 +68,9 @@ export function VisitsSection({ enabled = true }: { enabled?: boolean }) {
records: list.data ? [...upcoming, ...past] : undefined,
anchorAttribute: "data-encounter-id",
open: canManageVisits ? openSheet : undefined,
// The list reads the newest hundred of each arm; a search hit can name an
// older visit, which is read on its own.
resolve: fetchVisit,
});
const empty = upcoming.length === 0 && past.length === 0;
// The last visit that took place: the start of the visit preparation.
@@ -46,6 +46,7 @@ import { QueryErrorCard } from "@/components/ui/query-error-card";
import { TagChip } from "@/components/ui/tag-chip";
import { QuickEntrySheets } from "@/components/dashboard/quick-entry-sheets";
import { useAuth } from "@/hooks/use-auth";
import { useDeepLinkedRecord } from "@/hooks/use-deep-linked-record";
import { useRecordCapabilities } from "@/hooks/use-record-capabilities";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { DEFAULT_TIMEZONE, userDayKey } from "@/lib/tz/format";
@@ -369,6 +370,15 @@ export function SymptomsSection() {
const hidden = all.filter((d) => !d.isActive);
const days = groupEventsByDay(events.data?.events ?? [], timezone);
// `?symptom=<id>` — a search hit names the symptom. Scroll to it, and open
// it for someone who may edit it.
useDeepLinkedRecord({
param: "symptom",
records: definitions.data ? all : undefined,
anchorAttribute: "data-symptom-id",
open: canManage ? setEditingDefinition : undefined,
});
if (definitions.isLoading) return null;
if (definitions.isError || events.isError) {
return (
@@ -413,6 +423,7 @@ export function SymptomsSection() {
return (
<li
key={definition.id}
data-symptom-id={definition.id}
className="flex items-center justify-between gap-3 py-2.5"
>
<div className="flex min-w-0 items-center gap-2">
@@ -449,6 +460,7 @@ export function SymptomsSection() {
{hidden.map((definition) => (
<li
key={definition.id}
data-symptom-id={definition.id}
className="flex items-center justify-between gap-3 py-1.5"
>
<span className="text-muted-foreground truncate text-sm">
+33 -4
View File
@@ -14,14 +14,17 @@ import { useSearchParams } from "next/navigation";
* sheet, or nothing for a reader who may not edit). Each id is acted on once,
* so closing the sheet does not reopen it on the next render.
*
* An id the list does not hold (deleted since, or outside what this grant
* shows) is ignored: the page simply renders as it would without the link.
* An id the list does not hold (deleted since, outside what this grant shows,
* or older than the bounded list reads) is handed to `resolve` when the page
* gives one, and the record it returns is opened; without `resolve`, or when
* it finds nothing, the page simply renders as it would without the link.
*/
export function useDeepLinkedRecord<T extends { id: string }>({
param,
records,
anchorAttribute,
open,
resolve,
}: {
/** The query parameter carrying the id. */
param: string;
@@ -31,20 +34,46 @@ export function useDeepLinkedRecord<T extends { id: string }>({
anchorAttribute: string;
/** Called once with the linked record; omit to only scroll. */
open?: (record: T) => void;
/**
* Read one record the list does not hold, by its id, or null. Only asked
* when `open` is given: a record that is not on the page cannot be
* scrolled to.
*/
resolve?: (id: string) => Promise<T | null>;
}): void {
// Null outside the app router (a component rendered on its own, as the
// unit tests do): no link, nothing to open.
const wanted = useSearchParams()?.get(param) ?? null;
const handled = useRef<string | null>(null);
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
return () => {
mounted.current = false;
};
}, []);
useEffect(() => {
if (!wanted || !records || handled.current === wanted) return;
handled.current = wanted;
const record = records.find((entry) => entry.id === wanted);
if (!record) return;
if (!record) {
if (!resolve || !open) return;
void resolve(wanted)
.then((found) => {
// Still on the page, and still the link it was asked for.
if (found && mounted.current && handled.current === wanted) {
open(found);
}
})
.catch(() => {
// Gone or refused: the page stays as it would without the link.
});
return;
}
document
.querySelector(`[${anchorAttribute}="${CSS.escape(wanted)}"]`)
?.scrollIntoView({ block: "center" });
open?.(record);
}, [wanted, records, anchorAttribute, open]);
}, [wanted, records, anchorAttribute, open, resolve]);
}
+2 -2
View File
@@ -151,13 +151,13 @@ import {
USER_TEXT_FENCE_END,
USER_TEXT_FENCE_START,
} from "@/lib/ai/coach/data-fence";
import { MAX_DOCUMENT_EXCERPT_CHARS } from "../record-search";
import {
MAX_DOCUMENT_EXCERPT_CHARS,
MAX_RECORD_RESULTS,
queryWords,
rankCandidates,
type SearchCandidate,
} from "../record-search";
} from "@/lib/search/match";
import type { McpAuthContext } from "../auth";
const ctx = (userId: string): McpAuthContext => ({
+148 -270
View File
@@ -19,12 +19,9 @@
* on whole words of its text through the blind token index the vault search
* uses. The document body itself is never read for a search.
*
* Ranking. Every query word scores the best field it hits (a title, label,
* site, reason or practitioner counts more than a kind word or a side, which
* counts more than a note or an indexed-text hit). Results order by how many
* query words they matched, then by score, then by a fixed kind order, then
* newest first, then by id: the same query over the same record always
* returns the same list, which is what the offset cursor relies on.
* Ranking and folding live in `@/lib/search/match`, shared with the in-app
* search (`GET /api/search`), which reads its record kinds through
* `loadRecordCandidates` here as well: one matcher, one set of record reads.
*
* Module switches. A record kind whose module the account has off (or the
* operator switched off) is not read at all: no row, no link, no count.
@@ -55,9 +52,7 @@ import {
decryptIndexText,
decryptVerbatimText,
hashQueryTokens,
tokenise,
} from "@/lib/documents/content-index";
import { expandQueryTokens } from "@/lib/documents/search-synonyms";
import { normalizeSearchText } from "@/lib/encounters/procedures";
import { encounterKindLabel } from "@/lib/encounters/kind-label";
import { getServerTranslator } from "@/lib/i18n/server-translator";
@@ -71,15 +66,18 @@ import {
customLookupOf,
resolveVaccineEntry,
} from "@/lib/vaccinations/resolve-vaccine-entry";
import {
MAX_QUERY_WORDS,
MIN_SUBSTRING_WORD,
WEIGHT,
queryWords,
type SearchCandidate,
} from "@/lib/search/match";
// ── Bounds ───────────────────────────────────────────────────────────
/** Rows one search decrypts and matches, per record kind. */
export const MAX_ROWS_PER_KIND = 500;
/** Query words that take part in matching; the rest are ignored. */
export const MAX_QUERY_WORDS = 8;
/** Record results one search can return in total, across the four kinds. */
export const MAX_RECORD_RESULTS = 50;
/** Newest records per kind listed when the query is empty. */
export const EMPTY_QUERY_PER_KIND = 10;
/** Characters of indexed document text one `fetch` returns. */
@@ -125,251 +123,8 @@ async function readableKinds(userId: string): Promise<Set<RecordKind>> {
return out;
}
// ── Matching and ranking (pure) ──────────────────────────────────────
/** Field weights: what a query word hitting that field is worth. */
export const WEIGHT = { primary: 3, secondary: 2, tertiary: 1 } as const;
export interface SearchField {
text: string;
weight: number;
}
export interface SearchCandidate {
id: string;
title: string;
url: string;
/** Tie-break order between kinds; lower sorts first. */
kindOrder: number;
/** Epoch ms of the record's own date, newest first on a tie. */
date: number | null;
fields: SearchField[];
/** Query words the blind content index matched (documents only). */
contentWords?: ReadonlySet<string>;
/** True for the four record kinds; bounds `MAX_RECORD_RESULTS`. */
record?: boolean;
}
/**
* Words the index tokeniser keeps but a search box does not need. The
* tokeniser's own list already drops the common function words.
*/
const QUERY_STOPWORDS: ReadonlySet<string> = new Set([
"mein",
"meine",
"meinen",
"meinem",
"meiner",
"mine",
"our",
"all",
"any",
"some",
"last",
"latest",
"recent",
"letzte",
"letzten",
"letzter",
"letztes",
"show",
"find",
"list",
"get",
]);
/**
* Body parts, German and English, for matching a body site or a label. A site
* is typed in the owner's language and the question often arrives in the
* assistant's, so "knee" has to find "Knie". Search-time only and local to
* this module; the document vault's synonym list is left as it is. Same
* normal form as the tokeniser: lower case, accents stripped.
*/
const BODY_PART_GROUPS: ReadonlyArray<ReadonlyArray<string>> = [
["knee", "knees", "knie", "kniegelenk"],
["shoulder", "shoulders", "schulter", "schultergelenk"],
["hip", "hips", "hufte", "huftgelenk"],
["ankle", "sprunggelenk", "knochel"],
["foot", "feet", "fuss"],
["hand", "hands", "hande"],
["wrist", "handgelenk"],
["elbow", "ellbogen", "ellenbogen"],
["back", "rucken", "wirbelsaule", "spine"],
["neck", "nacken", "hals"],
["head", "kopf"],
["eye", "eyes", "auge", "augen"],
["ear", "ears", "ohr", "ohren"],
["tooth", "teeth", "zahn", "zahne"],
["heart", "herz"],
["lung", "lungs", "lunge"],
["stomach", "magen"],
["bowel", "colon", "darm"],
["skin", "haut"],
["arm", "arms", "arme"],
["leg", "legs", "bein", "beine"],
["finger", "fingers"],
["toe", "toes", "zeh", "zehe", "zehen"],
["breast", "brust"],
["thyroid", "schilddruse"],
];
const BODY_PART_BY_WORD: ReadonlyMap<string, ReadonlyArray<string>> = (() => {
const map = new Map<string, string[]>();
for (const group of BODY_PART_GROUPS) {
for (const word of group) map.set(word, [...group]);
}
return map;
})();
/** A query word's alternatives: the vault's synonyms plus body parts. */
function synonymsOf(word: string): string[] {
const out = new Set([
...expandQueryTokens([word]),
...(BODY_PART_BY_WORD.get(word) ?? []),
]);
out.delete(word);
return [...out];
}
/**
* The words of a query that take part in matching: folded, split, short and
* filler words dropped, deduplicated, capped.
*/
export function queryWords(query: string): string[] {
return tokenise(normalizeSearchText(query))
.filter((word) => !QUERY_STOPWORDS.has(word))
.slice(0, MAX_QUERY_WORDS);
}
interface PreparedField {
folded: string;
tokens: ReadonlySet<string>;
weight: number;
}
function prepareField(field: SearchField): PreparedField {
const folded = normalizeSearchText(field.text);
return {
folded,
tokens: new Set(folded.split(/[^\p{L}\p{N}]+/u).filter(Boolean)),
weight: field.weight,
};
}
export interface CandidateScore {
/** Query words that hit anything. */
matched: number;
/** Sum of each matched word's best field weight, plus the phrase bonus. */
score: number;
/** The whole query appears as typed inside one field. */
phrase: boolean;
}
/** Bonus for the whole query appearing inside one field. */
const PHRASE_BONUS = 5;
/**
* Score one candidate against the query.
*
* A word hits a field when the field contains it (so "knee" finds "Knee
* replacement") or when one of its curated synonyms is a whole word of the
* field (so "Zucker" finds "glucose"). Synonyms match whole words only: they
* are wider than the word the person typed, and a substring match on them
* would find "rate" inside "moderate".
*/
/** Query words shorter than this match whole words or word starts only. */
export const MIN_SUBSTRING_WORD = 4;
/**
* Whether a query word hits a field by its own spelling. A word of four
* letters or more may sit anywhere ("knee" in "kneecap"); a shorter one has
* to start a word, so "arm" finds "Arm" and "Armpit" but not "Darm" or "warm".
*/
function hitsField(field: PreparedField, word: string): boolean {
if (word.length >= MIN_SUBSTRING_WORD) return field.folded.includes(word);
for (const token of field.tokens) if (token.startsWith(word)) return true;
return false;
}
export function scoreCandidate(
candidate: Pick<SearchCandidate, "fields" | "contentWords">,
words: readonly string[],
foldedQuery: string,
): CandidateScore {
const fields = candidate.fields
.filter((f) => f.text.trim().length > 0)
.map(prepareField);
let matched = 0;
let score = 0;
for (const word of words) {
const synonyms = synonymsOf(word);
let best = 0;
for (const field of fields) {
if (field.weight <= best) continue;
if (hitsField(field, word) || synonyms.some((s) => field.tokens.has(s))) {
best = field.weight;
}
}
if (best < WEIGHT.tertiary && candidate.contentWords?.has(word)) {
best = WEIGHT.tertiary;
}
if (best > 0) {
matched += 1;
score += best;
}
}
const phrase =
foldedQuery.length > 0 &&
fields.some((field) =>
foldedQuery.includes(" ")
? field.folded.includes(foldedQuery)
: hitsField(field, foldedQuery),
);
if (phrase) score += PHRASE_BONUS;
return { matched, score, phrase };
}
/**
* Filter and order candidates for one query. Pure and deterministic.
*
* An empty query keeps every candidate. Otherwise a candidate stays when a
* query word hit it or the whole query appears in one of its fields (the
* substring match the older search kinds were built on, kept so a short
* query like "hr" still finds what it found before).
*/
export function rankCandidates(
candidates: readonly SearchCandidate[],
query: string,
): SearchCandidate[] {
const foldedQuery = normalizeSearchText(query);
const words = queryWords(query);
const scored = candidates.flatMap((candidate) => {
if (!foldedQuery) {
return [{ candidate, matched: 0, score: 0 }];
}
const s = scoreCandidate(candidate, words, foldedQuery);
if (s.matched === 0 && !s.phrase) return [];
return [{ candidate, matched: s.matched, score: s.score }];
});
scored.sort(
(a, b) =>
b.matched - a.matched ||
b.score - a.score ||
a.candidate.kindOrder - b.candidate.kindOrder ||
(b.candidate.date ?? 0) - (a.candidate.date ?? 0) ||
(a.candidate.id < b.candidate.id
? -1
: a.candidate.id > b.candidate.id
? 1
: 0),
);
let records = 0;
return scored.flatMap(({ candidate }) => {
if (!candidate.record) return [candidate];
records += 1;
return records <= MAX_RECORD_RESULTS ? [candidate] : [];
});
}
// ── Matching and ranking ─────────────────────────────────────────────
// Shared with the in-app search; see `@/lib/search/match`.
// ── Labels ───────────────────────────────────────────────────────────
@@ -500,19 +255,51 @@ const KIND_ORDER: Record<RecordKind, number> = {
interface Owner {
locale: Locale;
timezone: string;
/** The language of the in-app display data (`SearchCandidate.hit`). */
display: Locale;
}
async function loadOwner(userId: string): Promise<Owner> {
async function loadOwner(userId: string, display?: Locale): Promise<Owner> {
const row = await prisma.user.findUnique({
where: { id: userId },
select: { locale: true, timezone: true },
});
const locale = resolveLocale(row?.locale);
return {
locale: resolveLocale(row?.locale),
locale,
timezone: row?.timezone ?? DEFAULT_TIMEZONE,
display: display ?? locale,
};
}
/** The in-app path of one record: `recordUrl` without an origin. */
export function recordPath(kind: RecordKind, id: string): string {
return recordUrl("", kind, id);
}
/** "left Knee" in the display language, or the site alone. */
function sitePhrase(
site: string | null,
laterality: Laterality | null,
display: Locale,
): string | null {
if (!site) return null;
if (!laterality) return site;
const key =
laterality === "LEFT"
? "encounters.laterality.left"
: laterality === "RIGHT"
? "encounters.laterality.right"
: "encounters.laterality.both";
return `${site} (${getServerTranslator(display).t(key)})`;
}
function compact(values: ReadonlyArray<string | null | undefined>): string[] {
return values
.map((v) => v?.replace(/\s+/g, " ").trim() ?? "")
.filter((v) => v.length > 0);
}
function visitTitle(args: {
kind: EncounterKind;
day: string;
@@ -583,6 +370,8 @@ async function visitCandidates(
const outcome = decryptText(row.outcomeEncrypted);
const bodySite = decryptText(row.bodySiteEncrypted);
const day = userDayKey(row.occurredAt, owner.timezone);
const kindLabel = encounterKindLabel(row.kind, owner.display);
const title = reason?.replace(/\s+/g, " ").trim() || null;
return {
id: `visit:${row.id}`,
title: visitTitle({
@@ -597,9 +386,21 @@ async function visitCandidates(
kindOrder: KIND_ORDER.visit,
date: row.occurredAt.getTime(),
record: true,
hit: {
kind: "visit",
recordId: row.id,
title: title ?? kindLabel,
context: compact([
title ? kindLabel : null,
practitioner?.name,
sitePhrase(bodySite, row.laterality, owner.display),
]),
day,
path: recordPath("visit", row.id),
},
fields: [
{ text: bodySite ?? "", weight: WEIGHT.primary },
{ text: reason ?? "", weight: WEIGHT.primary },
{ text: reason ?? "", weight: WEIGHT.primary, body: true },
{ text: practitioner?.name ?? "", weight: WEIGHT.primary },
{ text: practitioner?.specialty ?? "", weight: WEIGHT.primary },
{
@@ -616,7 +417,7 @@ async function visitCandidates(
text: sideTerms(row.laterality, owner.locale) ?? "",
weight: WEIGHT.secondary,
},
{ text: outcome ?? "", weight: WEIGHT.secondary },
{ text: outcome ?? "", weight: WEIGHT.secondary, body: true },
],
};
});
@@ -696,6 +497,17 @@ async function conditionCandidates(
kindOrder: KIND_ORDER.condition,
date: row.onsetAt.getTime(),
record: true,
hit: {
kind: "condition",
recordId: row.id,
title: row.label,
context: compact([
illnessTypeLabel(row.type, owner.display),
sitePhrase(bodySite, row.laterality, owner.display),
]),
day,
path: recordPath("condition", row.id),
},
fields: [
{ text: row.label, weight: WEIGHT.primary },
{ text: bodySite ?? "", weight: WEIGHT.primary },
@@ -736,6 +548,7 @@ const DOCUMENT_SELECT = {
async function contentMatches(
userId: string,
words: readonly string[],
includeAiDeferred: boolean,
): Promise<Map<string, Set<string>>> {
const byDocument = new Map<string, Set<string>>();
await Promise.all(
@@ -747,8 +560,12 @@ async function contentMatches(
userId,
searchTokens: { hasSome: hashes },
// A document held back from AI reading is not searched by its
// text over this wire either; its title and kind still are.
document: { deletedAt: null, aiReadDeferred: false },
// text over MCP either; its title and kind still are. The person's
// own in-app search is not an AI surface and reads it.
document: {
deletedAt: null,
...(includeAiDeferred ? {} : { aiReadDeferred: false }),
},
},
select: { documentId: true },
take: MAX_ROWS_PER_KIND,
@@ -790,6 +607,7 @@ async function documentCandidates(
take: number,
query: string,
words: readonly string[],
includeAiDeferred: boolean,
): Promise<SearchCandidate[]> {
const nameTerms = documentNameTerms(query, words);
const [rows, content, named] = await Promise.all([
@@ -800,7 +618,7 @@ async function documentCandidates(
take,
}),
words.length > 0
? contentMatches(userId, words)
? contentMatches(userId, words, includeAiDeferred)
: Promise.resolve(new Map<string, Set<string>>()),
// Title and file name are plaintext columns, so an older document is
// found by them in SQL, the way the vault list finds it.
@@ -849,6 +667,22 @@ async function documentCandidates(
kindOrder: KIND_ORDER.document,
date: (dated ?? row.createdAt).getTime(),
record: true,
hit: {
kind: "document",
recordId: row.id,
title:
row.title ??
row.filename ??
documentKindLabel(row.kind, owner.display),
context: compact([
documentKindLabel(row.kind, owner.display),
row.title ? row.filename : null,
]),
day: dated
? dateOnly(dated)
: userDayKey(row.createdAt, owner.timezone),
path: recordPath("document", row.id),
},
contentWords: content.get(row.id),
fields: [
{ text: row.title ?? "", weight: WEIGHT.primary },
@@ -899,6 +733,19 @@ async function vaccinationCandidates(
kindOrder: KIND_ORDER.vaccination,
date: row.occurredAt.getTime(),
record: true,
hit: {
kind: "vaccination",
recordId: row.id,
title:
row.vaccineName ??
vaccineCatalogName(row.antigenSlug, owner.display) ??
own?.name ??
row.antigenSlug ??
null,
context: compact([practitioner?.name]),
day,
path: recordPath("vaccination", row.id),
},
fields: [
{ text: row.vaccineName ?? "", weight: WEIGHT.primary },
{
@@ -927,29 +774,60 @@ async function vaccinationCandidates(
});
}
export interface RecordSearchOptions {
/**
* The kinds to read. Defaults to the account's module switches; the in-app
* search passes its own set, narrowed by the module map and by the
* sections a delegate's grant covers.
*/
kinds?: ReadonlySet<RecordKind>;
/** The language of `SearchCandidate.hit`; defaults to the owner's. */
displayLocale?: Locale;
/**
* Match a document's indexed text even when it was held back from AI
* reading. Only the person's own in-app search sets it; MCP never does.
*/
includeAiDeferredText?: boolean;
/** Newest rows read per kind; defaults to `MAX_ROWS_PER_KIND`. */
rowsPerKind?: number;
}
/**
* Every record candidate this account can see for one query. Unranked; the
* caller ranks them together with the older search kinds.
* caller ranks them together with its other kinds.
*/
export async function loadRecordCandidates(
userId: string,
query: string,
origin: string,
options: RecordSearchOptions = {},
): Promise<SearchCandidate[]> {
const words = queryWords(query);
const empty = normalizeSearchText(query).length === 0;
const take = empty ? EMPTY_QUERY_PER_KIND : MAX_ROWS_PER_KIND;
const take = empty
? EMPTY_QUERY_PER_KIND
: (options.rowsPerKind ?? MAX_ROWS_PER_KIND);
const [kinds, owner] = await Promise.all([
readableKinds(userId),
loadOwner(userId),
options.kinds ? Promise.resolve(options.kinds) : readableKinds(userId),
loadOwner(userId, options.displayLocale),
]);
const groups = await Promise.all([
visitCandidates(userId, owner, origin, take, empty),
kinds.has("visit")
? visitCandidates(userId, owner, origin, take, empty)
: Promise.resolve([]),
kinds.has("condition")
? conditionCandidates(userId, owner, origin, take)
: Promise.resolve([]),
kinds.has("document")
? documentCandidates(userId, owner, origin, take, query, words)
? documentCandidates(
userId,
owner,
origin,
take,
query,
words,
options.includeAiDeferredText === true,
)
: Promise.resolve([]),
kinds.has("vaccination")
? vaccinationCandidates(userId, owner, origin, take)
+3 -1
View File
@@ -90,10 +90,12 @@ import {
fetchRecord,
isRecordKind,
loadRecordCandidates,
} from "@/lib/mcp/record-search";
import {
rankCandidates,
WEIGHT as SEARCH_WEIGHT,
type SearchCandidate,
} from "@/lib/mcp/record-search";
} from "@/lib/search/match";
import type { McpAuthContext } from "./auth";
import { dueSchedules } from "@/lib/medications/intake-tracking";
import { liveEraStartsByMedication } from "@/lib/medications/scheduling/live-era";
@@ -68,6 +68,7 @@ import {
import { TREND_CHART_CONFIG } from "@/lib/insights/trend-chart-select";
import { SYMPTOM_CHANNEL_PREFIX } from "@/lib/symptoms/shared";
import { DAY_SECTION_KEYS, TIMELINE_LANE_KEYS } from "@/lib/day/contract";
import { SEARCH_SOURCE_KEYS } from "@/lib/search/sources";
import {
SURFACE_KINDS,
SURFACE_MODULE,
@@ -171,6 +172,13 @@ describe("every surface id names a surface that exists", () => {
}
});
it("search: a source the record search reads", () => {
const keys = new Set<string>(SEARCH_SOURCE_KEYS);
for (const id of localIds("search")) {
expect(keys.has(id), id).toBe(true);
}
});
it("correlation: a discovery channel, a weather field or the lab pass", () => {
const channels = new Set<string>([
...DISCOVERY_BEHAVIOURS,
@@ -289,6 +297,9 @@ describe("every surface that shows a module's data is owned by it", () => {
["day-section:lifeEvents", "timeline"],
["day-section:symptoms", "illness"],
["timeline-lane:documents", "inboundDocuments"],
["search:document", "inboundDocuments"],
["search:moodNote", "mood"],
["search:symptomNote", "illness"],
])("%s is owned by %s", (id, owner) => {
expect(surfaceModule(id)).toBe(owner);
});
+18
View File
@@ -52,6 +52,8 @@ import type { ModuleKey } from "@/lib/modules/registry";
* day loader blanks a section whose owner is off, and the views drop it.
* - `timeline-lane`: a lane of the timeline, by its `TimelineLaneKey`; the
* readiness inventory leaves it out the same way.
* - `search`: a source the in-app record search reads, by its
* `SearchSourceKey`. A source whose owner is off is not read at all.
*/
export const SURFACE_KINDS = [
"nav",
@@ -68,6 +70,7 @@ export const SURFACE_KINDS = [
"score-ring",
"day-section",
"timeline-lane",
"search",
] as const;
export type SurfaceKind = (typeof SURFACE_KINDS)[number];
@@ -247,6 +250,21 @@ const STATIC_SURFACE_MODULE = {
"timeline-lane:labs": "labs",
"timeline-lane:documents": "inboundDocuments",
"timeline-lane:cycle": "cycle",
// ── Record search sources (v1.42.2) ──
// Visits are core. Notes on readings follow the owner of the reading's
// type and are filtered item by item, like the timeline's `life` lane.
"search:condition": "illness",
"search:document": "inboundDocuments",
"search:vaccination": "vaccinations",
"search:labAnalyte": "labs",
"search:labNote": "labs",
"search:medication": "medications",
"search:symptom": "illness",
"search:symptomNote": "illness",
"search:workout": "workouts",
"search:moodNote": "mood",
"search:illnessNote": "illness",
} as const satisfies Record<string, ModuleKey>;
/** A surface id the map declares literally. */
+1
View File
@@ -348,6 +348,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"report-selection.leaves.unknown",
],
route: ["route.retired"],
search: ["search.invalid"],
"share-link": [
"share-link.document.decryptFailed",
"share-link.document.notFound",
+2
View File
@@ -49,6 +49,7 @@ import { dailyPaths } from "./daily";
import { cyclePaths } from "./cycle";
import { dashboardWidgetPaths } from "./dashboard";
import { dayPaths } from "./day";
import { searchPaths } from "./search";
import { biomarkerPaths } from "./biomarkers";
import { inboundDocumentPaths } from "./documents";
import { documentSourcePaths } from "./document-sources";
@@ -185,6 +186,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
// v1.42 — the day view, the timeline and life events (appended, spread
// order is load-bearing).
...dayPaths,
...searchPaths,
// The environmental-context overview. Its own module because nothing else
// owns the surface (appended, spread order is load-bearing).
//
+40
View File
@@ -0,0 +1,40 @@
/**
* OpenAPI route table for the in-app record search (`/api/search`, v1.42.2,
* Discussion #1117).
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. The query and
* response schemas are the runtime ones from `@/lib/search/wire-schemas`, so
* the published contract is the parser's own.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import {
searchQuerySchema,
searchResponseSchema,
} from "@/lib/search/wire-schemas";
import { dataEnvelope, recordRefusal, stdResponses } from "./shared";
export const searchPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/search": {
get: {
tags: ["Records"],
summary: "Search the record",
description:
"One search over the record: visits and procedures, conditions, documents (title, file name and indexed text), vaccinations, lab results (analyte names and notes), medications, symptoms, workouts by sport, and the notes on mood entries, readings and illness days. Hits are grouped by kind, the best group first, and each carries the in-app path that opens the record. Matching ignores case and accents and reads German umlauts typed as ae, oe, ue or ss. A source whose module the record has switched off is not read. The search reads each source newest first under a per-source row cap and a time budget; `truncated` says when that cut it short. The query is never logged. Rate limited per acting account (60 a minute). A grant scoped to some sections of the record does not open it, like the timeline.",
requestParams: { query: searchQuerySchema },
responses: {
"200": {
description: "The hits, grouped by kind.",
content: {
"application/json": {
schema: dataEnvelope(searchResponseSchema, "SearchEnvelope"),
},
},
},
...stdResponses,
...recordRefusal(),
},
},
},
};
+2
View File
@@ -43,6 +43,7 @@ import { recordSettingsKeys } from "./record-settings";
import { profileKeys } from "./profile";
import { settingsKeys } from "./settings";
import { timelineKeys } from "./timeline";
import { searchKeys } from "./search";
import { sharingKeys } from "./sharing";
import { vaccinationKeys } from "./vaccinations";
import { workoutKeys } from "./workouts";
@@ -80,6 +81,7 @@ export const queryKeys = {
...aiRunKeys,
...dayKeys,
...timelineKeys,
...searchKeys,
};
export { recordSettingsKeys } from "./record-settings";
+15
View File
@@ -0,0 +1,15 @@
/**
* Query keys — the in-app record search (`GET /api/search`, v1.42.2).
* Part of the centralized factory; aggregated in `./index.ts`.
*
* The query text is part of the key, which keeps it in memory only: the
* `["search"]` head is not on the persister's allowlist, so neither the
* query nor its hits are written to disk.
*/
export const searchKeys = {
/** Root prefix. */
searchRoot: () => ["search"] as const,
/** `GET /api/search?q=&limit=`. */
recordSearch: (query: string, limit: number) =>
["search", "records", query, limit] as const,
};
+141
View File
@@ -0,0 +1,141 @@
import { describe, expect, it } from "vitest";
import {
foldForMatch,
isHit,
markMatches,
scoreForQuery,
queryWords,
scoreCandidate,
snippetOf,
WEIGHT,
type SearchCandidate,
} from "../match";
function candidate(
fields: SearchCandidate["fields"],
): Pick<SearchCandidate, "fields"> {
return { fields };
}
const hit = (text: string, query: string, weight: number = WEIGHT.primary) =>
isHit(scoreForQuery(candidate([{ text, weight }]), query));
/** The marked substrings, for readable assertions. */
const marks = (text: string, query: string) =>
markMatches(text, query).map(([s, e]) => text.slice(s, e));
describe("folding", () => {
it("ignores case, accents and the German keyboard spellings", () => {
expect(foldForMatch("Schulter")).toBe("schulter");
expect(foldForMatch("Hüfte")).toBe(foldForMatch("Huefte"));
expect(foldForMatch("Fuß")).toBe(foldForMatch("Fuss"));
expect(foldForMatch("Müdigkeit")).toBe("mudigkeit");
expect(foldForMatch(" FSME Impfung ")).toBe("fsme impfung");
});
it("matches a term whatever way either side spells the umlaut", () => {
expect(hit("Schulter links", "schulter")).toBe(true);
expect(hit("schulter links", "SCHULTER")).toBe(true);
expect(hit("Hüftschmerzen", "hueft")).toBe(true);
expect(hit("Hueftschmerzen", "hüft")).toBe(true);
expect(hit("Fußgelenk", "fuss")).toBe(true);
expect(hit("Schläfrig", "schlaefrig")).toBe(true);
});
it("does not match what is not there", () => {
expect(hit("Knie rechts", "schulter")).toBe(false);
// A short word must start a word: "arm" is not inside "Darm".
expect(hit("Darmspiegelung", "arm")).toBe(false);
expect(hit("Armbruch", "arm")).toBe(true);
});
it("finds a body part across languages", () => {
expect(hit("Schulter", "shoulder")).toBe(true);
expect(hit("left shoulder", "Schulter")).toBe(true);
});
});
describe("queryWords", () => {
it("drops filler and keeps the tokeniser's form", () => {
expect(queryWords("meine letzte Schulter OP")).toEqual(["schulter"]);
});
});
describe("marks", () => {
it("marks the original characters, not the folded ones", () => {
expect(marks("Hüftgelenk und Hüfte", "hueft")).toEqual(["Hüft", "Hüft"]);
expect(marks("Fußgelenk", "fuss")).toEqual(["Fuß"]);
expect(marks("Huefte", "hüfte")).toEqual(["Huefte"]);
expect(marks("SCHULTER links", "schulter")).toEqual(["SCHULTER"]);
});
it("marks a short word only at a word start", () => {
expect(marks("Arm und Darm", "arm")).toEqual(["Arm"]);
});
it("marks a query too short for the tokeniser by itself", () => {
expect(marks("Hb und HbA1c", "hb")).toEqual(["Hb", "Hb"]);
});
it("merges overlapping marks", () => {
expect(markMatches("Schulterschmerz", "schulter schulterschmerz")).toEqual([
[0, 15],
]);
});
it("keeps ranges inside the text for multi-unit characters", () => {
const text = "😀 Schulter";
const [[start, end]] = markMatches(text, "schulter");
expect(text.slice(start, end)).toBe("Schulter");
});
});
describe("snippetOf", () => {
it("returns the whole short text with its marks", () => {
const s = snippetOf("Schmerzen in der Schulter", "schulter");
expect(s?.text).toBe("Schmerzen in der Schulter");
expect(s?.marks).toEqual([[17, 25]]);
});
it("cuts a long text around the first match at word boundaries", () => {
const filler = "Lorem ipsum dolor sit amet ".repeat(20);
const s = snippetOf(`${filler}die Schulter tut weh ${filler}`, "schulter");
expect(s).not.toBeNull();
expect(s!.text.startsWith("…")).toBe(true);
expect(s!.text.endsWith("…")).toBe(true);
expect(s!.text.length).toBeLessThanOrEqual(142);
const [[start, end]] = s!.marks;
expect(s!.text.slice(start, end)).toBe("Schulter");
});
it("is null when the text does not contain the query", () => {
expect(snippetOf("Knie", "schulter")).toBeNull();
});
});
describe("ranking inputs", () => {
it("a title hit scores above a note hit", () => {
const title = scoreForQuery(
candidate([{ text: "Schulter", weight: WEIGHT.primary }]),
"schulter",
);
const note = scoreForQuery(
candidate([{ text: "Schulter", weight: WEIGHT.tertiary, body: true }]),
"schulter",
);
expect(title.score).toBeGreaterThan(note.score);
});
it("counts each matched word once", () => {
const s = scoreCandidate(
candidate([
{ text: "Schulter links", weight: WEIGHT.primary },
{ text: "links", weight: WEIGHT.secondary },
]),
["schulter", "links"],
"schulter links",
);
expect(s.matched).toBe(2);
});
});
@@ -0,0 +1,121 @@
/**
* The bounds of the record search and the masks that pick its sources. The
* reads themselves are exercised against Postgres in
* `tests/integration/record-search.test.ts`.
*
* Mutation checks (each run, each seen red):
* - drop the deadline check in `scanNewest` → "stops when the budget has
* run out" goes red;
* - drop the `scanned >= maxRows` stop → "never reads more than the cap"
* goes red;
* - drop the domain test from `readableSources` → "drops a source the grant
* does not cover" goes red.
*/
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({ prisma: {} }));
import type { SearchCandidate } from "../match";
import {
readableSources,
scanNewest,
type ScanBudget,
} from "../search-records";
function budget(deadlineIn = 10_000, clock = { t: 0 }): ScanBudget {
return {
deadline: clock.t + deadlineIn,
now: () => clock.t,
truncated: false,
rows: 0,
};
}
/** A source of `total` rows, newest first, every `hitEvery`-th one a hit. */
function source(total: number, hitEvery = 1) {
const rows = Array.from({ length: total }, (_, i) => ({ id: `r${i}`, i }));
const reads: number[] = [];
return {
reads,
read: async (cursor: string | null, take: number) => {
const start = cursor ? rows.findIndex((r) => r.id === cursor) + 1 : 0;
reads.push(take);
return rows.slice(start, start + take);
},
match: (row: { id: string; i: number }) =>
row.i % hitEvery === 0
? ({ id: row.id } as unknown as SearchCandidate)
: null,
};
}
describe("scanNewest", () => {
it("reads a small source to its end and is not truncated", async () => {
const b = budget();
const s = source(30, 10);
const out = await scanNewest({ budget: b, wanted: 5, batch: 10, ...s });
expect(out.map((c) => c.id)).toEqual(["r0", "r10", "r20"]);
expect(b.truncated).toBe(false);
expect(b.rows).toBe(30);
});
it("stops once it holds more hits than wanted", async () => {
const b = budget();
const s = source(10_000, 1);
const out = await scanNewest({ budget: b, wanted: 3, batch: 10, ...s });
expect(out.length).toBe(10);
expect(s.reads).toEqual([10]);
expect(b.truncated).toBe(false);
});
it("never reads more than the cap, and says so", async () => {
const b = budget();
const s = source(10_000, 1_000_000);
await scanNewest({ budget: b, wanted: 3, batch: 100, maxRows: 450, ...s });
expect(b.rows).toBe(450);
expect(s.reads).toEqual([100, 100, 100, 100, 50]);
expect(b.truncated).toBe(true);
});
it("stops when the budget has run out, and says so", async () => {
const clock = { t: 0 };
const b = budget(100, clock);
const s = source(10_000, 1_000_000);
const read = async (cursor: string | null, take: number) => {
clock.t += 60;
return s.read(cursor, take);
};
await scanNewest({ budget: b, wanted: 3, batch: 10, read, match: s.match });
expect(s.reads.length).toBe(2);
expect(b.truncated).toBe(true);
});
});
describe("readableSources", () => {
const all = () => true;
it("reads everything on an owner's record with every module on", () => {
expect(readableSources({}, all)).toHaveLength(13);
});
it("drops the sources of a switched-off module, never a core one", () => {
const sources = readableSources(
{ illness: false, mood: false, inboundDocuments: false },
all,
);
expect(sources).not.toContain("condition");
expect(sources).not.toContain("symptom");
expect(sources).not.toContain("symptomNote");
expect(sources).not.toContain("illnessNote");
expect(sources).not.toContain("moodNote");
expect(sources).not.toContain("document");
expect(sources).toContain("visit");
expect(sources).toContain("measurementNote");
});
it("drops a source the grant does not cover", () => {
const sources = readableSources({}, (domain) => domain !== "mind");
expect(sources).not.toContain("moodNote");
expect(sources).toContain("visit");
});
});
+82
View File
@@ -0,0 +1,82 @@
/**
* The in-app record search (`GET /api/search`): its vocabulary and wire
* types. Pure constants and types, no validator, so the command palette
* imports this file and nothing server-side. The Zod schemas the route parses
* with and the OpenAPI table publishes live in `./wire-schemas.ts`.
*/
/**
* The groups a hit is listed under, in the order the palette falls back to
* when two groups' best hits tie.
*/
export const SEARCH_HIT_KINDS = [
"visit",
"condition",
"document",
"vaccination",
"lab",
"medication",
"symptom",
"workout",
"note",
] as const;
export type SearchHitKind = (typeof SEARCH_HIT_KINDS)[number];
/** The entry a free-text note was written on. */
export const SEARCH_NOTE_SOURCES = ["mood", "measurement", "illness"] as const;
export type SearchNoteSource = (typeof SEARCH_NOTE_SOURCES)[number];
/** Shortest query the server searches for. */
export const SEARCH_QUERY_MIN = 2;
/** Longest query the server accepts. */
export const SEARCH_QUERY_MAX = 100;
/** Hits per group when the caller names no limit. */
export const SEARCH_LIMIT_DEFAULT = 5;
/** Most hits per group a caller can ask for. */
export const SEARCH_LIMIT_MAX = 20;
/** A half-open `[start, end)` character range of `text`. */
export type SearchMarkRange = [number, number];
/** Text with the ranges a query word matched. Plain text, never markup. */
export interface SearchMarkedText {
text: string;
marks: SearchMarkRange[];
}
export interface SearchHit {
/** Unique within one answer: `<source>:<record id>`. */
id: string;
kind: SearchHitKind;
/** The record's own name, or null when the kind's label names it. */
title: SearchMarkedText | null;
/** Short context values: a practitioner, a body site, a dose. */
context: SearchMarkedText[];
/** The part of the record's free text the query hit, or null. */
snippet: SearchMarkedText | null;
/** The record's calendar day, `YYYY-MM-DD`, or null. */
day: string | null;
/** In-app path that opens the record. */
path: string;
/** For a note: the entry it was written on. Null for every other kind. */
source: SearchNoteSource | null;
}
export interface SearchGroup {
kind: SearchHitKind;
hits: SearchHit[];
/** More hits of this kind matched than the limit lets through. */
more: boolean;
}
export interface SearchResponse {
/** Groups with at least one hit, the best group first. */
groups: SearchGroup[];
/**
* The search stopped before it had read every entry (the per-kind row cap
* or the time budget), so older entries may match and are not listed.
*/
truncated: boolean;
}
+492
View File
@@ -0,0 +1,492 @@
/**
* Matching and ranking for the record search, shared by the MCP `search` tool
* and the in-app search (`GET /api/search`). Pure: no database, no crypto.
*
* Folding. A field and a query are compared after the body-sites service's
* folding (`normalizeSearchText`: Unicode compatibility form, marks stripped,
* lower case, whitespace collapsed) plus the German spellings a keyboard
* without umlauts produces: "ß" reads as "ss", and "ae", "oe", "ue" read as
* "a", "o", "u". Both sides fold the same way, so "Schulter" finds
* "schulter", "Hüfte" finds "huefte" and "Fuß" finds "fuss".
*
* Ranking. Every query word scores the best field it hits (a title, label,
* site, reason or practitioner counts more than a kind word or a side, which
* counts more than a note or an indexed-text hit). Results order by how many
* query words they matched, then by score, then by a fixed kind order, then
* newest first, then by id: the same query over the same record always
* returns the same list, which is what the MCP offset cursor relies on.
*/
import { tokenise } from "@/lib/documents/content-index";
import { expandQueryTokens } from "@/lib/documents/search-synonyms";
import { normalizeSearchText } from "@/lib/encounters/procedures";
/** Query words that take part in matching; the rest are ignored. */
export const MAX_QUERY_WORDS = 8;
/** Record results one MCP search can return in total, across its kinds. */
export const MAX_RECORD_RESULTS = 50;
/** Field weights: what a query word hitting that field is worth. */
export const WEIGHT = { primary: 3, secondary: 2, tertiary: 1 } as const;
export interface SearchField {
text: string;
weight: number;
/**
* Free text a person wrote (a note, a reason, an outcome). The in-app
* search cuts its snippet from the first such field a query word hits.
*/
body?: boolean;
}
/**
* What the in-app search shows for a candidate. The MCP wire ignores it; it
* builds its own English title from the same row.
*/
export interface CandidateHit {
/** The group the hit is listed under. */
kind: string;
/** The record's own id (without the `kind:` prefix the MCP ids carry). */
recordId: string;
/** The record's own name, or null when the kind's label names it. */
title: string | null;
/** Short context: a practitioner, a body site, a dose. Plain values. */
context: string[];
/** The record's calendar day, `YYYY-MM-DD`, or null. */
day: string | null;
/** In-app path that opens the record. */
path: string;
/** For a note: which kind of entry it was written on. */
source?: string;
}
export interface SearchCandidate {
id: string;
title: string;
url: string;
/** Tie-break order between kinds; lower sorts first. */
kindOrder: number;
/** Epoch ms of the record's own date, newest first on a tie. */
date: number | null;
fields: SearchField[];
/** Query words the blind content index matched (documents only). */
contentWords?: ReadonlySet<string>;
/** True for the record kinds; bounds `MAX_RECORD_RESULTS`. */
record?: boolean;
/** Display data for the in-app search. */
hit?: CandidateHit;
}
// ── Folding ──────────────────────────────────────────────────────────
/**
* The German keyboard spellings, folded onto the plain vowel. Applied to text
* that `normalizeSearchText` already folded, so "ä" is "a" by now and only
* the typed digraph is left to fold.
*/
function foldGerman(folded: string): string {
return folded
.replace(/ß/g, "ss")
.replace(/ae/g, "a")
.replace(/oe/g, "o")
.replace(/ue/g, "u");
}
/** The form a field and a query are compared in. */
export function foldForMatch(text: string): string {
return foldGerman(normalizeSearchText(text));
}
/**
* Words the index tokeniser keeps but a search box does not need. The
* tokeniser's own list already drops the common function words.
*/
const QUERY_STOPWORDS: ReadonlySet<string> = new Set([
"mein",
"meine",
"meinen",
"meinem",
"meiner",
"mine",
"our",
"all",
"any",
"some",
"last",
"latest",
"recent",
"letzte",
"letzten",
"letzter",
"letztes",
"show",
"find",
"list",
"get",
]);
/**
* Body parts, German and English, for matching a body site or a label. A site
* is typed in the owner's language and the question often arrives in another,
* so "knee" has to find "Knie". Search-time only; the document vault's synonym
* list is left as it is. Same normal form as the tokeniser: lower case,
* accents stripped.
*/
const BODY_PART_GROUPS: ReadonlyArray<ReadonlyArray<string>> = [
["knee", "knees", "knie", "kniegelenk"],
["shoulder", "shoulders", "schulter", "schultergelenk"],
["hip", "hips", "hufte", "huftgelenk"],
["ankle", "sprunggelenk", "knochel"],
["foot", "feet", "fuss"],
["hand", "hands", "hande"],
["wrist", "handgelenk"],
["elbow", "ellbogen", "ellenbogen"],
["back", "rucken", "wirbelsaule", "spine"],
["neck", "nacken", "hals"],
["head", "kopf"],
["eye", "eyes", "auge", "augen"],
["ear", "ears", "ohr", "ohren"],
["tooth", "teeth", "zahn", "zahne"],
["heart", "herz"],
["lung", "lungs", "lunge"],
["stomach", "magen"],
["bowel", "colon", "darm"],
["skin", "haut"],
["arm", "arms", "arme"],
["leg", "legs", "bein", "beine"],
["finger", "fingers"],
["toe", "toes", "zeh", "zehe", "zehen"],
["breast", "brust"],
["thyroid", "schilddruse"],
];
const BODY_PART_BY_WORD: ReadonlyMap<string, ReadonlyArray<string>> = (() => {
const map = new Map<string, string[]>();
for (const group of BODY_PART_GROUPS) {
for (const word of group) map.set(word, [...group]);
}
return map;
})();
/** A query word's alternatives: the vault's synonyms plus body parts. */
function synonymsOf(word: string): string[] {
const out = new Set(
[
...expandQueryTokens([word]),
...(BODY_PART_BY_WORD.get(word) ??
BODY_PART_BY_WORD.get(foldGerman(word)) ??
[]),
].map(foldGerman),
);
out.delete(foldGerman(word));
return [...out];
}
/**
* The words of a query that take part in matching: folded, split, short and
* filler words dropped, deduplicated, capped. These are also the words the
* blind document index is asked for, so they keep the tokeniser's own form.
*/
export function queryWords(query: string): string[] {
return tokenise(normalizeSearchText(query))
.filter((word) => !QUERY_STOPWORDS.has(word))
.slice(0, MAX_QUERY_WORDS);
}
interface PreparedField {
folded: string;
tokens: ReadonlySet<string>;
weight: number;
}
function prepareField(field: SearchField): PreparedField {
const folded = foldForMatch(field.text);
return {
folded,
tokens: new Set(folded.split(/[^\p{L}\p{N}]+/u).filter(Boolean)),
weight: field.weight,
};
}
export interface CandidateScore {
/** Query words that hit anything. */
matched: number;
/** Sum of each matched word's best field weight, plus the phrase bonus. */
score: number;
/** The whole query appears as typed inside one field. */
phrase: boolean;
}
/** Bonus for the whole query appearing inside one field. */
const PHRASE_BONUS = 5;
/** Query words shorter than this match whole words or word starts only. */
export const MIN_SUBSTRING_WORD = 4;
/**
* Whether a query word hits a field by its own spelling. A word of four
* letters or more may sit anywhere ("knee" in "kneecap"); a shorter one has
* to start a word, so "arm" finds "Arm" and "Armpit" but not "Darm" or "warm".
*/
function hitsField(field: PreparedField, word: string): boolean {
if (word.length >= MIN_SUBSTRING_WORD) return field.folded.includes(word);
for (const token of field.tokens) if (token.startsWith(word)) return true;
return false;
}
/**
* Score one candidate against the query.
*
* A word hits a field when the field contains it (so "knee" finds "Knee
* replacement") or when one of its curated synonyms is a whole word of the
* field (so "Zucker" finds "glucose"). Synonyms match whole words only: they
* are wider than the word the person typed, and a substring match on them
* would find "rate" inside "moderate".
*/
export function scoreCandidate(
candidate: Pick<SearchCandidate, "fields" | "contentWords">,
words: readonly string[],
foldedQuery: string,
): CandidateScore {
const fields = candidate.fields
.filter((f) => f.text.trim().length > 0)
.map(prepareField);
const query = foldGerman(foldedQuery);
let matched = 0;
let score = 0;
for (const word of words) {
const folded = foldGerman(word);
const synonyms = synonymsOf(word);
let best = 0;
for (const field of fields) {
if (field.weight <= best) continue;
if (
hitsField(field, folded) ||
synonyms.some((s) => field.tokens.has(s))
) {
best = field.weight;
}
}
if (best < WEIGHT.tertiary && candidate.contentWords?.has(word)) {
best = WEIGHT.tertiary;
}
if (best > 0) {
matched += 1;
score += best;
}
}
const phrase =
query.length > 0 &&
fields.some((field) =>
query.includes(" ")
? field.folded.includes(query)
: hitsField(field, query),
);
if (phrase) score += PHRASE_BONUS;
return { matched, score, phrase };
}
/** `scoreCandidate` for a raw query. */
export function scoreForQuery(
candidate: Pick<SearchCandidate, "fields" | "contentWords">,
query: string,
): CandidateScore {
return scoreCandidate(
candidate,
queryWords(query),
normalizeSearchText(query),
);
}
/** Whether a scored candidate stays in the result at all. */
export function isHit(score: CandidateScore): boolean {
return score.matched > 0 || score.phrase;
}
/**
* Filter and order candidates for one query. Pure and deterministic.
*
* An empty query keeps every candidate. Otherwise a candidate stays when a
* query word hit it or the whole query appears in one of its fields (the
* substring match the older search kinds were built on, kept so a short
* query like "hr" still finds what it found before).
*/
export function rankCandidates(
candidates: readonly SearchCandidate[],
query: string,
maxRecords: number = MAX_RECORD_RESULTS,
): SearchCandidate[] {
const foldedQuery = normalizeSearchText(query);
const words = queryWords(query);
const scored = candidates.flatMap((candidate) => {
if (!foldedQuery) {
return [{ candidate, matched: 0, score: 0 }];
}
const s = scoreCandidate(candidate, words, foldedQuery);
if (!isHit(s)) return [];
return [{ candidate, matched: s.matched, score: s.score }];
});
scored.sort(
(a, b) =>
b.matched - a.matched ||
b.score - a.score ||
a.candidate.kindOrder - b.candidate.kindOrder ||
(b.candidate.date ?? 0) - (a.candidate.date ?? 0) ||
(a.candidate.id < b.candidate.id
? -1
: a.candidate.id > b.candidate.id
? 1
: 0),
);
let records = 0;
return scored.flatMap(({ candidate }) => {
if (!candidate.record) return [candidate];
records += 1;
return records <= maxRecords ? [candidate] : [];
});
}
// ── Highlighting and snippets ────────────────────────────────────────
/** A half-open `[start, end)` character range of the original text. */
export type MarkRange = [number, number];
export interface MarkedText {
text: string;
/** Ranges of `text` a query word matched, sorted and disjoint. */
marks: MarkRange[];
}
/**
* Fold `text` the way `foldForMatch` does, keeping for every folded character
* the range of the original it came from. The two have different lengths
* ("ß" becomes two characters, "ue" one), so a match found in the folded form
* is mapped back through this table.
*/
function foldWithMap(text: string): {
folded: string;
from: number[];
to: number[];
} {
// One entry per UTF-16 unit of the folded string, so an index `indexOf`
// returns can be looked up directly, astral characters included.
const units: string[] = [];
const from: number[] = [];
const to: number[] = [];
// Whether the vowel at that position has already taken in its "e".
const absorbed: boolean[] = [];
let index = 0;
for (const ch of text) {
const end = index + ch.length;
let piece = ch.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase();
if (/\s/u.test(ch)) piece = " ";
piece = piece.replace(/ß/g, "ss");
for (const out of piece) {
const prev = units.length - 1;
// "ae", "oe", "ue" fold onto the vowel: the "e" widens the vowel's
// source range instead of adding a character.
if (
out === "e" &&
prev >= 0 &&
/[aou]/.test(units[prev]) &&
!absorbed[prev]
) {
to[prev] = end;
absorbed[prev] = true;
continue;
}
for (let k = 0; k < out.length; k++) {
units.push(out[k]);
from.push(index);
to.push(end);
absorbed.push(false);
}
}
index = end;
}
return { folded: units.join(""), from, to };
}
/**
* The ranges of `text` the query's words hit by their own spelling, under the
* same rules as the matcher: a word of four letters or more anywhere, a
* shorter one only at the start of a word. Synonym hits are not marked (the
* text holds a different word), so a result can match with nothing marked.
*/
export function markMatches(text: string, query: string): MarkRange[] {
// The matcher's words; a query too short for the tokeniser ("Hb") is
// found by the phrase rule, so it is marked by its folded self.
const tokens = queryWords(query);
const words = [
...new Set(
(tokens.length > 0 ? tokens : foldForMatch(query).split(" ")).map((w) =>
foldGerman(w),
),
),
].filter((w) => w.length >= 2);
if (words.length === 0 || text.length === 0) return [];
const { folded, from, to } = foldWithMap(text);
const ranges: MarkRange[] = [];
for (const word of words) {
let at = folded.indexOf(word);
while (at !== -1) {
const startsWord = at === 0 || !/[\p{L}\p{N}]/u.test(folded[at - 1]);
if (word.length >= MIN_SUBSTRING_WORD || startsWord) {
ranges.push([from[at], to[at + word.length - 1]]);
}
at = folded.indexOf(word, at + 1);
}
}
ranges.sort((a, b) => a[0] - b[0] || b[1] - a[1]);
const merged: MarkRange[] = [];
for (const range of ranges) {
const last = merged[merged.length - 1];
if (last && range[0] <= last[1]) last[1] = Math.max(last[1], range[1]);
else merged.push([range[0], range[1]]);
}
return merged;
}
/** `text` with its marks for `query`. */
export function marked(text: string, query: string): MarkedText {
return { text, marks: markMatches(text, query) };
}
/** Characters a snippet holds at most, before the ellipses. */
export const SNIPPET_CHARS = 140;
/** Characters a snippet keeps ahead of its first mark. */
const SNIPPET_LEAD = 40;
/**
* A short window of free text around its first match, whitespace collapsed,
* cut at word boundaries and marked with "…" where it was cut. Null when the
* query hits nothing in the text by its own spelling.
*/
export function snippetOf(
raw: string,
query: string,
max: number = SNIPPET_CHARS,
): MarkedText | null {
const text = raw.replace(/\s+/g, " ").trim();
const marks = markMatches(text, query);
if (marks.length === 0) return null;
if (text.length <= max) return { text, marks };
let start = Math.max(0, marks[0][0] - SNIPPET_LEAD);
if (start > 0) {
const space = text.indexOf(" ", start);
if (space !== -1 && space < marks[0][0]) start = space + 1;
}
let end = Math.min(text.length, start + max);
if (end < text.length) {
const space = text.lastIndexOf(" ", end);
if (space > marks[0][1]) end = space;
}
const lead = start > 0 ? "…" : "";
const tail = end < text.length ? "…" : "";
const shift = lead.length - start;
return {
text: `${lead}${text.slice(start, end)}${tail}`,
marks: marks
.filter(([s, e]) => s >= start && e <= end)
.map(([s, e]) => [s + shift, e + shift] as MarkRange),
};
}
+960
View File
@@ -0,0 +1,960 @@
/**
* The in-app record search behind `GET /api/search` (Discussion #1117): one
* query over visits, conditions, documents, vaccinations, lab results,
* medications, symptoms, workouts and the free-text notes on mood entries,
* readings and illness days, each hit with the in-app path that opens it.
*
* Most of the record is ciphertext at rest, so matching cannot run in SQL.
* The search reads the record's own rows newest first, decrypts them here and
* matches them with the shared matcher (`./match.ts`, the one the MCP
* `search` tool uses). The four clinical kinds come from the MCP module's own
* reads (`loadRecordCandidates`); the rest are read here in batches.
*
* Bounds, so a large record cannot take the heap or the pool down:
*
* - every batched source reads at most `MAX_SCAN_ROWS_PER_SOURCE` rows, in
* batches of `SCAN_BATCH`, and stops as soon as it holds more hits than
* the caller's limit (newest first, so those are the newest matches);
* - one search shares a time budget (`SEARCH_TIME_BUDGET_MS`); a source
* checks it before every batch and stops when it has run out;
* - at most `SOURCE_CONCURRENCY` sources read at once, so one search holds
* a few pool connections, not one per table;
* - only matching rows are kept: a batch's decrypted text is dropped once
* the batch is matched.
*
* When a cap or the budget cut a source short the answer says `truncated`.
*
* Plaintext columns that are indexable (a lab's analyte, a medication's name,
* a workout's sport) are read whole and matched here as well, so the folding
* is the same for every kind: "Schulter", "schulter" and "Schülter" are one
* word to the search whichever column holds it.
*
* Nothing here logs the query or any record text. The route annotates the
* query's length and the counts only.
*/
import type { MeasurementType } from "@/generated/prisma/client";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { readNote } from "@/lib/crypto/note-cipher";
import { prisma } from "@/lib/db";
import {
decryptIndexText,
decryptVerbatimText,
} from "@/lib/documents/content-index";
import { normalizeSearchText } from "@/lib/encounters/procedures";
import type { Locale } from "@/lib/i18n/config";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { loadRecordCandidates, type RecordKind } from "@/lib/mcp/record-search";
import { moduleForMeasurementType } from "@/lib/modules/measurement-scope";
import { isSurfaceVisible, type SurfaceModuleMap } from "@/lib/modules/surface";
import type { ShareScope } from "@/lib/sharing/scope";
import { userDayKey } from "@/lib/tz/format";
import {
SEARCH_HIT_KINDS,
type SearchGroup,
type SearchHit,
type SearchHitKind,
type SearchMarkedText,
type SearchNoteSource,
type SearchResponse,
} from "./contract";
import {
isHit,
marked,
queryWords,
scoreCandidate,
snippetOf,
WEIGHT,
type SearchCandidate,
} from "./match";
import {
SEARCH_SOURCE_DOMAIN,
SEARCH_SOURCE_KEYS,
SEARCH_SOURCE_KIND,
type SearchSourceKey,
} from "./sources";
// ── Bounds ───────────────────────────────────────────────────────────
/** Rows one batched source reads per round trip. */
export const SCAN_BATCH = 250;
/** Rows one batched source reads at most, per search. */
export const MAX_SCAN_ROWS_PER_SOURCE = 5000;
/**
* Newest rows of each clinical kind (visits, conditions, documents,
* vaccinations) one search reads. Wider than the MCP bound: a person's own
* search is asked about the operation of ten years ago.
*/
export const RECORD_ROWS_PER_KIND = 2000;
/** Wall-clock budget one search shares across its sources. */
export const SEARCH_TIME_BUDGET_MS = 1500;
/** Sources that read at the same time. */
export const SOURCE_CONCURRENCY = 4;
/** Plaintext name rows (analytes, medications, symptom names) read at most. */
const MAX_NAME_ROWS = 1000;
/** Documents whose indexed text is opened for a snippet, per answer. */
const MAX_DOCUMENT_SNIPPETS = 5;
// ── The scan ─────────────────────────────────────────────────────────
/** Shared by every source of one search. */
export interface ScanBudget {
readonly deadline: number;
readonly now: () => number;
/** A cap or the deadline stopped a source before it read everything. */
truncated: boolean;
/** Rows read, for the annotation. */
rows: number;
}
/**
* Read a source newest first in batches until it is exhausted, holds more
* than `wanted` hits, has read `maxRows` rows or the budget has run out.
* `read` pages by the last row's id (Prisma cursor over a stable order);
* `match` turns a row into a candidate that is a hit, or null.
*/
export async function scanNewest<Row extends { id: string }>(args: {
budget: ScanBudget;
wanted: number;
maxRows?: number;
batch?: number;
read: (cursor: string | null, take: number) => Promise<Row[]>;
match: (row: Row) => SearchCandidate | null;
}): Promise<SearchCandidate[]> {
const { budget, wanted, read, match } = args;
const maxRows = args.maxRows ?? MAX_SCAN_ROWS_PER_SOURCE;
const batch = args.batch ?? SCAN_BATCH;
const out: SearchCandidate[] = [];
let cursor: string | null = null;
let scanned = 0;
for (;;) {
if (budget.now() >= budget.deadline) {
budget.truncated = true;
break;
}
const take = Math.min(batch, maxRows - scanned);
const rows = await read(cursor, take);
scanned += rows.length;
budget.rows += rows.length;
for (const row of rows) {
const candidate = match(row);
if (candidate) out.push(candidate);
}
// A short batch is the end of the source.
if (rows.length < take) break;
// One more than the limit: the caller learns there is more.
if (out.length > wanted) break;
if (scanned >= maxRows) {
budget.truncated = true;
break;
}
cursor = rows[rows.length - 1].id;
}
return out;
}
/** Prisma's keyset arguments for `scanNewest`. */
function after(cursor: string | null): {
cursor?: { id: string };
skip?: number;
} {
return cursor ? { cursor: { id: cursor }, skip: 1 } : {};
}
/** Run `tasks` with at most `limit` in flight. */
async function inPool(
tasks: ReadonlyArray<() => Promise<void>>,
limit: number,
): Promise<void> {
let next = 0;
const worker = async () => {
while (next < tasks.length) {
const task = tasks[next++];
await task();
}
};
await Promise.all(
Array.from({ length: Math.min(limit, tasks.length) }, worker),
);
}
// ── Sources ──────────────────────────────────────────────────────────
interface SearchContext {
recordId: string;
query: string;
words: readonly string[];
foldedQuery: string;
wanted: number;
timezone: string;
display: Locale;
modules: SurfaceModuleMap | null;
budget: ScanBudget;
}
/** Decrypt a free-text `Bytes` column fail-soft: a key gap reads as absent. */
function decryptText(value: Uint8Array | null): string | null {
if (!value || value.byteLength === 0) return null;
try {
const text = decryptFromBytes(value);
return text.trim() ? text : null;
} catch {
return null;
}
}
/** `readNote` fail-soft, for the two columns still carrying a plaintext twin. */
function readNoteSoft(
ciphertext: Uint8Array | null,
plaintext: string | null,
): string | null {
try {
const text = readNote(ciphertext, plaintext);
return text?.trim() ? text : null;
} catch {
return null;
}
}
/** The candidate when it is a hit for the context's query, else null. */
function keepIfHit(
ctx: SearchContext,
candidate: SearchCandidate,
): SearchCandidate | null {
return isHit(scoreCandidate(candidate, ctx.words, ctx.foldedQuery))
? candidate
: null;
}
function noteCandidate(args: {
id: string;
source: SearchSourceKey;
recordId: string;
title: string | null;
note: string;
date: Date | null;
day: string | null;
path: string;
noteSource?: SearchNoteSource;
}): SearchCandidate {
return {
id: args.id,
title: args.title ?? "",
url: args.path,
kindOrder: 0,
date: args.date?.getTime() ?? null,
fields: [{ text: args.note, weight: WEIGHT.tertiary, body: true }],
hit: {
kind: SEARCH_SOURCE_KIND[args.source],
recordId: args.recordId,
title: args.title,
context: [],
day: args.day,
path: args.path,
...(args.noteSource ? { source: args.noteSource } : {}),
},
};
}
async function labAnalyteCandidates(
ctx: SearchContext,
): Promise<SearchCandidate[]> {
const rows = await prisma.labResult.groupBy({
by: ["analyte"],
where: { userId: ctx.recordId, deletedAt: null },
_max: { takenAt: true },
orderBy: { _max: { takenAt: "desc" } },
take: MAX_NAME_ROWS,
});
ctx.budget.rows += rows.length;
return rows.flatMap((row) => {
const latest = row._max.takenAt;
const path = `/labs?analyte=${encodeURIComponent(row.analyte)}`;
const candidate = keepIfHit(ctx, {
id: `labAnalyte:${row.analyte}`,
title: row.analyte,
url: path,
kindOrder: 0,
date: latest?.getTime() ?? null,
fields: [{ text: row.analyte, weight: WEIGHT.primary }],
hit: {
kind: "lab",
recordId: row.analyte,
title: row.analyte,
context: [],
day: latest ? userDayKey(latest, ctx.timezone) : null,
path,
},
});
return candidate ? [candidate] : [];
});
}
function labNoteCandidates(ctx: SearchContext): Promise<SearchCandidate[]> {
return scanNewest({
budget: ctx.budget,
wanted: ctx.wanted,
read: (cursor, take) =>
prisma.labResult.findMany({
where: {
userId: ctx.recordId,
deletedAt: null,
noteEncrypted: { not: null },
},
select: { id: true, analyte: true, takenAt: true, noteEncrypted: true },
orderBy: [{ takenAt: "desc" }, { id: "desc" }],
take,
...after(cursor),
}),
match: (row) => {
const note = decryptText(row.noteEncrypted);
if (!note) return null;
return keepIfHit(
ctx,
noteCandidate({
id: `labNote:${row.id}`,
source: "labNote",
recordId: row.id,
title: row.analyte,
note,
date: row.takenAt,
day: userDayKey(row.takenAt, ctx.timezone),
path: `/labs?analyte=${encodeURIComponent(row.analyte)}`,
}),
);
},
});
}
async function medicationCandidates(
ctx: SearchContext,
): Promise<SearchCandidate[]> {
const rows = await prisma.medication.findMany({
where: { userId: ctx.recordId },
select: { id: true, name: true, dose: true, createdAt: true },
orderBy: [{ createdAt: "desc" }, { id: "asc" }],
take: MAX_NAME_ROWS,
});
ctx.budget.rows += rows.length;
return rows.flatMap((row) => {
const path = `/medications/${encodeURIComponent(row.id)}`;
const candidate = keepIfHit(ctx, {
id: `medication:${row.id}`,
title: row.name,
url: path,
kindOrder: 0,
date: row.createdAt.getTime(),
fields: [
{ text: row.name, weight: WEIGHT.primary },
{ text: row.dose, weight: WEIGHT.secondary },
],
hit: {
kind: "medication",
recordId: row.id,
title: row.name,
context: row.dose.trim() ? [row.dose.trim()] : [],
day: null,
path,
},
});
return candidate ? [candidate] : [];
});
}
/** The record's symptom names, decrypted once per search. */
function symptomLabels(
ctx: SearchContext,
): () => Promise<Map<string, string | null>> {
let pending: Promise<Map<string, string | null>> | null = null;
return () => {
pending ??= prisma.symptomDefinition
.findMany({
where: { userId: ctx.recordId },
select: { id: true, labelEncrypted: true },
orderBy: [{ sortOrder: "asc" }, { id: "asc" }],
take: MAX_NAME_ROWS,
})
.then((rows) => {
ctx.budget.rows += rows.length;
return new Map(
rows.map((row) => [row.id, decryptText(row.labelEncrypted)]),
);
});
return pending;
};
}
async function symptomCandidates(
ctx: SearchContext,
labels: () => Promise<Map<string, string | null>>,
): Promise<SearchCandidate[]> {
const byId = await labels();
return [...byId].flatMap(([id, label]) => {
if (!label) return [];
const path = `/illness?symptom=${encodeURIComponent(id)}`;
const candidate = keepIfHit(ctx, {
id: `symptom:${id}`,
title: label,
url: path,
kindOrder: 0,
date: null,
fields: [{ text: label, weight: WEIGHT.primary }],
hit: {
kind: "symptom",
recordId: id,
title: label,
context: [],
day: null,
path,
},
});
return candidate ? [candidate] : [];
});
}
async function symptomNoteCandidates(
ctx: SearchContext,
labels: () => Promise<Map<string, string | null>>,
): Promise<SearchCandidate[]> {
const byId = await labels();
return scanNewest({
budget: ctx.budget,
wanted: ctx.wanted,
read: (cursor, take) =>
prisma.symptomEvent.findMany({
where: { userId: ctx.recordId, noteEncrypted: { not: null } },
select: {
id: true,
definitionId: true,
occurredAt: true,
noteEncrypted: true,
},
orderBy: [{ occurredAt: "desc" }, { id: "desc" }],
take,
...after(cursor),
}),
match: (row) => {
const note = decryptText(row.noteEncrypted);
if (!note) return null;
const day = userDayKey(row.occurredAt, ctx.timezone);
return keepIfHit(
ctx,
noteCandidate({
id: `symptomNote:${row.id}`,
source: "symptomNote",
recordId: row.id,
title: byId.get(row.definitionId) ?? null,
note,
date: row.occurredAt,
day,
path: `/illness?day=${day}`,
}),
);
},
});
}
/** "stairClimber" → "stair climber". */
function sportWords(sportType: string): string {
return sportType
.replace(/([a-z])([A-Z])/g, "$1 $2")
.replace(/_/g, " ")
.toLowerCase();
}
/** The sport's name in the display language, else its words. */
function sportLabel(sportType: string, locale: Locale): string {
const key = `insights.workouts.sport.${sportType}`;
const label = getServerTranslator(locale).t(key);
return label === key ? sportWords(sportType) : label;
}
async function workoutCandidates(
ctx: SearchContext,
): Promise<SearchCandidate[]> {
const types = await prisma.workout.groupBy({
by: ["sportType"],
where: { userId: ctx.recordId },
});
ctx.budget.rows += types.length;
// A sport matches by its name in the display language, in English and as
// its enum words, so "laufen", "running" and "run" all find a run.
const matched = types
.map((t) => t.sportType)
.filter((sportType) =>
keepIfHit(ctx, {
id: sportType,
title: sportType,
url: "",
kindOrder: 0,
date: null,
fields: [
{
text: [
sportLabel(sportType, ctx.display),
sportLabel(sportType, "en"),
sportWords(sportType),
].join(" "),
weight: WEIGHT.primary,
},
],
}),
);
if (matched.length === 0) return [];
const rows = await prisma.workout.findMany({
where: { userId: ctx.recordId, sportType: { in: matched } },
select: { id: true, sportType: true, startedAt: true },
orderBy: [{ startedAt: "desc" }, { id: "desc" }],
take: ctx.wanted + 1,
});
ctx.budget.rows += rows.length;
return rows.map((row) => {
const path = `/insights/workouts/${encodeURIComponent(row.id)}`;
const label = sportLabel(row.sportType, ctx.display);
return {
id: `workout:${row.id}`,
title: label,
url: path,
kindOrder: 0,
date: row.startedAt.getTime(),
fields: [
{
text: [
label,
sportLabel(row.sportType, "en"),
sportWords(row.sportType),
].join(" "),
weight: WEIGHT.primary,
},
],
hit: {
kind: "workout",
recordId: row.id,
title: label,
context: [],
day: userDayKey(row.startedAt, ctx.timezone),
path,
},
};
});
}
function moodNoteCandidates(ctx: SearchContext): Promise<SearchCandidate[]> {
return scanNewest({
budget: ctx.budget,
wanted: ctx.wanted,
read: (cursor, take) =>
prisma.moodEntry.findMany({
where: {
userId: ctx.recordId,
deletedAt: null,
OR: [{ noteEncrypted: { not: null } }, { note: { not: null } }],
},
select: {
id: true,
date: true,
moodLoggedAt: true,
note: true,
noteEncrypted: true,
},
orderBy: [{ moodLoggedAt: "desc" }, { id: "desc" }],
take,
...after(cursor),
}),
match: (row) => {
const note = readNoteSoft(row.noteEncrypted, row.note);
if (!note) return null;
return keepIfHit(
ctx,
noteCandidate({
id: `moodNote:${row.id}`,
source: "moodNote",
recordId: row.id,
title: null,
note,
date: row.moodLoggedAt,
day: row.date,
path: `/mood?day=${row.date}`,
noteSource: "mood",
}),
);
},
});
}
function illnessNoteCandidates(ctx: SearchContext): Promise<SearchCandidate[]> {
return scanNewest({
budget: ctx.budget,
wanted: ctx.wanted,
read: (cursor, take) =>
prisma.illnessDayLog.findMany({
where: {
userId: ctx.recordId,
deletedAt: null,
noteEncrypted: { not: null },
episode: { deletedAt: null },
},
select: {
id: true,
date: true,
noteEncrypted: true,
episode: { select: { id: true, label: true } },
},
orderBy: [{ date: "desc" }, { id: "desc" }],
take,
...after(cursor),
}),
match: (row) => {
const note = decryptText(row.noteEncrypted);
if (!note) return null;
return keepIfHit(
ctx,
noteCandidate({
id: `illnessNote:${row.id}`,
source: "illnessNote",
recordId: row.id,
title: row.episode.label,
note,
// The day log's own calendar date; noon keeps the sort stable.
date: new Date(`${row.date}T12:00:00Z`),
day: row.date,
path: `/illness/${encodeURIComponent(row.episode.id)}`,
noteSource: "illness",
}),
);
},
});
}
function measurementNoteCandidates(
ctx: SearchContext,
): Promise<SearchCandidate[]> {
// A note on a reading follows the module of the reading's type.
const typeVisible = (type: MeasurementType): boolean => {
const owner = moduleForMeasurementType(type);
return owner === null || ctx.modules?.[owner] !== false;
};
return scanNewest({
budget: ctx.budget,
wanted: ctx.wanted,
// Served by the partial index `measurements_noted_live_idx` (migration
// 0397): only live rows that carry a note, so a record of millions of
// readings with a handful of notes reads the handful.
read: (cursor, take) =>
prisma.measurement.findMany({
where: {
userId: ctx.recordId,
deletedAt: null,
OR: [{ notesEncrypted: { not: null } }, { notes: { not: null } }],
},
select: {
id: true,
type: true,
measuredAt: true,
notes: true,
notesEncrypted: true,
},
orderBy: [{ measuredAt: "desc" }, { id: "desc" }],
take,
...after(cursor),
}),
match: (row) => {
if (!typeVisible(row.type)) return null;
const note = readNoteSoft(row.notesEncrypted, row.notes);
if (!note) return null;
const day = userDayKey(row.measuredAt, ctx.timezone);
return keepIfHit(
ctx,
noteCandidate({
id: `measurementNote:${row.id}`,
source: "measurementNote",
recordId: row.id,
title: null,
note,
date: row.measuredAt,
day,
path: `/measurements?day=${day}`,
noteSource: "measurement",
}),
);
},
});
}
// ── Ranking and the answer ───────────────────────────────────────────
const RECORD_SOURCES: ReadonlyArray<SearchSourceKey & RecordKind> = [
"visit",
"condition",
"document",
"vaccination",
];
interface Ranked {
candidate: SearchCandidate;
matched: number;
titleHit: boolean;
score: number;
}
function compareRanked(a: Ranked, b: Ranked): number {
return (
b.matched - a.matched ||
Number(b.titleHit) - Number(a.titleHit) ||
b.score - a.score ||
(b.candidate.date ?? 0) - (a.candidate.date ?? 0) ||
(a.candidate.id < b.candidate.id
? -1
: a.candidate.id > b.candidate.id
? 1
: 0)
);
}
function markedOrNull(
text: string | null,
query: string,
): SearchMarkedText | null {
return text ? marked(text, query) : null;
}
/** The snippet: the first free-text field the query hit, unless it is the title. */
function snippetFor(
candidate: SearchCandidate,
query: string,
): SearchMarkedText | null {
const title = candidate.hit?.title ?? null;
for (const field of candidate.fields) {
if (!field.body || !field.text.trim()) continue;
if (title !== null && field.text.trim() === title.trim()) continue;
const snippet = snippetOf(field.text, query);
if (snippet) return snippet;
}
return null;
}
function toHit(candidate: SearchCandidate, query: string): SearchHit {
const hit = candidate.hit!;
return {
id: candidate.id,
kind: hit.kind as SearchHitKind,
title: markedOrNull(hit.title, query),
context: hit.context.map((value) => marked(value, query)),
snippet: snippetFor(candidate, query),
day: hit.day,
path: hit.path,
source: (hit.source as SearchNoteSource | undefined) ?? null,
};
}
/**
* A snippet of the indexed text for the listed documents that matched by
* their text alone. Bounded: the few listed documents, never the vault.
*/
async function addDocumentSnippets(
recordId: string,
hits: SearchHit[],
query: string,
): Promise<void> {
const wanting = hits
.filter(
(hit) =>
hit.kind === "document" &&
hit.snippet === null &&
(hit.title?.marks.length ?? 0) === 0,
)
.slice(0, MAX_DOCUMENT_SNIPPETS);
if (wanting.length === 0) return;
const byDocument = new Map(
wanting.map((hit) => [hit.id.slice("document:".length), hit]),
);
const rows = await prisma.documentContentIndex.findMany({
where: { userId: recordId, documentId: { in: [...byDocument.keys()] } },
select: {
documentId: true,
textEncrypted: true,
verbatimTextEncrypted: true,
},
});
for (const row of rows) {
const hit = byDocument.get(row.documentId);
if (!hit) continue;
try {
const text =
row.verbatimTextEncrypted && row.verbatimTextEncrypted.byteLength > 0
? decryptVerbatimText(row.verbatimTextEncrypted)
: decryptIndexText(row.textEncrypted);
hit.snippet = snippetOf(text, query);
} catch {
// A key gap leaves the hit without a snippet; it still opens.
}
}
}
export interface SearchRecordsArgs {
/** The record the session is acting for. */
recordId: string;
query: string;
/** Most hits per group. */
limit: number;
/** The record's module map; an explicit `false` hides a source. */
modules: SurfaceModuleMap | null;
/** Whether the caller's grant covers a section (always true for the owner). */
domainVisible: (domain: ShareScope) => boolean;
/** The language of labels the server names (a visit kind, a sport). */
displayLocale: Locale;
/** The record's zone, for the calendar day of an instant. */
timezone: string;
/** Overrides for tests. */
now?: () => number;
budgetMs?: number;
}
export interface SearchStats {
sources: number;
rows: number;
candidates: number;
elapsedMs: number;
}
/** The sources this caller may read on this record. */
export function readableSources(
modules: SurfaceModuleMap | null,
domainVisible: (domain: ShareScope) => boolean,
): SearchSourceKey[] {
return SEARCH_SOURCE_KEYS.filter(
(key) =>
domainVisible(SEARCH_SOURCE_DOMAIN[key]) &&
isSurfaceVisible(`search:${key}`, modules),
);
}
export async function searchRecords(
args: SearchRecordsArgs,
): Promise<SearchResponse & { stats: SearchStats }> {
const now = args.now ?? Date.now;
const started = now();
const budget: ScanBudget = {
deadline: started + (args.budgetMs ?? SEARCH_TIME_BUDGET_MS),
now,
truncated: false,
rows: 0,
};
const ctx: SearchContext = {
recordId: args.recordId,
query: args.query,
words: queryWords(args.query),
foldedQuery: normalizeSearchText(args.query),
wanted: args.limit,
timezone: args.timezone,
display: args.displayLocale,
modules: args.modules,
budget,
};
const sources = new Set(readableSources(args.modules, args.domainVisible));
const collected: SearchCandidate[] = [];
const labels = symptomLabels(ctx);
const tasks: Array<() => Promise<void>> = [];
const add = (key: SearchSourceKey, run: () => Promise<SearchCandidate[]>) => {
if (!sources.has(key)) return;
tasks.push(async () => {
collected.push(...(await run()));
});
};
const recordKinds = new Set<RecordKind>(
RECORD_SOURCES.filter((key) => sources.has(key)),
);
if (recordKinds.size > 0) {
tasks.push(async () => {
const candidates = await loadRecordCandidates(
args.recordId,
args.query,
"",
{
kinds: recordKinds,
displayLocale: args.displayLocale,
includeAiDeferredText: true,
rowsPerKind: RECORD_ROWS_PER_KIND,
},
);
budget.rows += candidates.length;
// Each kind reads its newest `RECORD_ROWS_PER_KIND`; a kind that
// filled the read may have older rows the search did not see.
const perKind = new Map<string, number>();
for (const candidate of candidates) {
const kind = candidate.hit?.kind ?? "";
perKind.set(kind, (perKind.get(kind) ?? 0) + 1);
if (keepIfHit(ctx, candidate)) collected.push(candidate);
}
if ([...perKind.values()].some((n) => n >= RECORD_ROWS_PER_KIND)) {
budget.truncated = true;
}
});
}
add("labAnalyte", () => labAnalyteCandidates(ctx));
add("labNote", () => labNoteCandidates(ctx));
add("medication", () => medicationCandidates(ctx));
add("symptom", () => symptomCandidates(ctx, labels));
add("symptomNote", () => symptomNoteCandidates(ctx, labels));
add("workout", () => workoutCandidates(ctx));
add("moodNote", () => moodNoteCandidates(ctx));
add("illnessNote", () => illnessNoteCandidates(ctx));
add("measurementNote", () => measurementNoteCandidates(ctx));
await inPool(tasks, SOURCE_CONCURRENCY);
const ranked: Ranked[] = collected
.filter((candidate) => candidate.hit)
.map((candidate) => {
const s = scoreCandidate(candidate, ctx.words, ctx.foldedQuery);
const title = candidate.hit?.title ?? null;
return {
candidate,
matched: s.matched,
score: s.score,
titleHit: title !== null && marked(title, args.query).marks.length > 0,
};
})
.sort(compareRanked);
const groups = new Map<SearchHitKind, SearchGroup>();
for (const { candidate } of ranked) {
const kind = candidate.hit!.kind as SearchHitKind;
let group = groups.get(kind);
if (!group) {
group = { kind, hits: [], more: false };
groups.set(kind, group);
}
if (group.hits.length < args.limit)
group.hits.push(toHit(candidate, args.query));
else group.more = true;
}
// Groups in the order of their best hit; a tie keeps the fixed order.
const ordered = [...groups.values()].sort((a, b) => {
const ia = ranked.findIndex((r) => r.candidate.hit?.kind === a.kind);
const ib = ranked.findIndex((r) => r.candidate.hit?.kind === b.kind);
return (
ia - ib ||
SEARCH_HIT_KINDS.indexOf(a.kind) - SEARCH_HIT_KINDS.indexOf(b.kind)
);
});
await addDocumentSnippets(
args.recordId,
ordered.flatMap((group) => group.hits),
args.query,
);
return {
groups: ordered,
truncated: budget.truncated,
stats: {
sources: sources.size,
rows: budget.rows,
candidates: ranked.length,
elapsedMs: now() - started,
},
};
}
+72
View File
@@ -0,0 +1,72 @@
/**
* What the in-app record search reads: one source per table and field it
* matches, the group each lists its hits under, and the record section each
* belongs to. Pure data, so the surface registry test reads it too.
*
* Two masks, as everywhere else. A source is read only when the record's
* module for it is on (the `search:<source>` entries of
* `src/lib/modules/surface.ts`; a source with no entry is core) and when the
* caller's grant covers its section (`SEARCH_SOURCE_DOMAIN`). The route
* admits the whole record only, so for today's callers the second mask is
* always open; it is applied all the same, so narrowing that admission later
* cannot widen what the search reads.
*/
import type { ShareDomain } from "@/lib/sharing/scope";
import type { SearchHitKind } from "./contract";
export const SEARCH_SOURCE_KEYS = [
"visit",
"condition",
"document",
"vaccination",
"labAnalyte",
"labNote",
"medication",
"symptom",
"symptomNote",
"workout",
"moodNote",
"illnessNote",
"measurementNote",
] as const;
export type SearchSourceKey = (typeof SEARCH_SOURCE_KEYS)[number];
/** The group each source lists its hits under. */
export const SEARCH_SOURCE_KIND: Record<SearchSourceKey, SearchHitKind> = {
visit: "visit",
condition: "condition",
document: "document",
vaccination: "vaccination",
labAnalyte: "lab",
labNote: "lab",
medication: "medication",
symptom: "symptom",
symptomNote: "symptom",
workout: "workout",
moodNote: "note",
illnessNote: "note",
measurementNote: "note",
};
/**
* The record section each source reads, as the routes that serve those rows
* declare it: visits and the immunization log sit in `profile`, workouts and
* reading notes in `measurements`, mood in `mind`.
*/
export const SEARCH_SOURCE_DOMAIN: Record<SearchSourceKey, ShareDomain> = {
visit: "profile",
condition: "illness",
document: "documents",
vaccination: "profile",
labAnalyte: "labs",
labNote: "labs",
medication: "medications",
symptom: "illness",
symptomNote: "illness",
workout: "measurements",
moodNote: "mind",
illnessNote: "illness",
measurementNote: "measurements",
};
+76
View File
@@ -0,0 +1,76 @@
/**
* Zod schemas for `GET /api/search`. The route parses its query with these
* and the OpenAPI table publishes them. Server and OpenAPI side only: the
* palette imports its types from `./contract.ts`.
*/
import { z } from "zod/v4";
import {
SEARCH_HIT_KINDS,
SEARCH_LIMIT_DEFAULT,
SEARCH_LIMIT_MAX,
SEARCH_NOTE_SOURCES,
SEARCH_QUERY_MAX,
SEARCH_QUERY_MIN,
} from "./contract";
export const searchQuerySchema = z.object({
q: z
.string()
.trim()
.min(SEARCH_QUERY_MIN)
.max(SEARCH_QUERY_MAX)
.describe(
"What to look for. Matched without regard to case or accents, and German umlauts may be typed as ae, oe, ue or ss.",
),
limit: z.coerce
.number()
.int()
.min(1)
.max(SEARCH_LIMIT_MAX)
.default(SEARCH_LIMIT_DEFAULT)
.describe("Most hits per group."),
});
const markedTextSchema = z
.object({
text: z.string(),
marks: z
.array(z.tuple([z.number().int(), z.number().int()]))
.describe(
"Half-open [start, end) character ranges of `text` a query word matched.",
),
})
.meta({ id: "SearchMarkedText" });
export const searchHitSchema = z
.object({
id: z.string(),
kind: z.enum(SEARCH_HIT_KINDS),
title: markedTextSchema.nullable(),
context: z.array(markedTextSchema),
snippet: markedTextSchema.nullable(),
day: z.string().nullable(),
path: z
.string()
.describe("In-app path that opens the record, never an API path."),
source: z.enum(SEARCH_NOTE_SOURCES).nullable(),
})
.meta({ id: "SearchHit" });
export const searchResponseSchema = z
.object({
groups: z.array(
z.object({
kind: z.enum(SEARCH_HIT_KINDS),
hits: z.array(searchHitSchema),
more: z.boolean(),
}),
),
truncated: z
.boolean()
.describe(
"The search stopped before it had read every entry (a per-kind row cap or the time budget), so older entries may match and are not listed.",
),
})
.meta({ id: "SearchResponse" });
+413
View File
@@ -0,0 +1,413 @@
/**
* v1.42.2 (#1117) — `GET /api/search` against real Postgres.
*
* What the unit tests cannot show: that encrypted text is found after the
* decrypt whichever way the umlaut is typed, that the reads are narrowed to
* the record the session acts for (two accounts hold the same words here),
* that a switched-off module's rows are not read, that the per-source row cap
* really bounds a large record and says so, and that a delegate reads the
* owner's record only through a whole-record grant.
*
* Mutation checks (each run, each seen red):
* - drop `userId: ctx.recordId` from the mood-note read → "never returns the
* other account's records" goes red;
* - drop the `search:moodNote` surface entry → "a switched-off module
* contributes nothing" goes red;
* - let `scanNewest` read past `MAX_SCAN_ROWS_PER_SOURCE` → "stops at the
* scan cap" goes red.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
import { encryptNote } from "@/lib/crypto/note-cipher";
import type { SearchHit, SearchResponse } from "@/lib/search/contract";
import { MAX_SCAN_ROWS_PER_SOURCE } from "@/lib/search/search-records";
import { cookieJar, headerJar } from "./mock-next-headers";
import { getPrismaClient, switchSessionTo, truncateAllTables } from "./setup";
vi.mock("next/headers", async () => {
const { cookieJar, headerJar } = await import("./mock-next-headers");
return {
headers: vi.fn(async () => ({
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
})),
cookies: vi.fn(async () => ({
get: (name: string) => {
const value = cookieJar.get(name);
return value ? { name, value } : undefined;
},
set: (name: string, value: string) => {
cookieJar.set(name, value);
},
delete: (name: string) => {
cookieJar.delete(name);
},
})),
};
});
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
let counter = 0;
async function makeUser(label: string, modules: Record<string, boolean> = {}) {
const suffix = `${label}-${counter++}`;
return getPrismaClient().user.create({
data: {
username: `rs-${suffix}`,
email: `rs-${suffix}@example.test`,
displayName: `RS ${suffix}`,
role: "USER",
timezone: "Europe/Berlin",
locale: "de",
modulePreferencesJson: modules,
},
});
}
async function signIn(userId: string) {
const session = await getPrismaClient().session.create({
data: { userId, expiresAt: new Date(Date.now() + 60_000) },
});
cookieJar.set("healthlog_session", session.id);
return session;
}
async function search(q: string, limit?: number): Promise<Response> {
const { GET } = await import("@/app/api/search/route");
const params = new URLSearchParams({ q });
if (limit) params.set("limit", String(limit));
return (
GET as unknown as (
req: NextRequest,
ctx: { params: Promise<object> },
) => Promise<Response>
)(new NextRequest(`http://localhost/api/search?${params}`), {
params: Promise.resolve({}),
});
}
async function hits(q: string, limit?: number): Promise<SearchResponse> {
const res = await search(q, limit);
expect(res.status).toBe(200);
return ((await res.json()) as { data: SearchResponse }).data;
}
function all(result: SearchResponse): SearchHit[] {
return result.groups.flatMap((g) => g.hits);
}
/** One account's record holding "Schulter" in every source that can hold it. */
async function seedRecord(userId: string, tag: string) {
const db = getPrismaClient();
const visit = await db.encounter.create({
data: {
userId,
occurredAt: new Date("2026-03-02T09:00:00Z"),
kind: "PROCEDURE",
reasonEncrypted: encryptToBytes(`Arthroskopie ${tag}`),
bodySiteEncrypted: encryptToBytes("Schulter"),
laterality: "LEFT",
},
});
const condition = await db.illnessEpisode.create({
data: {
userId,
label: `Schulterprellung ${tag}`,
type: "INJURY",
onsetAt: new Date("2026-02-20T09:00:00Z"),
},
});
const mood = await db.moodEntry.create({
data: {
userId,
date: "2026-03-05",
mood: "OKAY",
score: 3,
moodLoggedAt: new Date("2026-03-05T18:00:00Z"),
noteEncrypted: encryptNote(`Die SCHULTER zwickt ${tag}`),
},
});
const reading = await db.measurement.create({
data: {
userId,
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: new Date("2026-03-06T07:00:00Z"),
notesEncrypted: encryptNote(`nach der Schulter-OP ${tag}`),
},
});
const medication = await db.medication.create({
data: { userId, name: `Ibuprofen ${tag}`, dose: "400 mg" },
});
const lab = await db.labResult.create({
data: {
userId,
analyte: "Hämoglobin",
value: 14,
unit: "g/dL",
takenAt: new Date("2026-03-01T08:00:00Z"),
},
});
const symptom = await db.symptomDefinition.create({
data: { userId, labelEncrypted: encryptToBytes(`Schulterschmerz ${tag}`) },
});
const document = await db.inboundDocument.create({
data: {
userId,
title: `MRT Schulter ${tag}`,
mimeType: "application/pdf",
byteSize: 4,
contentEncrypted: encryptToBytes("%PDF"),
kind: "IMAGING",
},
});
return {
visit,
condition,
mood,
reading,
medication,
lab,
symptom,
document,
};
}
beforeEach(async () => {
await truncateAllTables(getPrismaClient());
cookieJar.clear();
headerJar.clear();
});
describe("the record search", () => {
it("finds encrypted and plaintext records, case and umlaut blind, each with a path that opens it", async () => {
const owner = await makeUser("owner");
const mine = await seedRecord(owner.id, "eigen");
await signIn(owner.id);
const result = await hits("schulter", 20);
const byId = new Map(all(result).map((h) => [h.id, h]));
expect(byId.get(`visit:${mine.visit.id}`)?.path).toBe(
`/checkups?visit=${mine.visit.id}`,
);
expect(byId.get(`condition:${mine.condition.id}`)?.path).toBe(
`/illness/${mine.condition.id}`,
);
expect(byId.get(`document:${mine.document.id}`)?.path).toBe(
`/documents?doc=${mine.document.id}`,
);
expect(byId.get(`moodNote:${mine.mood.id}`)?.path).toBe(
"/mood?day=2026-03-05",
);
expect(byId.get(`measurementNote:${mine.reading.id}`)?.path).toBe(
"/measurements?day=2026-03-06",
);
expect(byId.get(`symptom:${mine.symptom.id}`)?.path).toBe(
`/illness?symptom=${mine.symptom.id}`,
);
// The note's snippet marks the word as the person wrote it.
const mood = byId.get(`moodNote:${mine.mood.id}`)!;
expect(mood.source).toBe("mood");
const [[start, end]] = mood.snippet!.marks;
expect(mood.snippet!.text.slice(start, end)).toBe("SCHULTER");
// The visit names its site in context, in the reader's language.
const visit = byId.get(`visit:${mine.visit.id}`)!;
expect(visit.title?.text).toBe("Arthroskopie eigen");
expect(visit.context.map((c) => c.text).join(" ")).toContain("Schulter");
// Umlauts typed either way, in either direction.
const lab = all(await hits("haemoglobin"));
expect(lab.map((h) => h.path)).toContain(
`/labs?analyte=${encodeURIComponent("Hämoglobin")}`,
);
expect(all(await hits("HÄMOGLOBIN")).length).toBeGreaterThan(0);
const meds = all(await hits("ibuprofen"));
expect(meds.map((h) => h.path)).toEqual([
`/medications/${mine.medication.id}`,
]);
});
it("never returns the other account's records", async () => {
const owner = await makeUser("owner");
const other = await makeUser("other");
await seedRecord(owner.id, "eigen");
const theirs = await seedRecord(other.id, "fremd");
await signIn(owner.id);
const found = all(await hits("schulter", 20));
expect(found.length).toBeGreaterThan(5);
const theirIds = new Set(
Object.values(theirs).map((row) => (row as { id: string }).id),
);
for (const hit of found) {
expect(theirIds.has(hit.id.slice(hit.id.indexOf(":") + 1))).toBe(false);
expect(JSON.stringify(hit)).not.toContain("fremd");
}
expect(all(await hits("fremd"))).toEqual([]);
});
it("a switched-off module contributes nothing", async () => {
const owner = await makeUser("owner", {
mood: false,
illness: false,
medications: false,
});
const mine = await seedRecord(owner.id, "eigen");
await signIn(owner.id);
const found = all(await hits("schulter", 20));
const ids = found.map((h) => h.id);
expect(ids).toContain(`visit:${mine.visit.id}`);
expect(ids).not.toContain(`moodNote:${mine.mood.id}`);
expect(ids).not.toContain(`condition:${mine.condition.id}`);
expect(ids).not.toContain(`symptom:${mine.symptom.id}`);
expect(found.map((h) => h.kind)).not.toContain("condition");
expect(all(await hits("ibuprofen"))).toEqual([]);
});
it("stops at the scan cap on a large record and says so", async () => {
const db = getPrismaClient();
const owner = await makeUser("owner");
// The one matching note is the oldest; newer than it sit more noted
// readings than one search reads.
await db.measurement.create({
data: {
userId: owner.id,
type: "WEIGHT",
value: 80,
unit: "kg",
measuredAt: new Date("2020-01-01T07:00:00Z"),
notesEncrypted: encryptNote("Schulter"),
},
});
const filler = Array.from(
{ length: MAX_SCAN_ROWS_PER_SOURCE + 10 },
(_, i) => ({
userId: owner.id,
type: "WEIGHT" as const,
value: 80,
unit: "kg",
measuredAt: new Date(Date.UTC(2021, 0, 1) + i * 3_600_000),
notesEncrypted: encryptNote(`Waage ${i}`),
}),
);
await db.measurement.createMany({ data: filler });
await signIn(owner.id);
const capped = await hits("schulter");
expect(capped.truncated).toBe(true);
expect(all(capped)).toEqual([]);
// A newer match is found inside the cap, and the answer is not cut short
// when there is no more to read.
expect(all(await hits("Waage", 3)).length).toBe(3);
});
it("caps each group and says when there is more", async () => {
const db = getPrismaClient();
const owner = await makeUser("owner");
for (let i = 0; i < 4; i++) {
await db.moodEntry.create({
data: {
userId: owner.id,
date: `2026-01-0${i + 1}`,
mood: "OKAY",
score: 3,
moodLoggedAt: new Date(`2026-01-0${i + 1}T18:00:00Z`),
noteEncrypted: encryptNote(`Kopfweh ${i}`),
},
});
}
await signIn(owner.id);
const result = await hits("kopfweh", 2);
const notes = result.groups.find((g) => g.kind === "note")!;
expect(notes.hits).toHaveLength(2);
expect(notes.more).toBe(true);
// Newest first on a tie.
expect(notes.hits[0].day).toBe("2026-01-04");
});
it("a delegate searches the owner's record through a whole-record grant only", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
const mine = await seedRecord(owner.id, "eigen");
await seedRecord(delegate.id, "selbst");
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
const whole = await inviteGrant({
grantorId: owner.id,
granteeId: delegate.id,
access: "READ",
scope: null,
});
await acceptGrant({ grantId: whole.id, granteeId: delegate.id });
const session = await signIn(delegate.id);
await switchSessionTo(session.id, owner.id);
const found = all(await hits("schulter", 20));
expect(found.map((h) => h.id)).toContain(`visit:${mine.visit.id}`);
expect(JSON.stringify(found)).not.toContain("selbst");
});
it("a grant scoped to some sections does not open it", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await seedRecord(owner.id, "eigen");
const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants");
const scoped = await inviteGrant({
grantorId: owner.id,
granteeId: delegate.id,
access: "READ",
scope: ["profile"],
});
await acceptGrant({ grantId: scoped.id, granteeId: delegate.id });
const session = await signIn(delegate.id);
await switchSessionTo(session.id, owner.id);
const res = await search("schulter");
expect(res.status).toBe(403);
expect(await res.text()).not.toContain("Schulter");
});
it("refuses a query too short to search", async () => {
const owner = await makeUser("owner");
await signIn(owner.id);
expect((await search("s")).status).toBe(422);
});
it("reads the noted readings through the partial index", async () => {
const db = getPrismaClient();
const owner = await makeUser("owner");
await db.measurement.createMany({
data: Array.from({ length: 2000 }, (_, i) => ({
userId: owner.id,
type: "PULSE" as const,
value: 60,
unit: "bpm",
measuredAt: new Date(Date.UTC(2024, 0, 1) + i * 60_000),
})),
});
await db.$executeRawUnsafe("ANALYZE measurements");
const plan = await db.$queryRawUnsafe<Array<{ "QUERY PLAN": string }>>(
`EXPLAIN SELECT id FROM measurements
WHERE user_id = $1 AND deleted_at IS NULL
AND (notes_encrypted IS NOT NULL OR notes IS NOT NULL)
ORDER BY measured_at DESC, id DESC LIMIT 250`,
owner.id,
);
expect(plan.map((r) => r["QUERY PLAN"]).join("\n")).toContain(
"measurements_noted_live_idx",
);
});
});