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

This commit is contained in:
Marc-André Bombeck committed 2026-10-11 14:41:27 +02:00
commit bfba7aec67
60 files changed
+2081 -555

No files matched your search

+4
View File
@@ -34,6 +34,10 @@ notes, and machine-readable specs that ship alongside the source:
managed profiles for somebody with no login and the guardians who look
after them, what revocation does and does not undo, and which trust
properties of the instance are unchanged by any of it.
- [`using/`](./using/) — notes on using the app that sit close to the
code. [`using/search.md`](./using/search.md) covers the command palette's
record search: opening it, what it finds, how matching works, and its
limits on very large and shared records.
- [`migration/`](./migration/) — migration notes. Currently only
v1.3 to v1.4; later releases are covered by `CHANGELOG.md` and
<https://docs.healthlog.dev>.
+26 -25
View File
@@ -23729,7 +23729,7 @@ paths:
"422": *a4
"429": *a5
/api/search:
get:
post:
tags:
- Records
summary: Search the record
@@ -23738,30 +23738,31 @@ paths:
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.
newest first under a per-source row cap and a time budget; `truncated` says when that cut it short. The query
travels in the JSON body, never in the URL, and is never logged. Rate limited per acting account (20 a minute),
and one search at a time: a newer search by the same account stops the one before it, which then answers 409
`search.superseded`. A grant scoped to some sections of the record does not open it, like the timeline."
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
q:
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.
limit:
default: 5
description: Most hits per group.
type: integer
minimum: 1
maximum: 20
required:
- q
responses:
"200":
description: The hits, grouped by kind.
+27 -2
View File
@@ -179,6 +179,12 @@ HealthLog publishes to that topic.
**Server URL** (defaults to `https://ntfy.sh`; point it at your own
server if you self-host ntfy) and **Topic**, then save.
HealthLog publishes each message as JSON to the server's root URL (the topic
travels in the body, see ntfy's
[publish as JSON](https://docs.ntfy.sh/publish/#publish-as-json)), so titles
and button labels in any language arrive as written. A reverse proxy in front
of your own ntfy has to pass `POST /` through, not only `POST /<topic>`.
If your ntfy server requires auth, an access token can be attached; the
server URL is validated at save time and dialled through the egress guard
(`src/lib/notifications/senders/ntfy.ts`). A self-hosted ntfy on your own
@@ -474,7 +480,8 @@ once per event type, and every channel carries the same target:
How each channel carries it:
- **ntfy**: the `Click` header, so tapping the notification opens the page.
- **ntfy**: the message's `click` field, so tapping the notification opens
the page.
- **Telegram**: an **Open in HealthLog** button under the message, below any
action buttons. Telegram only accepts `https` button links on a public
host, so a plain-HTTP instance gets a text link instead, and an instance
@@ -522,13 +529,31 @@ token is the only credential, so it is built to be harmless on its own:
answer, so it cannot be moved to another account or another answer;
- it expires six hours after the reminder was sent;
- the three answers of one reminder are one use between them: the first
tap logs, every later tap on that reminder is refused;
tap logs, every later tap on that reminder is refused, and that stays
true when the logged entry is deleted and purged from the trash before
the six hours are up;
- the route is rate-limited before it reads the token, refuses when the
mood module has been turned off, and the token is scrubbed from logs.
The buttons need `APP_URL`, because the ntfy app calls the URL from your
phone. Your phone has to reach the instance at that address.
What the token cannot protect against is the topic itself. The button URLs,
token included, travel through your ntfy server inside the message, and the
message is delivered to everyone subscribed to the topic. For the six hours
a token is valid, anyone who can read the topic can log that one answer on
your behalf. On the public `ntfy.sh` the topic name is the only access
control, so:
- use a long, random topic name and do not share it;
- better, run your own ntfy server with access control and give HealthLog
an access token that may only publish to your topic;
- to invalidate every quick-answer token at once, rotate
`API_TOKEN_HMAC_KEY`. The same key also hashes API tokens and session
cookies, so rotating it invalidates every issued API token and signs out
every browser session and the iOS app as well (see
[getting started](./getting-started.md)).
**Telegram** keeps its own keyboard on the mood reminder: the full 1 to 5
scale, a note and a "later" button.
+55
View File
@@ -0,0 +1,55 @@
# Searching your record
The command palette finds pages and settings, and it searches what you have
recorded.
## Opening it
Press **⌘K** on a Mac or **Ctrl K** elsewhere, or tap the magnifier at the
top right, and start typing.
## What it finds
Below the pages and settings, a section **In your records** lists the
entries that match what you typed:
- visits (procedures included)
- conditions
- documents, by title, file name and the text read from them
- vaccinations
- lab results
- medications
- symptoms
- workouts
- notes on mood entries, readings and illness days
Each hit opens where the entry lives: a visit on the check-ups page, a lab
result on its analyte, a note on a mood entry or a reading on its day, a note
on an illness day in its illness.
## How matching works
Case and accents do not matter, so "schulter", "Schulter" and "Schülter"
find the same entries. Umlauts can be typed as `ae`, `oe` and `ue` (and `ß`
as `ss`), which helps on a keyboard without them.
## Very large records
The search reads each kind of entry newest first and stops after a fixed
number of entries and a short time limit, so it stays quick on a record that
holds years of data. When it stopped before reaching the oldest entries, the
list says so. A more specific search term usually finds an older entry.
## Shared records
When someone has shared their whole record with you, the search works inside
that record too. An account shared with you section by section cannot be
searched: the search lays every section side by side, which a partial share
does not open.
## Privacy
What you type is sent to your instance in the body of the request, not in the
address, so it does not end up in a reverse proxy's access log or in the
browser history. The server does not log the search text either, only its
length and how many hits it found.
+7 -3
View File
@@ -31,11 +31,15 @@ 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. */
/** Answer `POST /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 page.route("**/api/search", async (route) => {
// The query rides the JSON body, never the URL.
expect(route.request().method()).toBe("POST");
expect(route.request().url()).not.toContain("?");
const body = route.request().postDataJSON() as { q?: string } | null;
queries.push(body?.q ?? "");
await route.fulfill({
status: 200,
contentType: "application/json",
+2 -1
View File
@@ -6832,6 +6832,7 @@
"nocreds": "Oura ist auf diesem Server nicht konfiguriert. Bitte deinen Administrator, die Oura-OAuth-Zugangsdaten zu hinterlegen.",
"token": "Der Token-Austausch mit Oura ist fehlgeschlagen. Bitte starte die Verbindung erneut.",
"rate_limited": "Zu viele Verbindungsversuche. Warte einen Moment und versuche es erneut.",
"issuer": "Die Anmeldeantwort kam nicht vom Autorisierungsserver von Oura, deshalb wurde die Verbindung zu deiner Sicherheit abgebrochen. Bitte starte die Verbindung erneut. Wenn das wieder passiert, sag deinem Administrator Bescheid.",
"generic": "Unbekannter Fehler bei der Verbindung. Bitte starte die Verbindung erneut."
},
"integrationSetupGuide": "Einrichtungsanleitung",
@@ -7239,7 +7240,7 @@
"typeCodex": "ChatGPT-Anmeldung (persönlich)",
"typeLocal": "Lokales Modell (persönlich)",
"typeGateway": "OpenAI-kompatibles Gateway (persönlich)",
"spendToday": "Heute ({date}): {total} Tokens, davon {operator} auf dem Schlüssel des Betreibers.",
"spendToday": "Heute, jeweils am eigenen Kalendertag jedes Kontos: {total} Tokens, davon {operator} auf dem Schlüssel des Betreibers (Konten mit Verbrauch: {accounts}).",
"statusPaused": "Pausiert",
"pausedHint": "Nach wiederholten Fehlern für {count} Nutzer pausiert bis {at}. Danach wird er automatisch wieder versucht.",
"modelNotServed": "Der Endpunkt bietet das Modell {model} nicht mehr an. Wähle in den KI-Einstellungen ein Modell, das er anbietet."
+2 -1
View File
@@ -6832,6 +6832,7 @@
"nocreds": "Oura is not configured on this server. Ask your administrator to add the Oura OAuth credentials.",
"token": "The token exchange with Oura failed. Please start the connection again.",
"rate_limited": "Too many connection attempts. Wait a moment and try again.",
"issuer": "The sign-in answer did not come from Oura's own authorization server, so the connection was stopped for your safety. Please start the connection again. If this keeps happening, tell your administrator.",
"generic": "Unknown error while connecting. Please start the connection again."
},
"integrationSetupGuide": "Setup guide",
@@ -7239,7 +7240,7 @@
"typeCodex": "ChatGPT sign-in (personal)",
"typeLocal": "Local model (personal)",
"typeGateway": "OpenAI-compatible gateway (personal)",
"spendToday": "Today ({date}): {total} tokens, {operator} of them on the operator's own key.",
"spendToday": "Today, on each account's own calendar day: {total} tokens, {operator} of them on the operator's own key (accounts with spend: {accounts}).",
"statusPaused": "Paused",
"pausedHint": "Paused after repeated failures for {count} users until {at}. It is tried again automatically then.",
"modelNotServed": "The endpoint no longer offers the model {model}. Choose a model it lists in the AI settings."
+2 -1
View File
@@ -6832,6 +6832,7 @@
"nocreds": "Oura no está configurado en este servidor. Pide a tu administrador que añada las credenciales OAuth de Oura.",
"token": "El intercambio de token con Oura falló. Inicia la conexión de nuevo.",
"rate_limited": "Demasiados intentos de conexión. Espera un momento e inténtalo de nuevo.",
"issuer": "La respuesta de inicio de sesión no procedía del servidor de autorización de Oura, así que la conexión se detuvo por tu seguridad. Vuelve a iniciar la conexión. Si sigue ocurriendo, avisa a tu administrador.",
"generic": "Error desconocido al conectar. Inicia la conexión de nuevo."
},
"integrationSetupGuide": "Guía de configuración",
@@ -7239,7 +7240,7 @@
"typeCodex": "Inicio de sesión de ChatGPT (personal)",
"typeLocal": "Modelo local (personal)",
"typeGateway": "Pasarela compatible con OpenAI (personal)",
"spendToday": "Hoy ({date}): {total} tokens, {operator} de ellos en la clave del operador.",
"spendToday": "Hoy, en el día natural de cada cuenta: {total} tokens, {operator} de ellos en la clave del operador (cuentas con consumo: {accounts}).",
"statusPaused": "En pausa",
"pausedHint": "En pausa tras fallos repetidos para {count} usuarios hasta {at}. Después se vuelve a intentar automáticamente.",
"modelNotServed": "El endpoint ya no ofrece el modelo {model}. Elige en los ajustes de IA un modelo que ofrezca."
+2 -1
View File
@@ -6832,6 +6832,7 @@
"nocreds": "Oura n'est pas configuré sur ce serveur. Demandez à votre administrateur d'ajouter les identifiants OAuth Oura.",
"token": "L'échange de jeton avec Oura a échoué. Veuillez relancer la connexion.",
"rate_limited": "Trop de tentatives de connexion. Patientez un instant et réessayez.",
"issuer": "La réponse de connexion ne provenait pas du serveur d'autorisation d'Oura, la connexion a donc été interrompue par sécurité. Veuillez relancer la connexion. Si cela se reproduit, prévenez votre administrateur.",
"generic": "Erreur inconnue lors de la connexion. Veuillez relancer la connexion."
},
"integrationSetupGuide": "Guide de configuration",
@@ -7239,7 +7240,7 @@
"typeCodex": "Connexion ChatGPT (personnelle)",
"typeLocal": "Modèle local (personnel)",
"typeGateway": "Passerelle compatible OpenAI (personnelle)",
"spendToday": "Aujourd'hui ({date}) : {total} jetons, dont {operator} sur la clé de l'opérateur.",
"spendToday": "Aujourd'hui, selon le jour civil de chaque compte : {total} jetons, dont {operator} sur la clé de l'opérateur (comptes avec consommation : {accounts}).",
"statusPaused": "En pause",
"pausedHint": "En pause après des échecs répétés pour {count} utilisateurs jusqu’au {at}. Il sera ensuite réessayé automatiquement.",
"modelNotServed": "Le point d’accès ne propose plus le modèle {model}. Choisissez dans les réglages IA un modèle qu’il propose."
+2 -1
View File
@@ -6832,6 +6832,7 @@
"nocreds": "Oura non è configurato su questo server. Chiedi al tuo amministratore di aggiungere le credenziali OAuth di Oura.",
"token": "Lo scambio del token con Oura è fallito. Riavvia la connessione.",
"rate_limited": "Troppi tentativi di connessione. Attendi un momento e riprova.",
"issuer": "La risposta di accesso non proveniva dal server di autorizzazione di Oura, quindi la connessione è stata interrotta per la tua sicurezza. Avvia di nuovo la connessione. Se succede ancora, avvisa il tuo amministratore.",
"generic": "Errore sconosciuto durante la connessione. Riavvia la connessione."
},
"integrationSetupGuide": "Guida alla configurazione",
@@ -7239,7 +7240,7 @@
"typeCodex": "Accesso ChatGPT (personale)",
"typeLocal": "Modello locale (personale)",
"typeGateway": "Gateway compatibile OpenAI (personale)",
"spendToday": "Oggi ({date}): {total} token, di cui {operator} sulla chiave dell'operatore.",
"spendToday": "Oggi, secondo il giorno di calendario di ciascun account: {total} token, di cui {operator} sulla chiave dell'operatore (account con consumo: {accounts}).",
"statusPaused": "In pausa",
"pausedHint": "In pausa dopo errori ripetuti per {count} utenti fino al {at}. Poi viene ritentato automaticamente.",
"modelNotServed": "L’endpoint non offre più il modello {model}. Scegli nelle impostazioni IA un modello che offre."
+2 -1
View File
@@ -6832,6 +6832,7 @@
"nocreds": "이 서버에는 Oura가 설정돼 있지 않아요. 관리자에게 Oura OAuth 자격 증명을 추가해 달라고 요청하세요.",
"token": "Oura와의 토큰 교환에 실패했어요. 연결을 다시 시작해 주세요.",
"rate_limited": "연결 시도가 너무 많아요. 잠시 후 다시 시도해 주세요.",
"issuer": "로그인 응답이 Oura의 인증 서버에서 오지 않아서 안전을 위해 연결을 중단했어요. 연결을 다시 시작해 주세요. 계속 발생하면 관리자에게 알려 주세요.",
"generic": "연결 중 알 수 없는 오류가 났어요. 연결을 다시 시작해 주세요."
},
"integrationSetupGuide": "설정 가이드",
@@ -7957,7 +7958,7 @@
"typeCodex": "ChatGPT 로그인 (개인)",
"typeLocal": "로컬 모델 (개인)",
"typeGateway": "OpenAI 호환 게이트웨이 (개인)",
"spendToday": "오늘({date}): 토큰 {total}개, 그중 {operator}개는 운영자 키에서 사용되었습니다.",
"spendToday": "오늘(각 계정의 현지 날짜 기준): 토큰 {total}개, 그중 {operator}개는 운영자 키에서 사용되었습니다(사용량이 있는 계정: {accounts}개).",
"statusPaused": "일시 중지됨",
"pausedHint": "반복된 오류로 사용자 {count}명에 대해 {at}까지 일시 중지되었습니다. 그 후 자동으로 다시 시도합니다.",
"modelNotServed": "엔드포인트가 더 이상 {model} 모델을 제공하지 않습니다. AI 설정에서 제공되는 모델을 선택하세요."
+2 -1
View File
@@ -6832,6 +6832,7 @@
"nocreds": "Oura nie jest skonfigurowana na tym serwerze. Poproś administratora o dodanie danych OAuth Oura.",
"token": "Wymiana tokena z Oura nie powiodła się. Rozpocznij połączenie ponownie.",
"rate_limited": "Zbyt wiele prób połączenia. Poczekaj chwilę i spróbuj ponownie.",
"issuer": "Odpowiedź logowania nie pochodziła z serwera autoryzacji Oura, więc połączenie zostało przerwane dla Twojego bezpieczeństwa. Rozpocznij połączenie ponownie. Jeśli to się powtarza, powiadom administratora.",
"generic": "Nieznany błąd podczas łączenia. Rozpocznij połączenie ponownie."
},
"integrationSetupGuide": "Przewodnik konfiguracji",
@@ -7239,7 +7240,7 @@
"typeCodex": "Logowanie ChatGPT (osobiste)",
"typeLocal": "Model lokalny (osobisty)",
"typeGateway": "Brama zgodna z OpenAI (osobista)",
"spendToday": "Dzisiaj ({date}): {total} tokenów, w tym {operator} na kluczu operatora.",
"spendToday": "Dzisiaj, według dnia kalendarzowego każdego konta: {total} tokenów, w tym {operator} na kluczu operatora (konta ze zużyciem: {accounts}).",
"statusPaused": "Wstrzymany",
"pausedHint": "Wstrzymany po powtarzających się błędach dla {count} użytkowników do {at}. Potem zostanie automatycznie ponowiony.",
"modelNotServed": "Punkt końcowy nie oferuje już modelu {model}. Wybierz w ustawieniach AI model, który oferuje."
@@ -8,8 +8,13 @@
--
-- 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.
-- transaction. The build is not short on a large instance: the predicate
-- only decides which rows land in the index, so Postgres still scans the
-- whole `measurements` table to find them, and the non-concurrent build
-- blocks writes to the table while it runs. That is acceptable because the
-- entrypoint applies migrations before the app serves traffic, so no request
-- is waiting on those writes; an upgrade on a table with many millions of
-- readings simply takes longer to come up.
CREATE INDEX IF NOT EXISTS "measurements_noted_live_idx"
ON "measurements" ("user_id", "measured_at" DESC, "id" DESC)
WHERE "deleted_at" IS NULL
@@ -154,6 +154,13 @@ describe("proxy.ts DEMO_MODE mutation allowlist", () => {
).toBe(403);
});
it("permits the record search, a read that travels as a POST", () => {
// The query rides the body so it stays out of URLs and access logs; the
// route writes nothing, so the demo searches like any other instance.
expect(proxy(makeRequest("/api/search", "POST")).status).not.toBe(403);
expect(proxy(makeRequest("/api/search", "DELETE")).status).toBe(403);
});
it("still blocks a health-data mutation (POST /api/measurements)", () => {
const res = proxy(makeRequest("/api/measurements", "POST"));
expect(res.status).toBe(403);
@@ -0,0 +1,34 @@
/**
* `readOverPost` lets a delegable route answer a POST at the grant's read
* level. That widens what a read-only grant reaches, so the set of routes
* declaring it is frozen here: adding one is a reviewed diff, not an
* accident. Today it is the record search alone, whose query is health data
* that must stay out of URLs.
*
* The matcher tolerates whitespace and line breaks, and the guard asserts it
* matched something, so a rewrite that hides the declaration from the regex
* fails instead of passing on an empty set.
*/
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { globSync } from "node:fs";
import { join, relative, sep } from "node:path";
const ROOT = process.cwd();
const FROZEN = ["src/app/api/search/route.ts"];
describe("readOverPost declarations", () => {
it("are exactly the frozen set", () => {
const declaring = globSync("src/**/*.ts", { cwd: ROOT })
.filter((file) => !file.includes(`${sep}__tests__${sep}`))
.filter((file) => file !== join("src", "lib", "api-handler.ts"))
.filter((file) =>
/readOverPost\s*:\s*true/.test(readFileSync(join(ROOT, file), "utf8")),
)
.map((file) => relative(ROOT, join(ROOT, file)).split(sep).join("/"))
.sort();
expect(declaring.length).toBeGreaterThan(0);
expect(declaring).toEqual(FROZEN);
});
});
@@ -21,7 +21,7 @@ vi.mock("@/lib/db", () => ({
findMany: vi.fn(async () => []),
},
coachUsage: {
aggregate: vi.fn(),
findMany: vi.fn(async () => []),
},
// v1.42 — the operator key's model check reads the admin settings.
appSettings: { findUnique: vi.fn(async () => null) },
@@ -59,7 +59,19 @@ import { prisma } from "@/lib/db";
import { requireAdmin } from "@/lib/api-handler";
const groupBy = vi.mocked(prisma.providerHealth.groupBy);
const aggregate = vi.mocked(prisma.coachUsage.aggregate);
const usageRows = vi.mocked(prisma.coachUsage.findMany);
/** A spend row as the reader selects it. */
function usage(
dateKey: string,
totalTokens: number,
operatorTokens: number,
timezone: string | null = "UTC",
) {
return { dateKey, totalTokens, operatorTokens, user: { timezone } };
}
const utcToday = () => new Date().toISOString().slice(0, 10);
function group(
providerType: string,
@@ -91,9 +103,7 @@ describe("GET /api/admin/provider-health", () => {
vi.mocked(requireAdmin).mockResolvedValue({
user: { id: "admin1" },
} as never);
aggregate.mockResolvedValue({
_sum: { totalTokens: 0, operatorTokens: 0 },
} as never);
usageRows.mockResolvedValue([] as never);
});
it("is admin-gated before it touches the ledger", async () => {
@@ -231,31 +241,44 @@ describe("GET /api/admin/provider-health", () => {
*/
it("reports the day's spend split by who paid for it", async () => {
groupBy.mockResolvedValue([] as never);
aggregate.mockResolvedValue({
_sum: { totalTokens: 1_240_000, operatorTokens: 151_200 },
} as never);
usageRows.mockResolvedValue([
usage(utcToday(), 1_000_000, 100_000),
usage(utcToday(), 240_000, 51_200),
] as never);
const res = await GET();
const body = await res.json();
expect(body.data.spendToday).toEqual({
dateKey: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/),
totalTokens: 1_240_000,
operatorTokens: 151_200,
accounts: 2,
});
});
it("counts each account on its own today and nothing from another day", async () => {
groupBy.mockResolvedValue([] as never);
const yesterday = new Date(Date.now() - 86_400_000)
.toISOString()
.slice(0, 10);
usageRows.mockResolvedValue([
usage(utcToday(), 500, 100),
// The same UTC account's yesterday: already closed, not today's spend.
usage(yesterday, 9_999, 9_999),
] as never);
const res = await GET();
const body = await res.json();
expect(body.data.spendToday).toEqual({
totalTokens: 500,
operatorTokens: 100,
accounts: 1,
});
// Today only, across every account — the operator's ceiling is per day.
expect(aggregate).toHaveBeenCalledWith(
expect.objectContaining({
where: { dateKey: expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/) },
}),
);
});
it("reports a day with no spend as zero, not null", async () => {
groupBy.mockResolvedValue([] as never);
aggregate.mockResolvedValue({
_sum: { totalTokens: null, operatorTokens: null },
} as never);
usageRows.mockResolvedValue([] as never);
const res = await GET();
const body = await res.json();
@@ -281,9 +304,7 @@ describe("GET /api/admin/provider-health — pause and model check (v1.42)", ()
vi.mocked(requireAdmin).mockResolvedValue({
user: { id: "admin" },
} as never);
aggregate.mockResolvedValue({
_sum: { totalTokens: 0, operatorTokens: 0 },
} as never);
usageRows.mockResolvedValue([] as never);
findMany.mockResolvedValue([] as never);
settings.mockResolvedValue(null as never);
});
+11 -14
View File
@@ -1,5 +1,5 @@
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { buildDateKey } from "@/lib/ai/coach/budget";
import { readInstanceSpendToday } from "@/lib/ai/coach/budget";
import { prisma } from "@/lib/db";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
@@ -73,14 +73,9 @@ interface ProviderHealthSummary {
const CENTRAL_TYPES = [...OPERATOR_HELD_PROVIDER_TYPES];
export const GET = apiHandler(async () => {
const { user } = await requireAdmin();
await requireAdmin();
annotate({ action: { name: "admin.provider-health.get" } });
// The ledger buckets each account by its own local day; the operator's
// "today" is the day in the operator's own zone, which on a single-zone
// instance is everyone's.
const dateKey = await buildDateKey(user.id);
const groups = await prisma.providerHealth.groupBy({
// `lastStatus` joins the grouping key rather than an aggregate: the
// MAX of two HTTP statuses is not a status, it is arithmetic on a
@@ -177,17 +172,19 @@ export const GET = apiHandler(async () => {
// alarming until you see that 151 200 of them were the instance's own key
// and the rest ran on the users' plans. The operator ceiling is enforced
// against the second figure, so both belong on screen.
const spend = await prisma.coachUsage.aggregate({
where: { dateKey },
_sum: { totalTokens: true, operatorTokens: true },
});
//
// "Today" is each account's own calendar day, the day its ceilings are
// enforced on (`readInstanceSpendToday`), not one date for the instance:
// the budget day is per account local day, so a single date key would
// count some accounts' yesterday or tomorrow and miss part of their today.
const spend = await readInstanceSpendToday();
return apiSuccess({
providers,
spendToday: {
dateKey,
totalTokens: spend._sum.totalTokens ?? 0,
operatorTokens: spend._sum.operatorTokens ?? 0,
totalTokens: spend.total,
operatorTokens: spend.operator,
accounts: spend.accounts,
},
});
});
@@ -10,7 +10,10 @@ import { NextRequest } from "next/server";
const TEST_HMAC_KEY = "test-hmac-key-at-least-32-chars-long-xxxxx";
vi.mock("@/lib/db", () => ({
prisma: { user: { findUnique: vi.fn() } },
prisma: {
user: { findUnique: vi.fn() },
idempotencyKey: { create: vi.fn(), deleteMany: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/rate-limit", () => ({
@@ -67,6 +70,10 @@ beforeEach(() => {
timezone: "Europe/Berlin",
} as never);
vi.mocked(isModuleEnabled).mockResolvedValue(true);
vi.mocked(prisma.idempotencyKey.create).mockResolvedValue({} as never);
vi.mocked(prisma.idempotencyKey.deleteMany).mockResolvedValue({
count: 1,
} as never);
vi.mocked(logTelegramMood).mockResolvedValue({
created: true,
moodEntryId: "m1",
@@ -151,4 +158,42 @@ describe("POST /api/notifications/action/{token}", () => {
const res = await call(token({ action: "mood-bad" }));
expect(res.status).toBe(409);
});
it("refuses a spent nonce with 409 before writing anything", async () => {
const { Prisma } = await import("@/generated/prisma/client");
vi.mocked(prisma.idempotencyKey.create).mockRejectedValue(
new Prisma.PrismaClientKnownRequestError("unique", {
code: "P2002",
clientVersion: "test",
}),
);
const res = await call(token());
expect(res.status).toBe(409);
expect(logTelegramMood).not.toHaveBeenCalled();
});
it("claims the nonce until the token expires", async () => {
const now = Date.now();
await call(token({ now }));
expect(prisma.idempotencyKey.create).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: "user-1",
key: "notification-action:nonce-1",
requestFingerprint: null,
expiresAt: new Date(now + ACTION_TOKEN_TTL_MS),
}),
});
});
it("gives the claim back when the write fails for another reason", async () => {
vi.mocked(logTelegramMood).mockRejectedValue(new Error("db down"));
const res = await call(token());
expect(res.status).toBe(500);
expect(prisma.idempotencyKey.deleteMany).toHaveBeenCalledWith({
where: expect.objectContaining({
userId: "user-1",
key: "notification-action:nonce-1",
}),
});
});
});
@@ -13,10 +13,12 @@
* 2. Verify the signature (constant-time) and the expiry.
* 3. Re-check what may have changed since the reminder went out: the
* account still exists and its mood module is still on.
* 4. Write the mood entry under the reminder's nonce. The unique
* `(userId, source, externalId)` key makes the first answer the only
* 4. Claim the reminder's nonce (`action-nonce.ts`), then write the mood
* entry under it. The claim and the entry's unique
* `(userId, source, externalId)` key each make the first answer the only
* one: a repeat tap, or a different answer on the same reminder, is
* refused with 409 and writes nothing.
* refused with 409 and writes nothing. The claim is what still holds
* after the entry was deleted and purged inside the token's lifetime.
*
* The token travels in the path, so it is scrubbed from every log line by
* the `PATH_SECRET_PATHS` entry for this prefix, and the route sits on the
@@ -31,6 +33,10 @@ import { prisma } from "@/lib/db";
import { annotate, getEvent } from "@/lib/logging/context";
import { isModuleEnabled } from "@/lib/modules/gate";
import { logTelegramMood } from "@/lib/mood/create-from-telegram";
import {
claimActionNonce,
releaseActionNonce,
} from "@/lib/notifications/action-nonce";
import {
MOOD_QUICK_ACTIONS,
quickActionExternalId,
@@ -116,6 +122,23 @@ export const POST = apiHandler(
});
}
const alreadyUsed = () => {
annotate({
action: { name: "notification.action.refused" },
meta: { reason: "already_used" },
});
return apiError("This reminder was already answered", 409, {
errorCode: "notification.action.already_used",
});
};
const claimed = await claimActionNonce({
userId: user.id,
nonce: verdict.nonce,
expiry: verdict.expiry,
});
if (!claimed) return alreadyUsed();
const score = MOOD_QUICK_ACTIONS[verdict.action];
let created: boolean;
try {
@@ -129,19 +152,15 @@ export const POST = apiHandler(
created = result.created;
} catch (err) {
// Two taps raced past the existence check; the unique key let one in.
if (!isUniqueViolation(err)) throw err;
if (!isUniqueViolation(err)) {
// Nothing was written: give the answer back so a retry can land.
await releaseActionNonce({ userId: user.id, nonce: verdict.nonce });
throw err;
}
created = false;
}
if (!created) {
annotate({
action: { name: "notification.action.refused" },
meta: { reason: "already_used" },
});
return apiError("This reminder was already answered", 409, {
errorCode: "notification.action.already_used",
});
}
if (!created) return alreadyUsed();
annotate({
action: { name: "notification.action.mood_logged" },
+131 -8
View File
@@ -1,6 +1,6 @@
/**
* `GET /api/search` — admission, validation, the rate limit, and what the
* route hands the search and the log.
* `POST /api/search` — admission, validation, the rate limit, one search in
* flight per actor, 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
@@ -49,12 +49,27 @@ vi.mock("@/lib/search/search-records", () => ({
searchRecords: mocks.searchRecords,
}));
import { GET } from "../route";
import { POST } from "../route";
import { searchesInFlight } from "@/lib/search/in-flight";
const handler = GET as unknown as (req: NextRequest) => Promise<Response>;
const handler = POST as unknown as (req: NextRequest) => Promise<Response>;
function request(query: string): NextRequest {
return new NextRequest(`http://localhost/api/search?${query}`);
/**
* A search request. Written as the query string the route used to take, so
* every case reads as before; the route receives it as a JSON body.
*/
function request(query: string, init: { signal?: AbortSignal } = {}) {
const params = new URLSearchParams(query);
const body: Record<string, unknown> = {};
for (const [key, value] of params) {
body[key] = key === "limit" && /^\d+$/.test(value) ? Number(value) : value;
}
return new NextRequest("http://localhost/api/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal: init.signal,
});
}
const OWNER = { id: "owner-1", locale: "de" };
@@ -93,10 +108,13 @@ beforeEach(() => {
});
});
describe("GET /api/search", () => {
describe("POST /api/search", () => {
it("admits the whole record only, at read", async () => {
await handler(request("q=schulter"));
expect(mocks.requireRecordAuth).toHaveBeenCalledWith("read", "record");
// A POST that only reads, declared as such.
expect(mocks.requireRecordAuth).toHaveBeenCalledWith("read", "record", {
readOverPost: true,
});
});
it("propagates a refused admission without searching", async () => {
@@ -137,6 +155,7 @@ describe("GET /api/search", () => {
["a zero limit", "q=knie&limit=0"],
["a limit above the cap", "q=knie&limit=21"],
["a non-numeric limit", "q=knie&limit=lots"],
["a fractional limit", "q=knie&limit=2.5"],
])("refuses %s with 422 and never searches", async (_label, query) => {
const res = await handler(request(query));
expect(res.status).toBe(422);
@@ -173,4 +192,108 @@ describe("GET /api/search", () => {
delegated: true,
});
});
it("keeps the query out of the URL", async () => {
const req = request("q=Psychotherapie");
expect(req.url).not.toMatch(/psychotherapie/i);
const res = await handler(req);
expect(res.status).toBe(200);
expect(mocks.searchRecords.mock.calls[0][0].query).toBe("Psychotherapie");
});
it("refuses a body that is not JSON", async () => {
const res = await handler(
new NextRequest("http://localhost/api/search", {
method: "POST",
headers: { "Content-Type": "text/plain" },
body: "q=schulter",
}),
);
expect(res.status).toBe(415);
expect(mocks.searchRecords).not.toHaveBeenCalled();
});
it("allows 20 searches a minute per actor", async () => {
await handler(request("q=schulter"));
expect(mocks.checkRateLimit).toHaveBeenCalledWith(
"search:actor-1",
20,
60_000,
);
});
it("hands the search an abort signal that follows the client", async () => {
const client = new AbortController();
let seen: AbortSignal | undefined;
mocks.searchRecords.mockImplementationOnce(async (args) => {
seen = args.signal;
client.abort();
return {
groups: [],
truncated: true,
stats: { sources: 1, rows: 0, candidates: 0, elapsedMs: 1 },
};
});
await handler(request("q=schulter", { signal: client.signal }));
expect(seen?.aborted).toBe(true);
});
it("stops an actor's earlier search when a newer one starts", async () => {
let release: () => void = () => {};
let firstSignal: AbortSignal | undefined;
mocks.searchRecords.mockImplementationOnce(
(args) =>
new Promise((resolve) => {
firstSignal = args.signal;
release = () =>
resolve({
groups: [],
truncated: true,
stats: { sources: 1, rows: 0, candidates: 0, elapsedMs: 1 },
});
}),
);
const first = handler(request("q=schul"));
await vi.waitFor(() => expect(firstSignal).toBeDefined());
expect(firstSignal?.aborted).toBe(false);
const second = await handler(request("q=schulter"));
expect(second.status).toBe(200);
expect(firstSignal?.aborted).toBe(true);
release();
const superseded = await first;
expect(superseded.status).toBe(409);
expect((await superseded.json()).meta?.errorCode).toBe("search.superseded");
expect(searchesInFlight()).toBe(0);
});
it("does not stop another actor's search", async () => {
let firstSignal: AbortSignal | undefined;
let release: () => void = () => {};
mocks.searchRecords.mockImplementationOnce(
(args) =>
new Promise((resolve) => {
firstSignal = args.signal;
release = () =>
resolve({
groups: [],
truncated: false,
stats: { sources: 1, rows: 0, candidates: 0, elapsedMs: 1 },
});
}),
);
const first = handler(request("q=schul"));
await vi.waitFor(() => expect(firstSignal).toBeDefined());
mocks.requireRecordAuth.mockResolvedValueOnce({
user: { id: "owner-2", locale: "de" },
actor: { id: "actor-2", locale: "de" },
grantId: null,
});
await handler(request("q=knie"));
expect(firstSignal?.aborted).toBe(false);
release();
expect((await first).status).toBe(200);
});
});
+85 -43
View File
@@ -1,5 +1,6 @@
/**
* `GET /api/search?q=&limit=` — one search over the record (Discussion #1117):
* `POST /api/search` with `{ 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
@@ -12,36 +13,54 @@
* 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.
* reaches a log: it travels in the JSON body rather than the URL, so neither
* this app's request log nor a reverse proxy's access log records it; 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.
* Bounded per actor: 20 searches a minute (the palette asks once per pause in
* typing, and a manager searching someone's record spends their own
* allowance), and one at a time. A newer search by the same actor aborts the
* one before it, and a client that goes away aborts its own, so an abandoned
* search stops reading at its next budget check instead of running out its
* time (`in-flight.ts`).
*/
import { NextRequest } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, returnAllZodIssues } from "@/lib/api-response";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} 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 { beginSearch, SUPERSEDED } from "@/lib/search/in-flight";
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;
/** Searches per actor per minute: one per pause in typing, with room to spare. */
const SEARCH_RATE_LIMIT = 20;
const SEARCH_RATE_WINDOW_MS = 60_000;
/** The body is a query of at most 100 characters and a number. */
const SEARCH_BODY_MAX_BYTES = 2048;
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");
export const POST = apiHandler(async (request: NextRequest) => {
// A POST that only reads: the query rides the body to stay out of URLs, and
// a read grant on the whole record is what it needs, as it was over GET.
const { user, actor, grantId } = await requireRecordAuth("read", "record", {
readOverPost: true,
});
const rl = await checkRateLimit(
`search:${actor.id}`,
@@ -52,10 +71,14 @@ export const GET = apiHandler(async (request: NextRequest) => {
return apiError("Too many searches. Please retry shortly.", 429);
}
const params = new URL(request.url).searchParams;
const { data: body, error: jsonError } = await safeJson<{
q?: unknown;
limit?: unknown;
}>(request, { maxBytes: SEARCH_BODY_MAX_BYTES });
if (jsonError) return jsonError;
const parsed = searchQuerySchema.safeParse({
q: params.get("q") ?? undefined,
limit: params.get("limit") ?? undefined,
q: body?.q ?? undefined,
limit: body?.limit ?? undefined,
});
if (!parsed.success) {
annotate({
@@ -67,35 +90,54 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
}
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,
const slot = beginSearch(actor.id);
try {
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,
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 });
modules,
domainVisible,
displayLocale: displayLocale(actor.locale),
timezone,
signal: AbortSignal.any([request.signal, slot.signal]),
});
if (slot.signal.aborted) {
// A newer search by the same actor took over; this answer is partial
// and nobody is waiting for it.
annotate({
action: { name: "search.query.superseded" },
meta: { query_length: parsed.data.q.length },
});
return apiError("A newer search replaced this one", 409, {
errorCode: SUPERSEDED,
});
}
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,
aborted: request.signal.aborted,
},
});
return apiSuccess({ groups: result.groups, truncated: result.truncated });
} finally {
slot.end();
}
});
@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
import type { ReactNode } from "react";
/**
* `/mood?add=1` is the mood reminder's tap target. It opens the entry sheet
* only in the caller's own record: a session that is inside a managed
* profile or a shared record opens the page without the sheet, so a reminder
* meant for the recipient never becomes a write form on somebody else.
*/
const caps = {
current: {
inSharedRecord: false,
recordSessionPending: undefined as boolean | undefined,
accessRefused: undefined as boolean | undefined,
canManageDomain: (_d: string) => true,
},
};
const params = { current: new URLSearchParams("add=1") };
vi.mock("@/hooks/use-record-capabilities", () => ({
useRecordCapabilities: () => caps.current,
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: { modules: {} },
isAuthenticated: true,
isLoading: false,
}),
}));
vi.mock("@/hooks/use-mounted", () => ({ useMounted: () => true }));
vi.mock("@/hooks/use-pull-to-refresh", () => ({
usePullToRefresh: () => ({}),
}));
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ invalidateQueries: () => {} }),
}));
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: () => {}, push: () => {} }),
useSearchParams: () => params.current,
}));
vi.mock("@/lib/i18n/context", () => ({
useTranslations: () => ({ t: (key: string) => key }),
}));
vi.mock("@/components/ui/responsive-sheet", () => ({
ResponsiveSheet: ({ open }: { open: boolean; children?: ReactNode }) =>
open ? <div data-slot="mood-sheet-open" /> : null,
}));
vi.mock("@/components/mood/mood-form", () => ({ MoodForm: () => null }));
vi.mock("@/components/mood/mood-list", () => ({ MoodList: () => null }));
vi.mock("@/components/ui/pull-to-refresh-indicator", () => ({
PullToRefreshIndicator: () => null,
}));
import MoodPageClient from "../page-client";
function sheetOpen(): boolean {
return renderToStaticMarkup(<MoodPageClient />).includes(
'data-slot="mood-sheet-open"',
);
}
beforeEach(() => {
params.current = new URLSearchParams("add=1");
caps.current = {
inSharedRecord: false,
recordSessionPending: undefined,
accessRefused: undefined,
canManageDomain: () => true,
};
});
describe("/mood ?add=1 and the record on screen", () => {
it("opens the entry sheet in the caller's own record", () => {
expect(sheetOpen()).toBe(true);
});
it("keeps the sheet closed without the param", () => {
params.current = new URLSearchParams();
expect(sheetOpen()).toBe(false);
});
it("does not open the sheet inside a managed profile the caller manages", () => {
// A guardian switched into a managed profile holds MANAGE on mind, so
// the sheet would otherwise open and save onto that profile.
caps.current = { ...caps.current, inSharedRecord: true };
expect(sheetOpen()).toBe(false);
});
it("does not open the sheet while the record context is pending or refused", () => {
caps.current = {
...caps.current,
inSharedRecord: true,
recordSessionPending: true,
canManageDomain: () => false,
};
expect(sheetOpen()).toBe(false);
caps.current = {
...caps.current,
recordSessionPending: undefined,
accessRefused: true,
};
expect(sheetOpen()).toBe(false);
});
});
+33 -8
View File
@@ -21,7 +21,12 @@ import { useTranslations } from "@/lib/i18n/context";
export default function MoodPageClient() {
const { user, isAuthenticated, isLoading } = useAuth();
const { canManageDomain, inSharedRecord } = useRecordCapabilities();
const {
canManageDomain,
inSharedRecord,
recordSessionPending,
accessRefused,
} = useRecordCapabilities();
// A mood entry is created at MANAGE under the mind section.
const canAddMood = canManageDomain("mind");
const mounted = useMounted();
@@ -33,15 +38,35 @@ export default function MoodPageClient() {
// hydration render agree; the effect below strips the one-shot param so the
// back button does not reopen the sheet. A later client-side navigation
// back onto `?add=1` is handled render-side, the measurements-page pattern.
//
// The request is held apart from the sheet's own open state because it is
// honoured only in the caller's own record. A reminder is about the person
// who receives it; a session that is currently inside a managed profile or
// a shared record would otherwise open a write form there, and an entry
// saved from it lands on the wrong person. In that case the page opens
// without the sheet and the shell's shared-record banner offers the way
// back. The answer is read once the account query settled (`isLoading`
// gates the content render), so the request is dropped, not deferred: a
// later switch back to the own record must not pop a stale sheet.
const addParam = searchParams.get("add") === "1";
const [dialogOpen, setDialogOpen] = useState(() => addParam);
const [dialogOpen, setDialogOpen] = useState(false);
const [addRequested, setAddRequested] = useState(() => addParam);
const [consumedAdd, setConsumedAdd] = useState(() => addParam);
if (!addParam && consumedAdd) {
setConsumedAdd(false);
} else if (addParam && !consumedAdd) {
setConsumedAdd(true);
setDialogOpen(true);
setAddRequested(true);
}
const ownRecord = !inSharedRecord && !recordSessionPending && !accessRefused;
if (addRequested && mounted && !isLoading && !ownRecord) {
setAddRequested(false);
}
const sheetOpen = (dialogOpen || (addRequested && ownRecord)) && canAddMood;
const setSheetOpen = useCallback((open: boolean) => {
setDialogOpen(open);
if (!open) setAddRequested(false);
}, []);
// The list's empty state carries the add action while there is nothing
// to list, so the header drops its own then.
const [listEmpty, setListEmpty] = useState(false);
@@ -68,7 +93,7 @@ export default function MoodPageClient() {
);
const pull = usePullToRefresh({
onRefresh: refreshVisible,
disabled: dialogOpen,
disabled: sheetOpen,
});
useEffect(() => {
@@ -149,14 +174,14 @@ export default function MoodPageClient() {
/>
<ResponsiveSheet
open={dialogOpen && canAddMood}
onOpenChange={setDialogOpen}
open={sheetOpen}
onOpenChange={setSheetOpen}
title={t("dashboard.quickAddMood")}
footer={<div ref={setFooterEl} className="flex w-full" />}
>
<MoodForm
onSuccess={() => setDialogOpen(false)}
onCancel={() => setDialogOpen(false)}
onSuccess={() => setSheetOpen(false)}
onCancel={() => setSheetOpen(false)}
footerSlot={footerEl}
/>
</ResponsiveSheet>
@@ -42,24 +42,26 @@ function render(data: object | null) {
}
describe("<ProviderHealthSection> — today's spend by cost owner", () => {
it("shows both figures and the day they belong to", () => {
it("shows both figures, the account count, and which day they count", () => {
const html = render({
providers: [],
spendToday: {
dateKey: "2026-09-11",
totalTokens: 1_240_000,
operatorTokens: 151_200,
accounts: 3,
},
});
expect(html).toContain("2026-09-11");
expect(html).toContain("1,240,000");
expect(html).toContain("151,200");
expect(html).toContain("accounts with spend: 3");
// Each account's own day, not one date for the whole instance.
expect(html).toContain("each account&#x27;s own calendar day");
});
it("renders a zero day rather than hiding the readout", () => {
const html = render({
providers: [],
spendToday: { dateKey: "2026-09-11", totalTokens: 0, operatorTokens: 0 },
spendToday: { totalTokens: 0, operatorTokens: 0, accounts: 0 },
});
expect(html).toContain('data-slot="ai-spend-today"');
});
@@ -34,10 +34,11 @@ import { formatDateTime } from "@/lib/format";
* the users' own plans. The operator ceiling is enforced against the second
* figure, so both belong on screen.
*/
/** Summed over every account's own current day, the day its ceilings use. */
interface SpendToday {
dateKey: string;
totalTokens: number;
operatorTokens: number;
accounts: number;
}
interface ProviderHealthRow {
@@ -107,7 +108,7 @@ export function ProviderHealthSection() {
{spendToday ? (
<p className="text-muted-foreground text-sm" data-slot="ai-spend-today">
{t("admin.providerHealth.spendToday", {
date: spendToday.dateKey,
accounts: groupDigits(spendToday.accounts),
total: groupDigits(spendToday.totalTokens),
operator: groupDigits(spendToday.operatorTokens),
})}
@@ -51,7 +51,7 @@ import { useRecordSearch } from "./use-record-search";
* 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.
* the record (`useRecordSearch`, `POST /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.
*
@@ -4,7 +4,7 @@ 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 { apiPost } from "@/lib/api/api-fetch";
import { queryKeys } from "@/lib/query-keys";
import {
SEARCH_QUERY_MAX,
@@ -37,7 +37,8 @@ export interface RecordSearchState {
* 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.
* its request is cancelled rather than left to land late, and the server
* stops reading for it as well.
*/
export function useRecordSearch(rawQuery: string): RecordSearchState {
const { inSharedRecord, sections } = useRecordCapabilities();
@@ -49,12 +50,12 @@ export function useRecordSearch(rawQuery: string): RecordSearchState {
const result = useQuery({
queryKey: queryKeys.recordSearch(query, PALETTE_RECORD_LIMIT),
// A POST with the query in the body, so it never stands in a URL that a
// proxy's access log or the browser history could keep.
queryFn: ({ signal }) =>
apiGet<SearchResponse>(
`/api/search?${new URLSearchParams({
q: query,
limit: String(PALETTE_RECORD_LIMIT),
})}`,
apiPost<SearchResponse>(
"/api/search",
{ q: query, limit: PALETTE_RECORD_LIMIT },
{ signal },
),
enabled,
@@ -61,9 +61,42 @@ describe("oauthReasonKey", () => {
);
});
it("names Oura's issuer refusal with its own copy, and only for Oura", () => {
expect(oauthReasonKey("oura", "issuer")).toBe(
"settings.ouraOauthError.issuer",
);
// No other callback emits it, and no other subtree carries the copy.
expect(oauthReasonKey("polar", "issuer")).toBe(
"settings.polarOauthError.generic",
);
});
it("falls back to generic for an unknown tag", () => {
expect(oauthReasonKey("whoop", "totally_unknown")).toBe(
"settings.whoopOauthError.generic",
);
});
});
describe("the Oura callback's reasons", () => {
it("each has its own copy, none falls back to generic", async () => {
const { readFileSync } = await import("node:fs");
const { join } = await import("node:path");
const route = readFileSync(
join(process.cwd(), "src/app/api/oura/callback/route.ts"),
"utf8",
);
const reasons = [...route.matchAll(/ERR\("([a-z_0-9]+)"\)/g)].map(
(m) => m[1],
);
expect(reasons).toContain("issuer");
const en = JSON.parse(
readFileSync(join(process.cwd(), "messages/en.json"), "utf8"),
) as { settings: { ouraOauthError: Record<string, string> } };
for (const reason of reasons) {
const key = oauthReasonKey("oura", reason);
expect(key, reason).toBe(`settings.ouraOauthError.${reason}`);
expect(en.settings.ouraOauthError[reason], reason).toBeTruthy();
}
});
});
@@ -127,6 +127,17 @@ const OAUTH_OUTCOME_REASONS = new Set([
"expired",
]);
/**
* Reason tags only one provider's callback emits, with copy under that
* provider's subtree alone. Oura's `issuer`: the authorization response named
* an issuer that is not Oura's (RFC 9207), so the callback refused the code.
*/
const PROVIDER_ONLY_REASONS: Partial<
Record<OAuthOutcomeProvider, ReadonlySet<string>>
> = {
oura: new Set(["issuer"]),
};
type OAuthOutcome =
| { provider: OAuthOutcomeProvider; kind: "connected" }
| { provider: OAuthOutcomeProvider; kind: "error"; reason: string };
@@ -162,7 +173,8 @@ export function oauthReasonKey(
reason: string,
): string {
const base = OAUTH_ERROR_KEY_BASE[provider];
return OAUTH_OUTCOME_REASONS.has(reason)
return OAUTH_OUTCOME_REASONS.has(reason) ||
PROVIDER_ONLY_REASONS[provider]?.has(reason)
? `${base}.${reason}`
: `${base}.generic`;
}
@@ -0,0 +1,96 @@
/**
* `/checkups?visit=<id>` from a search hit. The visits list reads the newest
* hundred of each arm, and a search reaches back further than that, so a hit
* can name a visit the list does not hold. That visit is read on its own and
* opened like a listed one.
*/
import { describe, expect, it, vi } from "vitest";
import { openDeepLinked } from "../use-deep-linked-record";
type Visit = { id: string; occurredAt: string };
/** The newest hundred visits, the way the list holds them. */
const newestHundred: Visit[] = Array.from({ length: 100 }, (_, i) => ({
id: `v${i}`,
occurredAt: `2026-01-01T00:00:00Z`,
}));
const older: Visit = { id: "v-older", occurredAt: "2014-05-06T09:00:00Z" };
describe("a link to a visit older than the newest hundred", () => {
it("reads it on its own and opens it", async () => {
const resolve = vi.fn(async (id: string) =>
id === older.id ? older : null,
);
const open = vi.fn();
const scrollTo = vi.fn();
const outcome = await openDeepLinked({
wanted: older.id,
records: newestHundred,
open,
resolve,
stillWanted: () => true,
scrollTo,
});
expect(outcome).toBe("resolved");
expect(resolve).toHaveBeenCalledWith("v-older");
expect(open).toHaveBeenCalledWith(older);
// Not on the page, so there is nothing to scroll to.
expect(scrollTo).not.toHaveBeenCalled();
});
it("opens a listed visit without a second read", async () => {
const resolve = vi.fn();
const open = vi.fn();
const scrollTo = vi.fn();
const outcome = await openDeepLinked({
wanted: "v42",
records: newestHundred,
open,
resolve,
stillWanted: () => true,
scrollTo,
});
expect(outcome).toBe("listed");
expect(resolve).not.toHaveBeenCalled();
expect(scrollTo).toHaveBeenCalledWith("v42");
expect(open).toHaveBeenCalledWith(newestHundred[42]);
});
it("leaves the page alone when the visit is gone or refused", async () => {
const open = vi.fn();
for (const resolve of [
async () => null,
async () => {
throw new Error("403");
},
]) {
const outcome = await openDeepLinked({
wanted: "v-gone",
records: newestHundred,
open,
resolve,
stillWanted: () => true,
scrollTo: () => {},
});
expect(outcome).toBe("none");
}
expect(open).not.toHaveBeenCalled();
});
it("does not open a late answer for a link that is no longer wanted", async () => {
const open = vi.fn();
const outcome = await openDeepLinked({
wanted: older.id,
records: newestHundred,
open,
resolve: async () => older,
stillWanted: () => false,
scrollTo: () => {},
});
expect(outcome).toBe("none");
expect(open).not.toHaveBeenCalled();
});
});
+47 -19
View File
@@ -56,24 +56,52 @@ export function useDeepLinkedRecord<T extends { id: string }>({
useEffect(() => {
if (!wanted || !records || handled.current === wanted) return;
handled.current = wanted;
const record = records.find((entry) => entry.id === wanted);
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);
void openDeepLinked({
wanted,
records,
open,
resolve,
// Still on the page, and still the link it was asked for.
stillWanted: () => mounted.current && handled.current === wanted,
scrollTo: (id) =>
document
.querySelector(`[${anchorAttribute}="${CSS.escape(id)}"]`)
?.scrollIntoView({ block: "center" }),
});
}, [wanted, records, anchorAttribute, open, resolve]);
}
/**
* What a link does once the list has loaded, apart from React: a record the
* list holds is scrolled to and opened; one it does not hold (older than the
* bounded read, say the newest hundred visits) is read on its own through
* `resolve` and opened when it comes back and the link is still wanted.
* Resolves to which of the three happened.
*/
export async function openDeepLinked<T extends { id: string }>(args: {
wanted: string;
records: readonly T[];
open?: (record: T) => void;
resolve?: (id: string) => Promise<T | null>;
stillWanted: () => boolean;
scrollTo: (id: string) => void;
}): Promise<"listed" | "resolved" | "none"> {
const { wanted, records, open, resolve } = args;
const record = records.find((entry) => entry.id === wanted);
if (record) {
args.scrollTo(wanted);
open?.(record);
return "listed";
}
if (!resolve || !open) return "none";
let found: T | null;
try {
found = await resolve(wanted);
} catch {
// Gone or refused: the page stays as it would without the link.
return "none";
}
if (!found || !args.stillWanted()) return "none";
open(found);
return "resolved";
}
@@ -505,6 +505,29 @@ describe("requireRecordAuth — every way a grant can fail", () => {
);
});
it("reads a POST at read level only where the route declares readOverPost", async () => {
signedInCookie(OWNER);
liveGrant();
const declared = route(() =>
requireRecordAuth("read", "record", { readOverPost: true }),
);
expect((await declared("POST")).status).toBe(200);
// The declaration covers POST alone: every other write method still
// needs a write grant.
for (const method of ["PUT", "PATCH", "DELETE"]) {
expect((await declared(method)).status, method).toBe(403);
}
});
it("does not lower a declared write for a readOverPost route", async () => {
signedInCookie(OWNER);
liveGrant();
const response = await route(() =>
requireRecordAuth("write", "record", { readOverPost: true }),
)("POST");
expect(response.status).toBe(403);
});
it("refuses a declared write under a read grant", async () => {
signedInCookie(OWNER);
liveGrant();
+54 -1
View File
@@ -5,6 +5,7 @@ import {
OPERATOR_COST_CAP,
USER_PLAN_CAP,
INTERACTIVE_RESERVE_SHARE,
readInstanceSpendToday,
resolveCostOwner,
resolveDailyCap,
resolveDailyCapFor,
@@ -15,6 +16,7 @@ vi.mock("@/lib/db", () => ({
prisma: {
coachUsage: {
findUnique: vi.fn(),
findMany: vi.fn(),
upsert: vi.fn(),
},
$executeRaw: vi.fn(),
@@ -31,7 +33,10 @@ const { zoneOf } = vi.hoisted(() => ({
: "UTC",
),
}));
vi.mock("@/lib/tz/resolver", () => ({ resolveUserTimezone: zoneOf }));
vi.mock("@/lib/tz/resolver", () => ({
resolveUserTimezone: zoneOf,
resolveServerDefaultTimezone: async () => "Europe/Berlin",
}));
/**
* The budget day turns at the account's own midnight, the boundary the daily
@@ -616,3 +621,51 @@ describe("OPERATOR_COST_CAP (F2 — reasoning-aware operator cap)", () => {
expect(USER_PLAN_CAP).toBeGreaterThan(OPERATOR_COST_CAP);
});
});
/**
* The operator's "today" is every account's own today. One date key for the
* whole instance counted some accounts' yesterday or tomorrow once the budget
* day turned local.
*/
describe("readInstanceSpendToday", () => {
it("sums each account's row for its own current day and nothing else", async () => {
const { prisma } = await import("@/lib/db");
// 22:30 UTC on May 10: Berlin is on May 11, New York and UTC on May 10.
const at = new Date("2026-05-10T22:30:00.000Z");
const row = (
dateKey: string,
total: number,
operator: number,
timezone: string | null,
) => ({
dateKey,
totalTokens: total,
operatorTokens: operator,
user: { timezone },
});
vi.mocked(prisma.coachUsage.findMany).mockResolvedValue([
row("2026-05-11", 100, 10, "Europe/Berlin"), // Berlin today
row("2026-05-10", 1_000, 1_000, "Europe/Berlin"), // Berlin yesterday
row("2026-05-10", 200, 20, "America/New_York"), // New York today
row("2026-05-11", 7_000, 7_000, "America/New_York"), // not yet begun
row("2026-05-10", 300, 30, "UTC"), // UTC today
// No usable zone: the server default (Berlin here), so May 11.
row("2026-05-11", 40, 4, null),
row("2026-05-11", 50, 5, "+05:30"),
] as never);
await expect(readInstanceSpendToday(at)).resolves.toEqual({
total: 100 + 200 + 300 + 40 + 50,
operator: 10 + 20 + 30 + 4 + 5,
accounts: 5,
});
// Only the three keys any zone can be on right now are read.
expect(prisma.coachUsage.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
dateKey: { in: ["2026-05-09", "2026-05-10", "2026-05-11"] },
},
}),
);
});
});
+64 -2
View File
@@ -14,8 +14,11 @@
* every other per-day surface.
*/
import { prisma } from "@/lib/db";
import { userDayKey } from "@/lib/tz/format";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { isValidTimezone, shiftDateKey, userDayKey } from "@/lib/tz/format";
import {
resolveServerDefaultTimezone,
resolveUserTimezone,
} from "@/lib/tz/resolver";
import type { ProviderChainType } from "@/lib/ai/provider-chain";
/**
@@ -229,6 +232,65 @@ export async function readDailySpend(
};
}
/** The day's spend across every account, for the operator's readout. */
export interface InstanceDailySpend extends DailySpend {
/** Accounts with a spend row on their own current day. */
accounts: number;
}
/**
* Every account's spend on that account's own current day, summed.
*
* Since the budget day turned local, one date key no longer names "today"
* for everybody: at 23:30 in Berlin a row keyed with the Berlin date belongs
* to a Berlin account's today, while an account in New York is still on the
* day before. Each ceiling is enforced on the account's own day, so the
* operator's figure is the sum of exactly those rows, one per account: the
* spend that counts against the ceilings right now.
*
* Every zone's today lies within one day of the UTC date, so the read
* fetches those three keys only and keeps the rows whose key is their
* account's current day, resolved the way `buildDateKey` resolves it (the
* account's zone when valid, otherwise the server default).
*/
export async function readInstanceSpendToday(
at: Date = new Date(),
): Promise<InstanceDailySpend> {
const utcKey = userDayKey(at, "UTC");
const rows = await prisma.coachUsage.findMany({
where: {
dateKey: {
in: [shiftDateKey(utcKey, -1), utcKey, shiftDateKey(utcKey, 1)],
},
},
select: {
dateKey: true,
totalTokens: true,
operatorTokens: true,
user: { select: { timezone: true } },
},
});
const serverTz = await resolveServerDefaultTimezone();
const todayByZone = new Map<string, string>();
const spend: InstanceDailySpend = { total: 0, operator: 0, accounts: 0 };
for (const row of rows) {
const zone =
row.user.timezone && isValidTimezone(row.user.timezone)
? row.user.timezone
: serverTz;
let today = todayByZone.get(zone);
if (today === undefined) {
today = userDayKey(at, zone);
todayByZone.set(zone, today);
}
if (row.dateKey !== today) continue;
spend.total += row.totalTokens;
spend.operator += row.operatorTokens;
spend.accounts += 1;
}
return spend;
}
/**
* The budget day of one account at `at` (default now): the calendar day in
* the account's own time zone.
+17 -1
View File
@@ -889,6 +889,19 @@ export interface RecordAuthOptions {
* else.
*/
readonly scope?: string;
/**
* v1.42.2 — this route answers a POST without changing anything: the body
* carries a query that must not stand in a URL (the record search, whose
* text is health data a proxy's access log would otherwise keep). Under a
* route declaring `"read"`, a POST is then read at the grant's read level
* instead of being escalated to a write.
*
* Like `scope`, naming it is a WIDENING and is frozen by
* `read-over-post-guard.test.ts`, so it lands in a reviewed diff. It does
* nothing for any other method, and nothing for a route declaring a need
* above `"read"`.
*/
readonly readOverPost?: true;
}
/**
@@ -980,8 +993,11 @@ export async function requireRecordAuth(
}
const method = (getEvent()?.getHttpMethod() ?? "").toUpperCase();
const readsOnly =
READ_HTTP_METHODS.has(method) ||
(options?.readOverPost === true && method === "POST");
const effectiveNeed: GrantNeed =
need !== "read" || !READ_HTTP_METHODS.has(method) ? escalate(need) : "read";
need !== "read" || !readsOnly ? escalate(need) : "read";
return resolveSwitchedRecord(auth, carrier, (grant) => {
if (!grantAllows(grant, effectiveNeed)) return "insufficient_access";
@@ -0,0 +1,65 @@
/**
* A collection counts toward a backfill's progress only once its resume note
* is written. The job reads the size of `done` as progress and queues another
* slice on it, so a key added before a write that failed would let a chain of
* slices "progress" over the same collection without end.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const { prismaMock, warnings } = vi.hoisted(() => ({
prismaMock: {
googleHealthConnection: { update: vi.fn() },
},
warnings: [] as string[],
}));
vi.mock("@/lib/db", () => ({ prisma: prismaMock }));
vi.mock("@/lib/logging/context", () => ({
annotate: () => {},
getEvent: () => ({ addWarning: (w: string) => warnings.push(w) }),
}));
import {
runGoogleHealthCollection,
runWithGoogleHealthBackfillResume,
} from "../sync-core";
beforeEach(() => {
vi.clearAllMocks();
warnings.length = 0;
});
describe("runGoogleHealthCollection under a backfill", () => {
it("adds a clean collection to done once its note is written", async () => {
prismaMock.googleHealthConnection.update.mockResolvedValue({});
const done = new Set<string>(["fetchSteps"]);
await runWithGoogleHealthBackfillResume("u1", done, () =>
runGoogleHealthCollection("sleep", async () => 3),
);
expect([...done]).toEqual(["fetchSteps", "sleep"]);
expect(prismaMock.googleHealthConnection.update).toHaveBeenCalledWith({
where: { userId: "u1" },
data: { backfillProgress: { done: ["fetchSteps", "sleep"] } },
});
});
it("counts no progress when the note cannot be written", async () => {
prismaMock.googleHealthConnection.update.mockRejectedValue(
new Error("connection reset"),
);
const done = new Set<string>(["fetchSteps"]);
const { result } = await runWithGoogleHealthBackfillResume("u1", done, () =>
runGoogleHealthCollection("sleep", async () => 3),
);
// The rows are in, the collection did not fail, but it is not finished.
expect(result).toBe(3);
expect([...done]).toEqual(["fetchSteps"]);
expect(warnings.some((w) => w.includes("progress write failed"))).toBe(
true,
);
});
});
+29
View File
@@ -0,0 +1,29 @@
/**
* Every collection a Google Health backfill walks, by the key its resume note
* stores (`runGoogleHealthCollection`). The key type is what that function
* accepts, so a collection added without an entry here does not compile, and
* the backfill's slice ceiling (`GOOGLE_HEALTH_BACKFILL_MAX_SLICE`) is derived
* from this list rather than counted by hand.
*/
export const GOOGLE_HEALTH_COLLECTION_KEYS = [
"fetchSteps",
"fetchDistance",
"fetchActiveEnergy",
"fetchFloors",
"fetchVo2Max",
"fetchWeight",
"fetchBodyFat",
"fetchSpo2",
"fetchHrv",
"fetchRhr",
"fetchRespiratoryRate",
"fetchHeartRate",
"fetchBloodGlucose",
"fetchCoreBodyTemperature",
"fetchSleepTemperature",
"fetchExercise",
"sleep",
] as const;
export type GoogleHealthCollectionKey =
(typeof GOOGLE_HEALTH_COLLECTION_KEYS)[number];
+2 -1
View File
@@ -46,6 +46,7 @@ import {
type GoogleHealthResourceSyncOptions,
} from "./sync-core";
import { annotate } from "@/lib/logging/context";
import type { GoogleHealthCollectionKey } from "./collection-keys";
import { resolveUserTimezone } from "@/lib/tz/resolver";
/** One mappable activity metric: its data-type encoding + the mapper + a verb. */
@@ -55,7 +56,7 @@ interface ActivityResource {
point: Record<string, unknown>,
tz: string,
) => GoogleHealthMappedMeasurement[];
verb: string;
verb: GoogleHealthCollectionKey;
}
/** The four cumulative daily totals — read via `POST :dailyRollUp`. */
+21 -12
View File
@@ -20,6 +20,7 @@ import { prisma } from "@/lib/db";
import type { MeasurementType } from "@/generated/prisma/client";
import { encrypt, decrypt } from "@/lib/crypto";
import { annotate, getEvent } from "@/lib/logging/context";
import type { GoogleHealthCollectionKey } from "./collection-keys";
import { userDayKey } from "@/lib/tz/format";
import { isP2002 } from "@/lib/prisma-errors";
import {
@@ -464,7 +465,7 @@ export async function runWithGoogleHealthBackfillResume<T>(
* budget asking it to stop). Anything less is walked again next attempt.
*/
export async function runGoogleHealthCollection(
key: string,
key: GoogleHealthCollectionKey,
fn: () => Promise<number>,
): Promise<number> {
const resume = backfillResumeStorage.getStore();
@@ -483,19 +484,27 @@ export async function runGoogleHealthCollection(
!truncatedBefore &&
!googleHealthOutcomeTruncated();
if (clean) {
resume.done.add(key);
await prisma.googleHealthConnection
.update({
// The collection counts as finished only once the note says so. The job
// reads the size of `done` as this attempt's progress and sends another
// slice on it; a key added before a write that then failed would be
// progress nobody can see next attempt, and a chain of slices could keep
// "progressing" over the same collection forever.
try {
await prisma.googleHealthConnection.update({
where: { userId: resume.userId },
data: { backfillProgress: { done: [...resume.done] } },
})
.catch((err) =>
// Losing the note costs a re-walk of this collection next attempt,
// nothing more; it must not fail a collection that did complete.
getEvent()?.addWarning(
`google-health: backfill progress write failed: ${err}`,
),
data: { backfillProgress: { done: [...resume.done, key] } },
});
resume.done.add(key);
} catch (err) {
// Not finished, then: the collection is walked again next attempt. It
// must not fail a collection whose rows are written.
getEvent()?.addWarning(
`google-health: backfill progress write failed for ${key}: ${err}`,
);
annotate({
meta: { "googleHealth.backfill.progressWriteFailed": key },
});
}
}
return imported;
}
+2 -1
View File
@@ -55,6 +55,7 @@ import {
type GoogleHealthResourceSyncOptions,
} from "./sync-core";
import { getEvent } from "@/lib/logging/context";
import type { GoogleHealthCollectionKey } from "./collection-keys";
import { prisma } from "@/lib/db";
import { resolveUserTimezone } from "@/lib/tz/resolver";
@@ -65,7 +66,7 @@ interface MetricResource {
point: Record<string, unknown>,
tz: string,
) => GoogleHealthMappedMeasurement[];
verb: string;
verb: GoogleHealthCollectionKey;
/** Page ceiling for the walk; the client default when omitted. */
maxPages?: number;
/**
@@ -69,7 +69,10 @@ vi.mock("@/lib/google-health/sync", () => ({
syncUserGoogleHealth: (...a: unknown[]) => syncUserGoogleHealthMock(...a),
}));
import { runGoogleHealthBackfillForUser } from "../google-health-backfill";
import {
GOOGLE_HEALTH_BACKFILL_MAX_SLICE,
runGoogleHealthBackfillForUser,
} from "../google-health-backfill";
import {
GOOGLE_HEALTH_DATA_TYPES,
forEachDataPointPage,
@@ -229,6 +232,39 @@ describe("runGoogleHealthBackfillForUser — slices", () => {
);
});
it("fails a slice past the ceiling without walking or queueing", async () => {
syncUserGoogleHealthMock.mockResolvedValue({ imported: 1, failed: true });
resumeState.finishes = ["sleep"];
await expect(
runGoogleHealthBackfillForUser(
"u1",
undefined,
GOOGLE_HEALTH_BACKFILL_MAX_SLICE + 1,
),
).rejects.toThrow(/past the ceiling/);
expect(syncUserGoogleHealthMock).not.toHaveBeenCalled();
expect(bossSend).not.toHaveBeenCalled();
});
it("does not queue a slice past the ceiling even when this one progressed", async () => {
resumeState.finishes = ["sleep"];
syncUserGoogleHealthMock.mockResolvedValue({ imported: 1, failed: true });
await expect(
runGoogleHealthBackfillForUser(
"u1",
undefined,
GOOGLE_HEALTH_BACKFILL_MAX_SLICE,
),
).rejects.toThrow(/incomplete/);
expect(bossSend).not.toHaveBeenCalled();
});
it("caps the chain at the number of collections plus two", () => {
expect(GOOGLE_HEALTH_BACKFILL_MAX_SLICE).toBe(17 + 2);
});
it("stamps the marker on the slice that finishes the history", async () => {
resumeState.doneBefore = ["steps", "sleep"];
resumeState.finishes = ["weight"];
+28 -2
View File
@@ -30,6 +30,7 @@ import {
} from "@/lib/google-health/sync";
import { GOOGLE_HEALTH_SYNC_LOCK_WAIT_MS } from "@/lib/google-health/sync-lock";
import { runWithGoogleHealthStop } from "@/lib/google-health/client";
import { GOOGLE_HEALTH_COLLECTION_KEYS } from "@/lib/google-health/collection-keys";
export const GOOGLE_HEALTH_BACKFILL_QUEUE = "google-health-backfill";
@@ -40,6 +41,17 @@ export const GOOGLE_HEALTH_BACKFILL_QUEUE = "google-health-backfill";
*/
export const GOOGLE_HEALTH_BACKFILL_CONCURRENCY = 1;
/**
* The highest slice a backfill chain may reach. Every continued slice has
* finished at least one more collection than the one before it, so a healthy
* chain is at most one slice per collection long; the two extra slices are
* slack for the first one and the one that stamps the marker. A slice past
* this is a chain that counts progress it does not make, and it fails rather
* than queueing another.
*/
export const GOOGLE_HEALTH_BACKFILL_MAX_SLICE =
GOOGLE_HEALTH_COLLECTION_KEYS.length + 2;
export interface GoogleHealthBackfillPayload {
userId: string;
enqueuedAt: string;
@@ -78,7 +90,10 @@ export interface GoogleHealthBackfillPayload {
* `continued`, without the error line or the queue's failure count. Only an
* attempt that finished nothing new throws, because that one is stuck: the
* pg-boss retry and, after it, the next boot take it from there. Progress is
* bounded by the number of collections, so the chain of slices is too.
* counted only once it is persisted (`runGoogleHealthCollection` adds a
* collection to `done` after its note is written), and the chain is capped at
* `GOOGLE_HEALTH_BACKFILL_MAX_SLICE`: a slice past it throws instead of
* running or sending another.
*
* Never beside another run of the same account: the sync takes the account's
* lock, waiting a bounded time for an hourly or manual run to finish. If the
@@ -90,6 +105,17 @@ export async function runGoogleHealthBackfillForUser(
shouldStop: () => boolean = () => false,
slice: number = 0,
): Promise<{ imported: number; continued?: boolean }> {
if (slice > GOOGLE_HEALTH_BACKFILL_MAX_SLICE) {
annotate({
action: {
name: "google_health.backfill.slice_ceiling",
details: { slice, maxSlice: GOOGLE_HEALTH_BACKFILL_MAX_SLICE },
},
});
throw new Error(
`google-health backfill for user ${userId} reached slice ${slice}, past the ceiling of ${GOOGLE_HEALTH_BACKFILL_MAX_SLICE}`,
);
}
if (await isGoogleHealthParked(userId)) {
annotate({
action: {
@@ -143,7 +169,7 @@ export async function runGoogleHealthBackfillForUser(
// `done` is the resume set this attempt added to as it finished
// collections; a larger set than it started with is progress.
const finished = done.size - doneBefore;
if (finished > 0) {
if (finished > 0 && slice + 1 <= GOOGLE_HEALTH_BACKFILL_MAX_SLICE) {
const boss = getGlobalBoss();
const sent = boss
? await enqueueIntegrationBackfillAdmission(
@@ -0,0 +1,111 @@
/**
* `loadRecordCandidates` under the in-app search's budget: a kind stops
* building candidates once `shouldStop` says so, keeps its newest rows, and
* says it did not see everything; a kind not started when the budget is
* spent is not read at all.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
user: { findUnique: vi.fn(async () => null) },
encounter: { findMany: vi.fn() },
illnessEpisode: { findMany: vi.fn(async () => []) },
},
}));
vi.mock("@/lib/db", () => ({ prisma: prismaMock }));
import { loadRecordCandidates } from "../record-search";
function encounters(n: number) {
return Array.from({ length: n }, (_, i) => ({
id: `e${i}`,
userId: "u1",
occurredAt: new Date(Date.UTC(2026, 0, 30 - i)),
kind: "CHECKUP",
status: "COMPLETED",
reasonEncrypted: null,
outcomeEncrypted: null,
bodySiteEncrypted: null,
laterality: null,
practitioner: null,
}));
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("loadRecordCandidates under a budget", () => {
it("stops a kind part-way, newest first, and reports it", async () => {
prismaMock.encounter.findMany.mockResolvedValue(encounters(10));
let checks = 0;
const truncated: string[] = [];
const out = await loadRecordCandidates("u1", "visit", "", {
kinds: new Set(["visit"]),
rowsPerKind: 100,
// One check before the kind starts, then one per row: the fifth
// check, before the fourth row, says stop.
shouldStop: () => ++checks > 4,
onTruncated: (kind) => truncated.push(kind),
});
expect(out.map((c) => c.id)).toEqual(["visit:e0", "visit:e1", "visit:e2"]);
expect(truncated).toContain("visit");
});
it("does not read a kind once the budget is already spent", async () => {
const truncated: string[] = [];
const out = await loadRecordCandidates("u1", "visit", "", {
kinds: new Set(["visit", "condition"]),
shouldStop: () => true,
onTruncated: (kind) => truncated.push(kind),
});
expect(out).toEqual([]);
expect(prismaMock.encounter.findMany).not.toHaveBeenCalled();
expect(prismaMock.illnessEpisode.findMany).not.toHaveBeenCalled();
expect(truncated.sort()).toEqual(["condition", "visit"]);
});
it("reads everything and reports nothing without a budget", async () => {
prismaMock.encounter.findMany.mockResolvedValue(encounters(10));
const truncated: string[] = [];
const out = await loadRecordCandidates("u1", "visit", "", {
kinds: new Set(["visit"]),
rowsPerKind: 100,
onTruncated: (kind) => truncated.push(kind),
});
expect(out).toHaveLength(10);
expect(truncated).toEqual([]);
});
it("does not call a kind holding exactly its cap truncated", async () => {
prismaMock.encounter.findMany.mockResolvedValue(encounters(5));
const truncated: string[] = [];
const out = await loadRecordCandidates("u1", "visit", "", {
kinds: new Set(["visit"]),
rowsPerKind: 5,
onTruncated: (kind) => truncated.push(kind),
});
expect(out).toHaveLength(5);
expect(truncated).toEqual([]);
// One row past the cap is asked for, as the probe.
expect(prismaMock.encounter.findMany).toHaveBeenCalledWith(
expect.objectContaining({ take: 6 }),
);
});
it("calls a kind with a row past its cap truncated, and keeps the cap", async () => {
prismaMock.encounter.findMany.mockResolvedValue(encounters(6));
const truncated: string[] = [];
const out = await loadRecordCandidates("u1", "visit", "", {
kinds: new Set(["visit"]),
rowsPerKind: 5,
onTruncated: (kind) => truncated.push(kind),
});
expect(out).toHaveLength(5);
expect(truncated).toEqual(["visit"]);
});
});
+261 -176
View File
@@ -20,7 +20,7 @@
* uses. The document body itself is never read for a search.
*
* Ranking and folding live in `@/lib/search/match`, shared with the in-app
* search (`GET /api/search`), which reads its record kinds through
* search (`POST /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
@@ -349,7 +349,7 @@ async function visitCandidates(
userId: string,
owner: Owner,
origin: string,
take: number,
read: KindRead,
empty: boolean,
): Promise<SearchCandidate[]> {
const rows = await prisma.encounter.findMany({
@@ -361,66 +361,70 @@ async function visitCandidates(
...(empty ? { occurredAt: { lte: new Date() } } : {}),
},
orderBy: [{ occurredAt: "desc" }, { id: "asc" }],
take,
take: probeTake(read),
include: { practitioner: PRACTITIONER_SELECT },
});
return rows.map((row) => {
const practitioner = livePractitioner(row.practitioner);
const reason = decryptText(row.reasonEncrypted);
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({
kind: row.kind,
day,
bodySite: bodySite?.replace(/\s+/g, " ").trim() ?? null,
laterality: row.laterality,
practitioner: practitioner?.name ?? null,
status: row.status,
}),
url: recordUrl(origin, "visit", row.id),
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, body: true },
{ text: practitioner?.name ?? "", weight: WEIGHT.primary },
{ text: practitioner?.specialty ?? "", weight: WEIGHT.primary },
{
text: [
"visit",
enumWords(row.kind),
encounterKindLabel(row.kind, "en"),
encounterKindLabel(row.kind, owner.locale),
ENCOUNTER_KIND_ALIASES[row.kind] ?? "",
].join(" "),
weight: WEIGHT.secondary,
return mapUntilStopped(
withinCap(rows, read),
read,
(row): SearchCandidate => {
const practitioner = livePractitioner(row.practitioner);
const reason = decryptText(row.reasonEncrypted);
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({
kind: row.kind,
day,
bodySite: bodySite?.replace(/\s+/g, " ").trim() ?? null,
laterality: row.laterality,
practitioner: practitioner?.name ?? null,
status: row.status,
}),
url: recordUrl(origin, "visit", row.id),
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),
},
{
text: sideTerms(row.laterality, owner.locale) ?? "",
weight: WEIGHT.secondary,
},
{ text: outcome ?? "", weight: WEIGHT.secondary, body: true },
],
};
});
fields: [
{ text: bodySite ?? "", weight: WEIGHT.primary },
{ text: reason ?? "", weight: WEIGHT.primary, body: true },
{ text: practitioner?.name ?? "", weight: WEIGHT.primary },
{ text: practitioner?.specialty ?? "", weight: WEIGHT.primary },
{
text: [
"visit",
enumWords(row.kind),
encounterKindLabel(row.kind, "en"),
encounterKindLabel(row.kind, owner.locale),
ENCOUNTER_KIND_ALIASES[row.kind] ?? "",
].join(" "),
weight: WEIGHT.secondary,
},
{
text: sideTerms(row.laterality, owner.locale) ?? "",
weight: WEIGHT.secondary,
},
{ text: outcome ?? "", weight: WEIGHT.secondary, body: true },
],
};
},
);
}
/**
@@ -476,58 +480,62 @@ async function conditionCandidates(
userId: string,
owner: Owner,
origin: string,
take: number,
read: KindRead,
): Promise<SearchCandidate[]> {
const rows = await prisma.illnessEpisode.findMany({
where: { userId, deletedAt: null },
orderBy: [{ onsetAt: "desc" }, { id: "asc" }],
take,
take: probeTake(read),
select: CONDITION_SELECT,
});
return rows.map((row) => {
const bodySite = decryptText(row.bodySiteEncrypted);
const day = userDayKey(row.onsetAt, owner.timezone);
const site = bodySite?.replace(/\s+/g, " ").trim();
return {
id: `condition:${row.id}`,
title: scrubFenceMarkers(
`${row.label}${site ? ` · ${site}` : ""} (${illnessTypeLabel(row.type, "en")}, since ${day})`,
),
url: recordUrl(origin, "condition", row.id),
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 },
{
text: [
"condition illness",
enumWords(row.type),
illnessTypeLabel(row.type, "en"),
illnessTypeLabel(row.type, owner.locale),
enumWords(row.lifecycle),
].join(" "),
weight: WEIGHT.secondary,
return mapUntilStopped(
withinCap(rows, read),
read,
(row): SearchCandidate => {
const bodySite = decryptText(row.bodySiteEncrypted);
const day = userDayKey(row.onsetAt, owner.timezone);
const site = bodySite?.replace(/\s+/g, " ").trim();
return {
id: `condition:${row.id}`,
title: scrubFenceMarkers(
`${row.label}${site ? ` · ${site}` : ""} (${illnessTypeLabel(row.type, "en")}, since ${day})`,
),
url: recordUrl(origin, "condition", row.id),
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),
},
{
text: sideTerms(row.laterality, owner.locale) ?? "",
weight: WEIGHT.secondary,
},
],
};
});
fields: [
{ text: row.label, weight: WEIGHT.primary },
{ text: bodySite ?? "", weight: WEIGHT.primary },
{
text: [
"condition illness",
enumWords(row.type),
illnessTypeLabel(row.type, "en"),
illnessTypeLabel(row.type, owner.locale),
enumWords(row.lifecycle),
].join(" "),
weight: WEIGHT.secondary,
},
{
text: sideTerms(row.laterality, owner.locale) ?? "",
weight: WEIGHT.secondary,
},
],
};
},
);
}
const DOCUMENT_SELECT = {
@@ -604,18 +612,18 @@ async function documentCandidates(
userId: string,
owner: Owner,
origin: string,
take: number,
read: KindRead,
query: string,
words: readonly string[],
includeAiDeferred: boolean,
): Promise<SearchCandidate[]> {
const nameTerms = documentNameTerms(query, words);
const [rows, content, named] = await Promise.all([
const [probed, content, named] = await Promise.all([
prisma.inboundDocument.findMany({
where: { userId, deletedAt: null },
select: DOCUMENT_SELECT,
orderBy: [{ createdAt: "desc" }, { id: "asc" }],
take,
take: probeTake(read),
}),
words.length > 0
? contentMatches(userId, words, includeAiDeferred)
@@ -638,6 +646,9 @@ async function documentCandidates(
})
: Promise.resolve([] as Array<{ id: string }>),
]);
// The cap applies to the newest read; the extra rows below are content and
// name hits beyond it, already bounded by their own read.
const rows = withinCap(probed, read);
// A content or name hit on a document older than the newest `take` still
// counts; the extra read is bounded like the others.
const seen = new Set(rows.map((r) => r.id));
@@ -654,7 +665,7 @@ async function documentCandidates(
})
: [];
return [...rows, ...extra].map((row) => {
return mapUntilStopped([...rows, ...extra], read, (row): SearchCandidate => {
const dated = row.documentDate ?? row.reportDate;
const kindLabel = documentKindLabel(row.kind, "en");
const name = row.title ?? row.filename ?? kindLabel;
@@ -705,73 +716,77 @@ async function vaccinationCandidates(
userId: string,
owner: Owner,
origin: string,
take: number,
read: KindRead,
): Promise<SearchCandidate[]> {
const rows = await prisma.vaccinationRecord.findMany({
where: { userId, deletedAt: null },
orderBy: [{ occurredAt: "desc" }, { id: "asc" }],
take,
take: probeTake(read),
select: VACCINATION_SELECT,
});
return rows.map((row) => {
const practitioner = livePractitioner(row.practitioner);
const entry = resolveVaccineEntry({ antigenSlug: row.antigenSlug });
const own = ownVaccine(row);
const catalogEn = vaccineCatalogName(row.antigenSlug, "en");
const name =
row.vaccineName ??
catalogEn ??
own?.name ??
row.antigenSlug ??
"Vaccination";
const dose = row.doseNumber ? `dose ${row.doseNumber}, ` : "";
const day = dateOnly(row.occurredAt);
return {
id: `vaccination:${row.id}`,
title: scrubFenceMarkers(`${name} (${dose}${day})`),
url: recordUrl(origin, "vaccination", row.id),
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 },
{
text: [
catalogEn ?? "",
vaccineCatalogName(row.antigenSlug, owner.locale) ?? "",
row.antigenSlug
? enumWords(row.antigenSlug.replace(/-/g, "_"))
: "",
...(entry?.synonyms ?? []),
own?.name ?? "",
...(own?.components ?? []).map((antigen) =>
enumWords(antigen.replace(/-/g, "_")),
),
].join(" "),
weight: WEIGHT.primary,
return mapUntilStopped(
withinCap(rows, read),
read,
(row): SearchCandidate => {
const practitioner = livePractitioner(row.practitioner);
const entry = resolveVaccineEntry({ antigenSlug: row.antigenSlug });
const own = ownVaccine(row);
const catalogEn = vaccineCatalogName(row.antigenSlug, "en");
const name =
row.vaccineName ??
catalogEn ??
own?.name ??
row.antigenSlug ??
"Vaccination";
const dose = row.doseNumber ? `dose ${row.doseNumber}, ` : "";
const day = dateOnly(row.occurredAt);
return {
id: `vaccination:${row.id}`,
title: scrubFenceMarkers(`${name} (${dose}${day})`),
url: recordUrl(origin, "vaccination", row.id),
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),
},
{
text: "vaccination vaccine immunization impfung",
weight: WEIGHT.secondary,
},
{ text: practitioner?.name ?? "", weight: WEIGHT.secondary },
{ text: practitioner?.specialty ?? "", weight: WEIGHT.secondary },
],
};
});
fields: [
{ text: row.vaccineName ?? "", weight: WEIGHT.primary },
{
text: [
catalogEn ?? "",
vaccineCatalogName(row.antigenSlug, owner.locale) ?? "",
row.antigenSlug
? enumWords(row.antigenSlug.replace(/-/g, "_"))
: "",
...(entry?.synonyms ?? []),
own?.name ?? "",
...(own?.components ?? []).map((antigen) =>
enumWords(antigen.replace(/-/g, "_")),
),
].join(" "),
weight: WEIGHT.primary,
},
{
text: "vaccination vaccine immunization impfung",
weight: WEIGHT.secondary,
},
{ text: practitioner?.name ?? "", weight: WEIGHT.secondary },
{ text: practitioner?.specialty ?? "", weight: WEIGHT.secondary },
],
};
},
);
}
export interface RecordSearchOptions {
@@ -790,6 +805,62 @@ export interface RecordSearchOptions {
includeAiDeferredText?: boolean;
/** Newest rows read per kind; defaults to `MAX_ROWS_PER_KIND`. */
rowsPerKind?: number;
/**
* Asked before each kind starts and before every row a kind decrypts.
* Once it says stop, the kind keeps what it has (its newest rows) and
* `onTruncated` is told. The in-app search hands its shared time budget
* and the request's abort signal in here.
*/
shouldStop?: () => boolean;
/**
* Told when a kind did not see all of its rows: it filled its newest
* `rowsPerKind` read, or `shouldStop` ended it part-way.
*/
onTruncated?: (kind: RecordKind) => void;
}
/** How one kind reads: its row cap, and the stop and truncation hooks. */
interface KindRead {
take: number;
shouldStop: () => boolean;
onTruncated: () => void;
}
/**
* A kind's newest read asks for one row past its cap. The extra row only
* answers whether anything older is left: a kind holding exactly `take` rows
* was read whole and is not reported cut short.
*/
function probeTake(read: KindRead): number {
return read.take + 1;
}
/** The rows within the cap, telling `onTruncated` when the probe row came. */
function withinCap<Row>(rows: Row[], read: KindRead): Row[] {
if (rows.length <= read.take) return rows;
read.onTruncated();
return rows.slice(0, read.take);
}
/**
* Map a kind's rows newest first until `shouldStop` says stop. The rows are
* read already; what this bounds is the decrypting and the building, which is
* where a large record spends its time.
*/
function mapUntilStopped<Row, Out>(
rows: readonly Row[],
read: KindRead,
build: (row: Row) => Out,
): Out[] {
const out: Out[] = [];
for (const row of rows) {
if (read.shouldStop()) {
read.onTruncated();
break;
}
out.push(build(row));
}
return out;
}
/**
@@ -811,26 +882,40 @@ export async function loadRecordCandidates(
options.kinds ? Promise.resolve(options.kinds) : readableKinds(userId),
loadOwner(userId, options.displayLocale),
]);
const shouldStop = options.shouldStop ?? (() => false);
const reading = (kind: RecordKind): KindRead | null => {
if (!kinds.has(kind)) return null;
const onTruncated = () => options.onTruncated?.(kind);
if (shouldStop()) {
onTruncated();
return null;
}
return { take, shouldStop, onTruncated };
};
const visit = reading("visit");
const condition = reading("condition");
const document = reading("document");
const vaccination = reading("vaccination");
const groups = await Promise.all([
kinds.has("visit")
? visitCandidates(userId, owner, origin, take, empty)
visit
? visitCandidates(userId, owner, origin, visit, empty)
: Promise.resolve([]),
kinds.has("condition")
? conditionCandidates(userId, owner, origin, take)
condition
? conditionCandidates(userId, owner, origin, condition)
: Promise.resolve([]),
kinds.has("document")
document
? documentCandidates(
userId,
owner,
origin,
take,
document,
query,
words,
options.includeAiDeferredText === true,
)
: Promise.resolve([]),
kinds.has("vaccination")
? vaccinationCandidates(userId, owner, origin, take)
vaccination
? vaccinationCandidates(userId, owner, origin, vaccination)
: Promise.resolve([]),
]);
return groups.flat();
+71
View File
@@ -0,0 +1,71 @@
/**
* The record that a quick-action nonce was spent.
*
* A reminder's answers are single-use through the mood entry they write: its
* unique `(userId, source, externalId)` key refuses a second entry under the
* same nonce. That holds only while the entry exists. A user who answers,
* then deletes the entry and purges it from the trash inside the token's
* six hours, frees the key, and the same notification button would write a
* second entry. So the nonce is claimed on its own as well, in the
* idempotency table, which already holds one-shot claims under a unique key
* and is swept by expiry (`idempotency-cleanup.ts`). The claim expires with
* the token, so the table holds it exactly as long as it matters.
*
* The row can never be read back as a cached response: its method is not an
* HTTP verb, its path names no route, and its fingerprint is null, which
* `withIdempotency` never replays.
*/
import { prisma } from "@/lib/db";
import { isP2002 } from "@/lib/prisma-errors";
import { quickActionExternalId } from "@/lib/notifications/action-token";
const CLAIM_METHOD = "NOTIFICATION_ACTION";
const CLAIM_PATH = "/api/notifications/action";
const CLAIM_STATUS = 0;
/**
* Claim a reminder's nonce. `true` when this call won it, `false` when the
* nonce was already spent (by an earlier answer or a concurrent one).
*/
export async function claimActionNonce(input: {
userId: string;
nonce: string;
expiry: number;
}): Promise<boolean> {
try {
await prisma.idempotencyKey.create({
data: {
userId: input.userId,
key: quickActionExternalId(input.nonce),
method: CLAIM_METHOD,
path: CLAIM_PATH,
requestFingerprint: null,
responseStatus: CLAIM_STATUS,
responseBody: "",
expiresAt: new Date(input.expiry),
},
});
return true;
} catch (err) {
if (isP2002(err)) return false;
throw err;
}
}
/**
* Give a claim back when the write it guarded failed for a reason other than
* the nonce being spent, so the person can tap the answer again.
*/
export async function releaseActionNonce(input: {
userId: string;
nonce: string;
}): Promise<void> {
await prisma.idempotencyKey.deleteMany({
where: {
userId: input.userId,
key: quickActionExternalId(input.nonce),
method: CLAIM_METHOD,
path: CLAIM_PATH,
},
});
}
@@ -1,8 +1,8 @@
/**
* ntfy sender unit tests.
*
* Covers the outbound shape (topic appended to the server URL, priority and
* tag headers, bearer token), the outcome classification, and (#947) the
* Covers the outbound shape (a JSON publish to the server root carrying
* topic, priority and tags, bearer token), the outcome classification, and (#947) the
* NOTIFICATION_PRIVATE_ORIGINS grant: a listed server rides the
* operator-approved pin, an unlisted one keeps the public pin, and a listed
* origin admits nothing beside itself.
@@ -85,7 +85,7 @@ afterEach(() => {
});
describe("sendViaNtfy", () => {
it("POSTs the topic URL through safeFetch under the public pin and returns ok on 200", async () => {
it("POSTs a JSON message to the server root through safeFetch under the public pin and returns ok on 200", async () => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
const result = await sendViaNtfy(config, payload());
@@ -96,10 +96,17 @@ describe("sendViaNtfy", () => {
{ method?: string; headers?: Record<string, string>; body?: string },
EgressOpts,
];
expect(url).toBe("https://ntfy.example.com/health");
expect(url).toBe("https://ntfy.example.com/");
expect(init.method).toBe("POST");
expect(init.headers?.Authorization).toBe("Bearer tk_secret");
expect(init.body).toBe("Body");
expect(init.headers?.["Content-Type"]).toBe("application/json");
expect(JSON.parse(init.body!)).toEqual({
topic: "health",
title: "Title",
message: "Body",
priority: 3,
tags: ["system-alert"],
});
expect(opts.requirePublicHost).toBe(true);
expect(opts.operatorApprovedPrivateOrigin).toBeUndefined();
expect(recordPushAttemptMock).toHaveBeenCalledWith(
@@ -164,7 +171,7 @@ describe("sendViaNtfy", () => {
unknown,
EgressOpts,
];
expect(url).toBe("http://ntfy.lan:8080/health");
expect(url).toBe("http://ntfy.lan:8080/");
expect(opts.requirePublicHost).toBe(false);
expect(opts.operatorApprovedPrivateOrigin).toBe("http://ntfy.lan:8080");
expect(annotateMock).toHaveBeenCalledWith({
@@ -270,15 +277,19 @@ describe("sendViaNtfy — what the server said (#947)", () => {
});
describe("sendViaNtfy — tap target and quick actions", () => {
function headersOfFirstCall(): Record<string, string> {
function messageOfFirstCall(): Record<string, unknown> {
const [, init] = safeFetchMock.mock.calls[0] as [
string,
{ headers: Record<string, string> },
{ headers: Record<string, string>; body: string },
];
return init.headers;
// Nothing of the message rides a header any more.
expect(Object.keys(init.headers).sort()).toEqual(
["Authorization", "Content-Type"].sort(),
);
return JSON.parse(init.body) as Record<string, unknown>;
}
it("sets Click to the resolved absolute tap URL", async () => {
it("sets click to the resolved absolute tap URL", async () => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
await sendViaNtfy(
config,
@@ -291,17 +302,18 @@ describe("sendViaNtfy — tap target and quick actions", () => {
},
}),
);
expect(headersOfFirstCall().Click).toBe("https://health.example/");
expect(headersOfFirstCall().Actions).toBeUndefined();
const message = messageOfFirstCall();
expect(message.click).toBe("https://health.example/");
expect(message.actions).toBeUndefined();
});
it("omits Click when no public origin produced a URL", async () => {
it("omits click when no public origin produced a URL", async () => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
await sendViaNtfy(
config,
payload({ tap: { path: "/", url: null, label: "Open" } }),
);
expect(headersOfFirstCall().Click).toBeUndefined();
expect(messageOfFirstCall().click).toBeUndefined();
});
it("sends the quick answers as three POST http actions that clear the notification", async () => {
@@ -317,18 +329,66 @@ describe("sendViaNtfy — tap target and quick actions", () => {
],
}),
);
expect(headersOfFirstCall().Actions).toBe(
"http, Good, https://h.example/a/1, method=POST, clear=true; " +
"http, Okay, https://h.example/a/2, method=POST, clear=true; " +
"http, Bad, https://h.example/a/3, method=POST, clear=true",
expect(messageOfFirstCall().actions).toEqual([
{
action: "http",
label: "Good",
url: "https://h.example/a/1",
method: "POST",
clear: true,
},
{
action: "http",
label: "Okay",
url: "https://h.example/a/2",
method: "POST",
clear: true,
},
{
action: "http",
label: "Bad",
url: "https://h.example/a/3",
method: "POST",
clear: true,
},
]);
});
it("carries non-ASCII titles and labels as plain UTF-8, URLs untouched", async () => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
await sendViaNtfy(
config,
payload({
eventType: "MOOD_REMINDER",
title: "기분은 어떠세요?",
quickActions: [
{ id: "mood-good", label: "Świetnie", url: "https://h.example/a/1" },
{ id: "mood-okay", label: "보통", url: "https://h.example/a/2" },
{ id: "mood-bad", label: "Così così", url: "https://h.example/a/3" },
],
}),
);
const message = messageOfFirstCall();
expect(message.title).toBe("기분은 어떠세요?");
const actions = message.actions as { label: string; url: string }[];
expect(actions.map((a) => a.label)).toEqual([
"Świetnie",
"보통",
"Così così",
]);
expect(actions.map((a) => a.url)).toEqual([
"https://h.example/a/1",
"https://h.example/a/2",
"https://h.example/a/3",
]);
expect(JSON.stringify(message)).not.toContain("=?UTF-8?");
});
});
describe("ntfy header helpers", () => {
it("caps the actions at three and strips separators from labels", async () => {
const { buildNtfyActionsHeader } = await import("../ntfy");
const header = buildNtfyActionsHeader({
describe("ntfy message helpers", () => {
it("caps the actions at three and keeps labels verbatim", async () => {
const { buildNtfyActions } = await import("../ntfy");
const actions = buildNtfyActions({
quickActions: [
{ id: "mood-good", label: 'Go;od, "yes"', url: "https://h/1" },
{ id: "mood-okay", label: "B", url: "https://h/2" },
@@ -336,20 +396,20 @@ describe("ntfy header helpers", () => {
{ id: "mood-bad", label: "D", url: "https://h/4" },
],
});
expect(header?.split("; ")).toHaveLength(3);
expect(header).toContain("http, Go od yes, https://h/1");
expect(header).not.toContain("https://h/4");
expect(buildNtfyActionsHeader({ quickActions: [] })).toBeNull();
expect(actions).toHaveLength(3);
// JSON needs no separator escaping: the label reaches ntfy as written.
expect(actions[0]!.label).toBe('Go;od, "yes"');
expect(actions.map((a) => a.url)).not.toContain("https://h/4");
expect(buildNtfyActions({ quickActions: [] })).toEqual([]);
});
it("RFC 2047-encodes a non-ASCII header value and leaves ASCII alone", async () => {
const { ntfyHeaderValue } = await import("../ntfy");
expect(ntfyHeaderValue("Morning score 78")).toBe("Morning score 78");
const encoded = ntfyHeaderValue("아침 점수 78");
expect(encoded).toMatch(/^=\?UTF-8\?B\?[A-Za-z0-9+/=]+\?=$/);
const inner = encoded.slice("=?UTF-8?B?".length, -2);
expect(Buffer.from(inner, "base64").toString("utf8")).toBe("아침 점수 78");
// `ü` is Latin-1, which fetch would send as one invalid UTF-8 byte.
expect(ntfyHeaderValue("Morgenüberblick")).toMatch(/^=\?UTF-8\?B\?/);
it("maps a medication reminder to high priority (4)", async () => {
const { buildNtfyMessage } = await import("../ntfy");
expect(
buildNtfyMessage(
"t",
payload({ eventType: "MEDICATION_REMINDER" }) as never,
).priority,
).toBe(4);
});
});
@@ -104,6 +104,15 @@ beforeEach(() => {
afterEach(() => vi.clearAllMocks());
/** The JSON message the ntfy sender published on call `i`. */
function ntfyMessage(i: number): {
priority: number;
tags: string[];
actions?: unknown;
} {
return JSON.parse((safeFetchMock.mock.calls[i][1] as { body: string }).body);
}
describe("ntfy urgent mapping", () => {
it("urgent → Priority 5 (max) + warning tag", async () => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
@@ -111,10 +120,8 @@ describe("ntfy urgent mapping", () => {
{ serverUrl: "https://ntfy.example.com", topic: "t" },
payload({ urgent: true }),
);
const [, init] = safeFetchMock.mock.calls[0];
const headers = (init as { headers: Record<string, string> }).headers;
expect(headers.Priority).toBe("5");
expect(headers.Tags).toContain("warning");
expect(ntfyMessage(0).priority).toBe(5);
expect(ntfyMessage(0).tags).toContain("warning");
});
it("non-urgent SYSTEM_ALERT → default priority, no warning tag", async () => {
@@ -123,13 +130,8 @@ describe("ntfy urgent mapping", () => {
{ serverUrl: "https://ntfy.example.com", topic: "t" },
payload(),
);
const headers = (
safeFetchMock.mock.calls[0][1] as {
headers: Record<string, string>;
}
).headers;
expect(headers.Priority).toBe("default");
expect(headers.Tags).not.toContain("warning");
expect(ntfyMessage(0).priority).toBe(3);
expect(ntfyMessage(0).tags).not.toContain("warning");
});
it("discreet urgent keeps the generic tag (no warning leak)", async () => {
@@ -138,16 +140,11 @@ describe("ntfy urgent mapping", () => {
{ serverUrl: "https://ntfy.example.com", topic: "t" },
payload({ urgent: true, discreet: true }),
);
const headers = (
safeFetchMock.mock.calls[0][1] as {
headers: Record<string, string>;
}
).headers;
expect(headers.Priority).toBe("5");
expect(headers.Tags).toBe("reminder");
expect(ntfyMessage(0).priority).toBe(5);
expect(ntfyMessage(0).tags).toEqual(["reminder"]);
});
it("does not send ntfy action headers to a managed Guardian", async () => {
it("does not send ntfy actions to a managed Guardian", async () => {
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
await sendViaNtfy(
{ serverUrl: "https://ntfy.example.com", topic: "t" },
@@ -157,12 +154,7 @@ describe("ntfy urgent mapping", () => {
recipientUserId: "guardian-1",
}),
);
const headers = (
safeFetchMock.mock.calls[0][1] as {
headers: Record<string, string>;
}
).headers;
expect(headers.Actions).toBeUndefined();
expect(ntfyMessage(0).actions).toBeUndefined();
});
});
@@ -245,10 +237,7 @@ describe("APNs-less instance degrades gracefully", () => {
expect(ntfy.ok).toBe(true);
expect(web.ok).toBe(true);
expect(
(safeFetchMock.mock.calls[0][1] as { headers: Record<string, string> })
.headers.Priority,
).toBe("5");
expect(ntfyMessage(0).priority).toBe(5);
expect(
(generateRequestDetailsMock.mock.calls[0][2] as { urgency?: string })
.urgency,
+84 -74
View File
@@ -41,48 +41,24 @@ export async function sendViaNtfy(
// can open.
let policyReason: OriginReason | null = null;
try {
const url = `${config.serverUrl.replace(/\/$/, "")}/${encodeURIComponent(config.topic)}`;
// v1.18.4 — an explicitly urgent payload escalates to ntfy's top
// priority (`5` / max) so it bypasses the relay's batching and surfaces
// loudest; MEDICATION_REMINDER keeps its long-standing `high` (4); the
// rest stay `default` (3). `urgent` also adds a tag so the lock-screen
// entry reads as an alert (unless discreet privacy collapses tags).
const urgent = isUrgentPayload(payload) && payload.urgent === true;
const priority = urgent
? "5"
: payload.eventType === "MEDICATION_REMINDER"
? "high"
: "default";
const eventTag = payload.discreet
? "reminder"
: payload.eventType.toLowerCase().replace(/_/g, "-");
// Published as JSON to the server root rather than as a body with
// headers to the topic URL. A header carries only Latin-1 (`fetch`
// refuses anything above U+00FF, and ntfy reads a Latin-1 byte as invalid
// UTF-8), so a Korean title or a Polish action label had to travel as an
// RFC 2047 encoded word, and an `Actions` header encoded whole, URLs and
// all, is not a form ntfy's action parser is documented to accept, so a
// reminder with an Italian, Korean or Polish label risked a 400 and never
// arrived. A JSON body is UTF-8 end to end and has no header encoding
// at all (https://docs.ntfy.sh/publish/#publish-as-json).
const url = `${config.serverUrl.replace(/\/$/, "")}/`;
const message = buildNtfyMessage(config.topic, payload);
const headers: Record<string, string> = {
Title: ntfyHeaderValue(plainPushText(payload.title, payload.eventType)),
Priority: priority,
// Discreet mode (cycle privacy): the X-Tags header is visible on the
// lock screen, so collapse it to a generic tag instead of leaking the
// cycle event name. An urgent (non-discreet) event also carries a
// `warning` tag so the entry renders as an alert.
Tags: urgent && !payload.discreet ? `warning,${eventTag}` : eventTag,
"Content-Type": "application/json",
};
if (config.authToken) {
headers["Authorization"] = `Bearer ${config.authToken}`;
}
// Tapping the notification opens this URL (ntfy `Click`). Absent when
// the operator has not configured a public origin.
if (payload.tap?.url) {
headers["Click"] = payload.tap.url;
}
const actions = buildNtfyActionsHeader(payload);
if (actions) {
headers["Actions"] = ntfyHeaderValue(actions);
}
// Strip HTML tags for ntfy (plain text only) + emoji on routine reminders
const body = plainPushText(stripHtml(payload.message), payload.eventType);
const body = JSON.stringify(message);
// The DNS-rebinding pin (issue #217) sits on top of the input-time
// guard enforced when the user saved the serverUrl; the authToken would
@@ -201,47 +177,81 @@ export async function sendViaNtfy(
/** ntfy shows at most three actions per notification. */
const NTFY_MAX_ACTIONS = 3;
/**
* A label safe inside the `Actions` header's short format: `,` and `;`
* separate fields and actions there, and a quote would open a quoted value.
*/
function ntfyActionLabel(label: string): string {
return label.replace(/[,;"'\r\n]/g, " ").trim() || "?";
/** One `http` action button in ntfy's JSON form. */
export interface NtfyHttpAction {
action: "http";
label: string;
url: string;
method: "POST";
clear: true;
}
/** The JSON publish body (https://docs.ntfy.sh/publish/#publish-as-json). */
export interface NtfyMessage {
topic: string;
title: string;
message: string;
priority: 3 | 4 | 5;
tags: string[];
click?: string;
actions?: NtfyHttpAction[];
}
/**
* The `Actions` header for the payload's quick answers, in ntfy's short
* format (https://docs.ntfy.sh/publish/#action-buttons):
* The message ntfy publishes for a payload.
*
* http, <label>, <url>, method=POST, clear=true; …
*
* `clear=true` dismisses the notification once the request succeeded, so a
* reminder answered from the shade does not linger. The token in the URL is
* the request's only credential (see `action-token.ts`).
* - Priority: an explicitly urgent payload escalates to ntfy's top priority
* (5, max) so it bypasses the relay's batching and surfaces loudest;
* MEDICATION_REMINDER keeps its long-standing high (4); the rest stay at
* the default (3).
* - Tags: discreet mode (cycle privacy) collapses them to a generic tag,
* because tags are visible on the lock screen and would name the cycle
* event. An urgent, non-discreet event also carries `warning` so the entry
* renders as an alert.
* - Click: tapping the notification opens the resolved tap URL; absent when
* the operator configured no public origin.
* - Actions: the payload's quick answers, at most three, as `http` POST
* buttons with `clear` so an answered reminder leaves the shade. The token
* in each URL is the request's only credential (see `action-token.ts`).
*/
export function buildNtfyActionsHeader(
export function buildNtfyMessage(
topic: string,
payload: NotificationPayload,
): NtfyMessage {
const urgent = isUrgentPayload(payload) && payload.urgent === true;
const priority = urgent
? 5
: payload.eventType === "MEDICATION_REMINDER"
? 4
: 3;
const eventTag = payload.discreet
? "reminder"
: payload.eventType.toLowerCase().replace(/_/g, "-");
const message: NtfyMessage = {
topic,
title: plainPushText(payload.title, payload.eventType),
// Plain text only, and no emoji on routine reminders.
message: plainPushText(stripHtml(payload.message), payload.eventType),
priority,
tags: urgent && !payload.discreet ? ["warning", eventTag] : [eventTag],
};
if (payload.tap?.url) message.click = payload.tap.url;
const actions = buildNtfyActions(payload);
if (actions.length > 0) message.actions = actions;
return message;
}
/** The payload's quick answers as ntfy `http` actions, at most three. */
export function buildNtfyActions(
payload: Pick<NotificationPayload, "quickActions">,
): string | null {
const actions = (payload.quickActions ?? []).slice(0, NTFY_MAX_ACTIONS);
if (actions.length === 0) return null;
return actions
.map(
(action) =>
`http, ${ntfyActionLabel(action.label)}, ${action.url}, method=POST, clear=true`,
)
.join("; ");
}
/**
* A header value fetch can send and ntfy reads as UTF-8.
*
* `fetch` refuses any header character above U+00FF outright (a Korean
* title or action label would fail the whole send), and a Latin-1 byte such
* as `ü` reaches ntfy as invalid UTF-8. ntfy decodes RFC 2047 encoded words
* in every header, so a value with anything beyond printable ASCII travels as
* one `=?UTF-8?B?…?=` word; plain ASCII stays readable on the wire.
*/
export function ntfyHeaderValue(value: string): string {
if (/^[\x20-\x7e]*$/.test(value)) return value;
return `=?UTF-8?B?${Buffer.from(value, "utf8").toString("base64")}?=`;
): NtfyHttpAction[] {
return (payload.quickActions ?? [])
.slice(0, NTFY_MAX_ACTIONS)
.map((action) => ({
action: "http",
label: action.label.trim() || "?",
url: action.url,
method: "POST",
clear: true,
}));
}
+5 -2
View File
@@ -5,7 +5,7 @@
* the dispatcher resolves the path once per delivery and every channel
* carries the same target in its own vocabulary:
*
* - ntfy → `Click` header (absolute URL)
* - ntfy → `click` in the JSON publish body (absolute URL)
* - Telegram → an inline URL button (absolute `https` URL only, see
* `telegramLinkMode`)
* - Web Push → `url` in the service-worker payload (same-origin path)
@@ -36,7 +36,10 @@ export const NOTIFICATION_TAP_PATHS: Record<EventType, string | null> = {
WITHINGS_SYNC_FAILED: "/settings/integrations",
SYSTEM_ALERT: "/",
PERSONAL_RECORD: "/achievements",
// `?add=1` opens the mood entry sheet on the first paint.
// `?add=1` opens the mood entry sheet on the first paint, and only in the
// recipient's own record: a session switched into another record opens the
// page without the sheet (`src/app/mood/page-client.tsx`). No other target
// here opens a write form.
MOOD_REMINDER: "/mood?add=1",
CYCLE_PERIOD_SOON: "/cycle",
CYCLE_PERIOD_CONFIRM: "/cycle",
+6 -3
View File
@@ -17,12 +17,15 @@ import { dataEnvelope, recordRefusal, stdResponses } from "./shared";
export const searchPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/search": {
get: {
post: {
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 },
"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 travels in the JSON body, never in the URL, and is never logged. Rate limited per acting account (20 a minute), and one search at a time: a newer search by the same account stops the one before it, which then answers 409 `search.superseded`. A grant scoped to some sections of the record does not open it, like the timeline.",
requestBody: {
required: true,
content: { "application/json": { schema: searchQuerySchema } },
},
responses: {
"200": {
description: "The hits, grouped by kind.",
+2 -2
View File
@@ -1,5 +1,5 @@
/**
* Query keys — the in-app record search (`GET /api/search`, v1.42.2).
* Query keys — the in-app record search (`POST /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
@@ -9,7 +9,7 @@
export const searchKeys = {
/** Root prefix. */
searchRoot: () => ["search"] as const,
/** `GET /api/search?q=&limit=`. */
/** `POST /api/search` with `{ q, limit }`. */
recordSearch: (query: string, limit: number) =>
["search", "records", query, limit] as const,
};
@@ -19,9 +19,15 @@ import type { SearchCandidate } from "../match";
import {
readableSources,
scanNewest,
searchRecords,
type ScanBudget,
} from "../search-records";
const { loadRecordCandidates } = vi.hoisted(() => ({
loadRecordCandidates: vi.fn(async () => []),
}));
vi.mock("@/lib/mcp/record-search", () => ({ loadRecordCandidates }));
function budget(deadlineIn = 10_000, clock = { t: 0 }): ScanBudget {
return {
deadline: clock.t + deadlineIn,
@@ -73,7 +79,24 @@ describe("scanNewest", () => {
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]);
// The last batch probes one row past the cap and does not keep it.
expect(s.reads).toEqual([100, 100, 100, 100, 51]);
expect(b.truncated).toBe(true);
});
it("does not call a source holding exactly the cap truncated", async () => {
const b = budget();
const s = source(450, 1_000_000);
await scanNewest({ budget: b, wanted: 3, batch: 100, maxRows: 450, ...s });
expect(b.rows).toBe(450);
expect(b.truncated).toBe(false);
});
it("calls a source one row past the cap truncated", async () => {
const b = budget();
const s = source(451, 1_000_000);
await scanNewest({ budget: b, wanted: 3, batch: 100, maxRows: 450, ...s });
expect(b.rows).toBe(450);
expect(b.truncated).toBe(true);
});
@@ -91,6 +114,64 @@ describe("scanNewest", () => {
});
});
describe("an abandoned search", () => {
it("stops a scan at its next batch once the signal aborts", async () => {
const controller = new AbortController();
const b = { ...budget(), signal: controller.signal };
const s = source(10_000, 1_000_000);
const read = async (cursor: string | null, take: number) => {
controller.abort();
return s.read(cursor, take);
};
await scanNewest({ budget: b, wanted: 3, batch: 10, read, match: s.match });
expect(s.reads).toEqual([10]);
expect(b.truncated).toBe(true);
});
it("starts no source when the request is already gone", async () => {
const controller = new AbortController();
controller.abort();
const result = await searchRecords({
recordId: "u1",
query: "schulter",
limit: 5,
modules: null,
domainVisible: () => true,
displayLocale: "en",
timezone: "UTC",
signal: controller.signal,
});
// `prisma` is an empty object here: any source that ran would throw.
expect(loadRecordCandidates).not.toHaveBeenCalled();
expect(result.groups).toEqual([]);
expect(result.truncated).toBe(true);
});
it("hands the clinical kinds the shared budget", async () => {
const clock = { t: 0 };
loadRecordCandidates.mockImplementationOnce(async (...args: unknown[]) => {
const options = args[3] as { shouldStop: () => boolean };
expect(options.shouldStop()).toBe(false);
clock.t = 2_000;
expect(options.shouldStop()).toBe(true);
return [];
});
await searchRecords({
recordId: "u1",
query: "schulter",
limit: 5,
modules: null,
// Only the clinical kinds: every other source would reach `prisma`.
domainVisible: (domain) => domain === "profile",
displayLocale: "en",
timezone: "UTC",
now: () => clock.t,
budgetMs: 1_500,
});
expect(loadRecordCandidates).toHaveBeenCalledTimes(1);
});
});
describe("readableSources", () => {
const all = () => true;
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* The in-app record search (`GET /api/search`): its vocabulary and wire
* The in-app record search (`POST /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`.
+42
View File
@@ -0,0 +1,42 @@
/**
* One search in flight per actor.
*
* The palette searches once per pause in typing, and the client cancels the
* previous request when the text changes. A cancelled fetch does not stop the
* server, though: without this, every keystroke-pause left a search reading
* and decrypting on the server until its own budget ran out, so a fast typist
* held several at once. A new search by the same actor aborts the one before
* it, which stops at its next budget check.
*
* Per process: on a multi-instance deploy two searches by one actor on two
* instances both run, each still bounded by its own time budget and by the
* per-actor rate limit, which lives in Postgres.
*/
const inFlight = new Map<string, AbortController>();
/** The reason a search was stopped for a newer one. */
export const SUPERSEDED = "search.superseded";
export interface SearchSlot {
/** Aborted when the same actor starts a newer search. */
signal: AbortSignal;
/** Release the slot once the search answered (or failed). */
end: () => void;
}
export function beginSearch(actorId: string): SearchSlot {
inFlight.get(actorId)?.abort(SUPERSEDED);
const controller = new AbortController();
inFlight.set(actorId, controller);
return {
signal: controller.signal,
end: () => {
if (inFlight.get(actorId) === controller) inFlight.delete(actorId);
},
};
}
/** Searches currently held, for tests. */
export function searchesInFlight(): number {
return inFlight.size;
}
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* 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.
* and the in-app search (`POST /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,
+56 -20
View File
@@ -1,5 +1,5 @@
/**
* The in-app record search behind `GET /api/search` (Discussion #1117): one
* The in-app record search behind `POST /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.
@@ -16,7 +16,11 @@
* 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;
* checks it before every batch and stops when it has run out, the
* clinical kinds check it before every row they decrypt, and a source
* that has not started yet when it runs out is not started at all;
* - the request's abort signal ends the search the same way: a client that
* typed on, or a newer search by the same actor, stops this one;
* - 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
@@ -101,12 +105,19 @@ const MAX_DOCUMENT_SNIPPETS = 5;
export interface ScanBudget {
readonly deadline: number;
readonly now: () => number;
/** Aborted when nobody waits for the answer any more. */
readonly signal?: AbortSignal;
/** A cap or the deadline stopped a source before it read everything. */
truncated: boolean;
/** Rows read, for the annotation. */
rows: number;
}
/** The search may not read any further: out of time, or abandoned. */
export function budgetSpent(budget: ScanBudget): boolean {
return budget.signal?.aborted === true || budget.now() >= budget.deadline;
}
/**
* 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.
@@ -128,26 +139,34 @@ export async function scanNewest<Row extends { id: string }>(args: {
let cursor: string | null = null;
let scanned = 0;
for (;;) {
if (budget.now() >= budget.deadline) {
if (budgetSpent(budget)) {
budget.truncated = true;
break;
}
const take = Math.min(batch, maxRows - scanned);
const rows = await read(cursor, take);
const remaining = maxRows - scanned;
// The batch that reaches the cap asks for one row more than it keeps:
// that row only answers whether anything is left, so a source holding
// exactly `maxRows` rows is read whole and not reported cut short.
const last = remaining <= batch;
const take = last ? remaining : batch;
const fetched = await read(cursor, last ? take + 1 : take);
const more = last && fetched.length > take;
const rows = more ? fetched.slice(0, take) : fetched;
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) {
if (more) {
budget.truncated = true;
break;
}
// A short batch, or the cap reached with nothing beyond it, is the end
// of the source.
if (rows.length < take || last) break;
// One more than the limit: the caller learns there is more.
if (out.length > wanted) break;
cursor = rows[rows.length - 1].id;
}
return out;
@@ -161,15 +180,23 @@ function after(cursor: string | null): {
return cursor ? { cursor: { id: cursor }, skip: 1 } : {};
}
/** Run `tasks` with at most `limit` in flight. */
/**
* Run `tasks` with at most `limit` in flight. A task not yet started when
* the budget is spent is not started: the search says `truncated` instead.
*/
async function inPool(
tasks: ReadonlyArray<() => Promise<void>>,
limit: number,
budget: ScanBudget,
): Promise<void> {
let next = 0;
const worker = async () => {
while (next < tasks.length) {
const task = tasks[next++];
if (budgetSpent(budget)) {
budget.truncated = true;
continue;
}
await task();
}
};
@@ -805,6 +832,12 @@ export interface SearchRecordsArgs {
displayLocale: Locale;
/** The record's zone, for the calendar day of an instant. */
timezone: string;
/**
* Aborted when the answer is no longer wanted (the client went away, or
* the same actor started a newer search): every source stops at its next
* check.
*/
signal?: AbortSignal;
/** Overrides for tests. */
now?: () => number;
budgetMs?: number;
@@ -837,6 +870,7 @@ export async function searchRecords(
const budget: ScanBudget = {
deadline: started + (args.budgetMs ?? SEARCH_TIME_BUDGET_MS),
now,
signal: args.signal,
truncated: false,
rows: 0,
};
@@ -877,20 +911,22 @@ export async function searchRecords(
displayLocale: args.displayLocale,
includeAiDeferredText: true,
rowsPerKind: RECORD_ROWS_PER_KIND,
// The same budget as every other source: each kind checks it
// before every row it decrypts, so a large record cannot hold the
// search past its time.
shouldStop: () => budgetSpent(budget),
// A kind that holds more than its newest `RECORD_ROWS_PER_KIND`,
// or that the budget stopped part-way, may have older matches the
// search did not see.
onTruncated: () => {
budget.truncated = true;
},
},
);
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));
@@ -903,7 +939,7 @@ export async function searchRecords(
add("illnessNote", () => illnessNoteCandidates(ctx));
add("measurementNote", () => measurementNoteCandidates(ctx));
await inPool(tasks, SOURCE_CONCURRENCY);
await inPool(tasks, SOURCE_CONCURRENCY, budget);
const ranked: Ranked[] = collected
.filter((candidate) => candidate.hit)
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Zod schemas for `GET /api/search`. The route parses its query with these
* Zod schemas for `POST /api/search`. The route parses its body with these
* and the OpenAPI table publishes them. Server and OpenAPI side only: the
* palette imports its types from `./contract.ts`.
*/
+3
View File
@@ -225,6 +225,9 @@ const DEMO_MUTATION_ALLOWLIST: ReadonlyArray<{ path: string; method: string }> =
// The per-profile routes (`/api/managed-profiles/{id}`, its guardians)
// are different paths and stay closed too.
{ path: "/api/auth/profile", method: "PUT" },
// v1.42.2 — the record search is a read that travels as a POST so the
// query stays out of URLs and access logs. It writes nothing.
{ path: "/api/search", method: "POST" },
];
// Legacy route redirects (German → English)
@@ -100,4 +100,19 @@ describe("POST /api/notifications/action/{token}", () => {
await getPrismaClient().moodEntry.count({ where: { userId: USER } }),
).toBe(2);
});
it("refuses the reminder again after its entry was deleted and purged", async () => {
const { good, bad } = await tokens("reminder-purged");
expect((await call(good)).status).toBe(200);
// Delete and purge inside the token's lifetime: the row that held the
// unique key is gone.
await getPrismaClient().moodEntry.deleteMany({ where: { userId: USER } });
expect((await call(good)).status).toBe(409);
expect((await call(bad)).status).toBe(409);
expect(
await getPrismaClient().moodEntry.count({ where: { userId: USER } }),
).toBe(0);
});
});
+11 -8
View File
@@ -1,5 +1,5 @@
/**
* v1.42.2 (#1117) — `GET /api/search` against real Postgres.
* v1.42.2 (#1117) — `POST /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
@@ -79,17 +79,20 @@ async function signIn(userId: string) {
}
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));
const { POST } = await import("@/app/api/search/route");
return (
GET as unknown as (
POST as unknown as (
req: NextRequest,
ctx: { params: Promise<object> },
) => Promise<Response>
)(new NextRequest(`http://localhost/api/search?${params}`), {
params: Promise.resolve({}),
});
)(
new NextRequest("http://localhost/api/search", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(limit ? { q, limit } : { q }),
}),
{ params: Promise.resolve({}) },
);
}
async function hits(q: string, limit?: number): Promise<SearchResponse> {