From cefc8c15c14011ede55223524e87d7eabc6771fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 12:15:03 +0200 Subject: [PATCH 01/61] fix(db): pin every connection to UTC and say so at boot when it is not Stored times are UTC in timestamp columns without a zone, and Postgres reads now(), CURRENT_TIMESTAMP and offset-free timestamptz parameters through the session time zone. On a database whose default is not UTC every expiry check ran off by the offset. The Prisma pool, pg-boss, the lock and the maintenance connections now send TimeZone=UTC as a startup option, or SET it on connect when the startup options are disabled for a pooler. A boot check reads SHOW TimeZone and warns on anything else. --- src/instrumentation.ts | 11 +++ src/lib/__tests__/db.test.ts | 39 +++++++++- .../boot/__tests__/time-zone-check.test.ts | 59 +++++++++++++++ src/lib/boot/time-zone-check.ts | 75 +++++++++++++++++++ src/lib/db-session.ts | 69 +++++++++++++++++ src/lib/db.ts | 36 ++++----- .../measurement-maintenance-session.test.ts | 1 + src/lib/jobs/boss-instance.ts | 4 + src/lib/jobs/job-lock.ts | 7 +- src/lib/jobs/measurement-maintenance.ts | 2 +- 10 files changed, 281 insertions(+), 22 deletions(-) create mode 100644 src/lib/boot/__tests__/time-zone-check.test.ts create mode 100644 src/lib/boot/time-zone-check.ts create mode 100644 src/lib/db-session.ts diff --git a/src/instrumentation.ts b/src/instrumentation.ts index 9a3e2cb25..78981c5e6 100644 --- a/src/instrumentation.ts +++ b/src/instrumentation.ts @@ -28,6 +28,17 @@ export async function register() { console.error("[instrumentation] Encryption key check crashed:", err); } + // Every connection is pinned to UTC; say clearly if one is not. + try { + const [{ runBootTimeZoneCheck }, { prisma }] = await Promise.all([ + import("@/lib/boot/time-zone-check"), + import("@/lib/db"), + ]); + await runBootTimeZoneCheck(prisma); + } catch { + // A diagnostic must never break boot. + } + // Every Node process watches its own event loop — web-only // containers included; a stall report from the process serving // requests is the whole point. diff --git a/src/lib/__tests__/db.test.ts b/src/lib/__tests__/db.test.ts index 5cf7c93e6..9ca117bee 100644 --- a/src/lib/__tests__/db.test.ts +++ b/src/lib/__tests__/db.test.ts @@ -7,6 +7,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildSessionOptions, getConnectionBudget, + pinTimeZoneOnConnect, + SET_SESSION_TIME_ZONE, + timeZoneSessionOptions, getPgBossPoolMax, getPoolConnectionTimeoutMs, getPrismaPoolMax, @@ -135,14 +138,14 @@ describe("buildSessionOptions", () => { it("keeps work_mem when the timeout is disabled", () => { process.env.DATABASE_STATEMENT_TIMEOUT_MS = "0"; delete process.env.DATABASE_WORK_MEM; - expect(buildSessionOptions()).toBe("-c work_mem=16MB"); + expect(buildSessionOptions()).toBe("-c work_mem=16MB -c TimeZone=UTC"); }); it("sets the default work_mem beside the timeouts", () => { delete process.env.DATABASE_STATEMENT_TIMEOUT_MS; delete process.env.DATABASE_WORK_MEM; expect(buildSessionOptions()).toBe( - "-c statement_timeout=60000 -c idle_in_transaction_session_timeout=60000 -c work_mem=16MB", + "-c statement_timeout=60000 -c idle_in_transaction_session_timeout=60000 -c work_mem=16MB -c TimeZone=UTC", ); }); @@ -166,6 +169,38 @@ describe("buildSessionOptions", () => { }); }); +// Every session runs in UTC whatever the database default says: the stored +// `timestamp` values are UTC and `now()` reads through the session zone. +describe("session time zone pin", () => { + afterEach(() => { + delete process.env.DATABASE_SESSION_OPTIONS_DISABLED; + }); + + it("rides the startup options of every pool", () => { + expect(buildSessionOptions()).toContain("-c TimeZone=UTC"); + expect(timeZoneSessionOptions()).toBe("-c TimeZone=UTC"); + }); + + it("adds no connect hook while the startup options carry the pin", () => { + const on = vi.fn(); + pinTimeZoneOnConnect({ on } as never); + expect(on).not.toHaveBeenCalled(); + }); + + it("pins by statement on connect when the startup options are off", async () => { + process.env.DATABASE_SESSION_OPTIONS_DISABLED = "true"; + expect(timeZoneSessionOptions()).toBeUndefined(); + const on = vi.fn(); + pinTimeZoneOnConnect({ on } as never); + expect(on).toHaveBeenCalledWith("connect", expect.any(Function)); + const query = vi.fn().mockResolvedValue(undefined); + const hook = on.mock.calls[0][1] as (client: unknown) => void; + hook({ query }); + expect(query).toHaveBeenCalledWith(SET_SESSION_TIME_ZONE); + expect(SET_SESSION_TIME_ZONE).toBe("SET TIME ZONE 'UTC'"); + }); +}); + describe("process-wide Prisma client", () => { const globalSlot = globalThis as unknown as { prisma?: unknown }; diff --git a/src/lib/boot/__tests__/time-zone-check.test.ts b/src/lib/boot/__tests__/time-zone-check.test.ts new file mode 100644 index 000000000..bce14204c --- /dev/null +++ b/src/lib/boot/__tests__/time-zone-check.test.ts @@ -0,0 +1,59 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + checkSessionTimeZone, + isUtcTimeZone, + runBootTimeZoneCheck, +} from "../time-zone-check"; + +function clientAnswering(rows: unknown) { + return { $queryRawUnsafe: vi.fn().mockResolvedValue(rows) }; +} + +describe("boot time zone check", () => { + afterEach(() => vi.restoreAllMocks()); + + it("accepts the spellings Postgres reports for UTC", () => { + for (const name of ["UTC", "Etc/UTC", "utc", "UCT", "Zulu"]) { + expect(isUtcTimeZone(name)).toBe(true); + } + for (const name of ["Europe/Berlin", "GMT+1", "America/New_York"]) { + expect(isUtcTimeZone(name)).toBe(false); + } + }); + + it("stays quiet on a UTC session", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const client = clientAnswering([{ TimeZone: "UTC" }]); + expect(await runBootTimeZoneCheck(client)).toEqual({ + state: "utc", + timeZone: "UTC", + }); + expect(client.$queryRawUnsafe).toHaveBeenCalledWith("SHOW TimeZone"); + expect(warn).not.toHaveBeenCalled(); + }); + + it("warns, naming the zone and the fix, on any other zone", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const outcome = await runBootTimeZoneCheck( + clientAnswering([{ TimeZone: "Europe/Berlin" }]), + ); + expect(outcome).toEqual({ state: "mismatch", timeZone: "Europe/Berlin" }); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('"Europe/Berlin"'); + expect(warn.mock.calls[0][0]).toContain("SET timezone TO 'UTC'"); + }); + + it("never throws when the database cannot answer", async () => { + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + const client = { + $queryRawUnsafe: vi.fn().mockRejectedValue(new Error("down")), + }; + expect(await checkSessionTimeZone(client)).toEqual({ + state: "error", + message: "down", + }); + await runBootTimeZoneCheck(client); + expect(warn.mock.calls[0][0]).toContain("skipped: down"); + }); +}); diff --git a/src/lib/boot/time-zone-check.ts b/src/lib/boot/time-zone-check.ts new file mode 100644 index 000000000..45a23ba60 --- /dev/null +++ b/src/lib/boot/time-zone-check.ts @@ -0,0 +1,75 @@ +/** + * Boot check: does a pooled connection really run in UTC? + * + * HealthLog pins `TimeZone=UTC` on every connection it opens (see + * `@/lib/db-session`). This reads the setting back on one pooled connection + * and says so in the log when it is anything else: a pooler that drops the + * startup options, a role or proxy that overrides the setting. The expiry + * checks of passkey challenges, sessions and one-time links read the stored + * UTC timestamps through that zone, so a mismatch shows up as "expired" + * straight away. The check never throws and never stops the boot. + */ + +const UTC_NAMES = /^(etc\/)?(utc|uct|universal|zulu)$/i; + +export interface TimeZoneCheckClient { + $queryRawUnsafe(query: string): Promise; +} + +export type TimeZoneCheckOutcome = + | { state: "utc"; timeZone: string } + | { state: "mismatch"; timeZone: string } + | { state: "error"; message: string }; + +export function isUtcTimeZone(name: string): boolean { + return UTC_NAMES.test(name.trim()); +} + +export async function checkSessionTimeZone( + client: TimeZoneCheckClient, +): Promise { + try { + const rows = + await client.$queryRawUnsafe>>( + "SHOW TimeZone", + ); + const value = rows[0] ? Object.values(rows[0])[0] : undefined; + const timeZone = typeof value === "string" ? value : String(value); + return isUtcTimeZone(timeZone) + ? { state: "utc", timeZone } + : { state: "mismatch", timeZone }; + } catch (err) { + return { + state: "error", + message: err instanceof Error ? err.message : String(err), + }; + } +} + +export function timeZoneMismatchMessage(timeZone: string): string { + return ( + `[boot] The database session runs in time zone "${timeZone}", not UTC. ` + + "HealthLog stores times as UTC and pins every connection to UTC, but " + + "something between the app and the database overrode it. Expiry checks " + + "(passkey challenges, sessions, one-time links) will be off by the zone " + + "offset. Fix: ALTER DATABASE SET timezone TO 'UTC', and if a " + + "connection pooler drops the startup options, set " + + "DATABASE_SESSION_OPTIONS_DISABLED=true so the app pins the zone by " + + "statement instead." + ); +} + +/** Run the check and log the outcome. Resolves the outcome for tests. */ +export async function runBootTimeZoneCheck( + client: TimeZoneCheckClient, +): Promise { + const outcome = await checkSessionTimeZone(client); + if (outcome.state === "mismatch") { + console.warn(timeZoneMismatchMessage(outcome.timeZone)); + } else if (outcome.state === "error") { + console.warn( + `[boot] Database time zone check skipped: ${outcome.message.slice(0, 300)}`, + ); + } + return outcome; +} diff --git a/src/lib/db-session.ts b/src/lib/db-session.ts new file mode 100644 index 000000000..e9b0e8323 --- /dev/null +++ b/src/lib/db-session.ts @@ -0,0 +1,69 @@ +/** + * Session settings shared by every Postgres connection HealthLog opens: the + * Prisma pool, pg-boss's pool and the dedicated lock and maintenance clients. + * Kept apart from `db.ts` so a side connection can use them without loading + * the Prisma client. + */ +import type { Pool, PoolClient } from "pg"; + +/** + * Whether the app may send the libpq `options` startup parameter at all. + * + * PgBouncer refuses a connection that carries a startup parameter it does not + * know (`unsupported startup parameter: options`) unless the operator lists + * it in `ignore_startup_parameters`. Up to v1.41 such a host could set the + * statement timeout to 0, which dropped the whole string; v1.42 always sends + * `work_mem`, so that no longer worked. `DATABASE_SESSION_OPTIONS_DISABLED` + * (1, true or yes) sends none of the session settings; the pooler or the + * database's own defaults then apply. + */ +export function sessionOptionsDisabled(): boolean { + const raw = process.env.DATABASE_SESSION_OPTIONS_DISABLED?.trim(); + return raw !== undefined && /^(1|true|yes)$/i.test(raw); +} + +/** + * The session time zone every connection HealthLog opens runs in. + * + * Almost every `DateTime` column is `timestamp(3)` WITHOUT time zone and + * holds UTC. Postgres converts between that type and `now()` / + * `CURRENT_TIMESTAMP` / `timestamptz` through the session time zone, and the + * driver sends a `Date` parameter as an offset-free UTC string that a + * `timestamptz` column reads in the session time zone. On a database whose + * default is anything but UTC (a shared instance set to Europe/Berlin, say) + * every expiry check then ran hours off, a passkey challenge expired before + * the prompt closed, and `timestamptz` writes landed shifted. Pinning the + * session removes the dependence for every query at once, whatever the + * database or role default says. + */ +export const SESSION_TIME_ZONE = "UTC"; + +export const TIME_ZONE_OPTION = `-c TimeZone=${SESSION_TIME_ZONE}`; + +/** + * The statement that pins the time zone where the startup `options` cannot + * be sent (`DATABASE_SESSION_OPTIONS_DISABLED`, a connection pooler). + * PgBouncer tracks `TimeZone` per client and replays it onto whichever server + * connection serves the next transaction, so a `SET` on connect holds in + * transaction pooling too. + */ +export const SET_SESSION_TIME_ZONE = `SET TIME ZONE '${SESSION_TIME_ZONE}'`; + +/** The startup option alone, for the side connections (pg-boss, locks). */ +export function timeZoneSessionOptions(): string | undefined { + return sessionOptionsDisabled() ? undefined : TIME_ZONE_OPTION; +} + +/** + * Pin the time zone on every new connection of `pool` by statement, when the + * startup options are off. pg queues the statement ahead of the first query + * the new client is handed out for, so no query runs before it. + */ +export function pinTimeZoneOnConnect(pool: Pool): void { + if (!sessionOptionsDisabled()) return; + pool.on("connect", (client: PoolClient) => { + client.query(SET_SESSION_TIME_ZONE).catch((err: unknown) => { + console.error("[db] Could not pin the session time zone:", err); + }); + }); +} diff --git a/src/lib/db.ts b/src/lib/db.ts index bfa815ae1..be64fb5b7 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,5 +1,19 @@ import { Prisma, PrismaClient } from "@/generated/prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; +import { Pool } from "pg"; +import { + pinTimeZoneOnConnect, + sessionOptionsDisabled, + TIME_ZONE_OPTION, +} from "@/lib/db-session"; + +export { + pinTimeZoneOnConnect, + SESSION_TIME_ZONE, + SET_SESSION_TIME_ZONE, + sessionOptionsDisabled, + timeZoneSessionOptions, +} from "@/lib/db-session"; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient | undefined; @@ -108,22 +122,6 @@ export function getWorkMem(): string { return raw && WORK_MEM_SHAPE.test(raw) ? raw : DEFAULT_WORK_MEM; } -/** - * Whether the app may send the libpq `options` startup parameter at all. - * - * PgBouncer refuses a connection that carries a startup parameter it does not - * know (`unsupported startup parameter: options`) unless the operator lists - * it in `ignore_startup_parameters`. Up to v1.41 such a host could set the - * statement timeout to 0, which dropped the whole string; v1.42 always sends - * `work_mem`, so that no longer worked. `DATABASE_SESSION_OPTIONS_DISABLED` - * (1, true or yes) sends none of the session settings; the pooler or the - * database's own defaults then apply. - */ -export function sessionOptionsDisabled(): boolean { - const raw = process.env.DATABASE_SESSION_OPTIONS_DISABLED?.trim(); - return raw !== undefined && /^(1|true|yes)$/i.test(raw); -} - /** * Build the libpq `options` startup string applying the session settings. * Passed straight through `PrismaPg` to the underlying `pg.Pool`, which @@ -143,16 +141,18 @@ export function buildSessionOptions(): string | undefined { timeoutMs > 0 ? `-c statement_timeout=${timeoutMs} -c idle_in_transaction_session_timeout=${timeoutMs} ` : ""; - return `${timeouts}-c work_mem=${getWorkMem()}`; + return `${timeouts}-c work_mem=${getWorkMem()} ${TIME_ZONE_OPTION}`; } function createPrismaClient() { - const adapter = new PrismaPg({ + const pool = new Pool({ connectionString: process.env.DATABASE_URL!, max: getPrismaPoolMax(), connectionTimeoutMillis: getPoolConnectionTimeoutMs(), options: buildSessionOptions(), }); + pinTimeZoneOnConnect(pool); + const adapter = new PrismaPg(pool, { disposeExternalPool: true }); return new PrismaClient({ adapter }); } diff --git a/src/lib/jobs/__tests__/measurement-maintenance-session.test.ts b/src/lib/jobs/__tests__/measurement-maintenance-session.test.ts index 93ba20002..213c9c5ed 100644 --- a/src/lib/jobs/__tests__/measurement-maintenance-session.test.ts +++ b/src/lib/jobs/__tests__/measurement-maintenance-session.test.ts @@ -26,6 +26,7 @@ describe("maintenance session settings behind a pooler", () => { "SET maintenance_work_mem = '128MB'", "SET max_parallel_maintenance_workers = '0'", "SET lock_timeout = '300000'", + "SET TimeZone = 'UTC'", ]); }); it("keeps maintenance in one process so it needs no /dev/shm segment", () => { diff --git a/src/lib/jobs/boss-instance.ts b/src/lib/jobs/boss-instance.ts index b807477d5..438e85818 100644 --- a/src/lib/jobs/boss-instance.ts +++ b/src/lib/jobs/boss-instance.ts @@ -8,6 +8,7 @@ import { PgBoss } from "pg-boss"; import { getPgBossPoolMax, getPoolConnectionTimeoutMs } from "@/lib/db"; +import { timeZoneSessionOptions } from "@/lib/db-session"; const BOSS_KEY = "__healthlog_pgboss__" as const; const BOSS_START_KEY = "__healthlog_pgboss_start__" as const; @@ -76,6 +77,9 @@ function databaseOptions(connectionString: string) { connectionString, max: getPgBossPoolMax(), connectionTimeoutMillis: getPoolConnectionTimeoutMs(), + // pg-boss keeps its own tables in `timestamptz`, so its queue math does + // not depend on the zone; the pin keeps every HealthLog session alike. + options: timeZoneSessionOptions(), } as const; } diff --git a/src/lib/jobs/job-lock.ts b/src/lib/jobs/job-lock.ts index 711fe471d..eb85441d7 100644 --- a/src/lib/jobs/job-lock.ts +++ b/src/lib/jobs/job-lock.ts @@ -19,6 +19,7 @@ * One extra connection per running locked job. */ import { Client } from "pg"; +import { timeZoneSessionOptions } from "@/lib/db-session"; import { caughtAs } from "@/lib/logging/signal"; /** What a guarded run came to. */ @@ -50,7 +51,11 @@ export async function withJobLock( connectionString: string | undefined = process.env.DATABASE_URL, options: JobLockOptions = {}, ): Promise> { - const client = new Client({ connectionString, keepAlive: true }); + const client = new Client({ + connectionString, + keepAlive: true, + options: timeZoneSessionOptions(), + }); // An idle client whose server goes away emits `error`; unhandled, that // takes the whole worker down. The run itself notices the database is // gone through its own queries. diff --git a/src/lib/jobs/measurement-maintenance.ts b/src/lib/jobs/measurement-maintenance.ts index 72a7c6a03..34eed50d9 100644 --- a/src/lib/jobs/measurement-maintenance.ts +++ b/src/lib/jobs/measurement-maintenance.ts @@ -77,7 +77,7 @@ export interface MeasurementMaintenancePayload { * whatever its container settings. */ export const MAINTENANCE_SESSION_OPTIONS = - "-c statement_timeout=0 -c idle_in_transaction_session_timeout=0 -c maintenance_work_mem=128MB -c max_parallel_maintenance_workers=0 -c lock_timeout=300000"; + "-c statement_timeout=0 -c idle_in_transaction_session_timeout=0 -c maintenance_work_mem=128MB -c max_parallel_maintenance_workers=0 -c lock_timeout=300000 -c TimeZone=UTC"; /** * The same settings as `SET` statements, for a host behind a connection From 42ba4717654db1c2c6b92e44fbba2e599c063ce9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 12:20:15 +0200 Subject: [PATCH 02/61] refactor(search): share the record matcher and give record candidates display data --- src/lib/mcp/__tests__/record-search.test.ts | 4 +- src/lib/mcp/record-search.ts | 412 ++++++----------- src/lib/mcp/tools.ts | 4 +- src/lib/search/match.ts | 469 ++++++++++++++++++++ 4 files changed, 617 insertions(+), 272 deletions(-) create mode 100644 src/lib/search/match.ts diff --git a/src/lib/mcp/__tests__/record-search.test.ts b/src/lib/mcp/__tests__/record-search.test.ts index a7b804078..4d05457da 100644 --- a/src/lib/mcp/__tests__/record-search.test.ts +++ b/src/lib/mcp/__tests__/record-search.test.ts @@ -151,13 +151,13 @@ import { USER_TEXT_FENCE_END, USER_TEXT_FENCE_START, } from "@/lib/ai/coach/data-fence"; +import { MAX_DOCUMENT_EXCERPT_CHARS } from "../record-search"; import { - MAX_DOCUMENT_EXCERPT_CHARS, MAX_RECORD_RESULTS, queryWords, rankCandidates, type SearchCandidate, -} from "../record-search"; +} from "@/lib/search/match"; import type { McpAuthContext } from "../auth"; const ctx = (userId: string): McpAuthContext => ({ diff --git a/src/lib/mcp/record-search.ts b/src/lib/mcp/record-search.ts index 38eaf0870..d41b3efe2 100644 --- a/src/lib/mcp/record-search.ts +++ b/src/lib/mcp/record-search.ts @@ -19,12 +19,9 @@ * on whole words of its text through the blind token index the vault search * uses. The document body itself is never read for a search. * - * Ranking. Every query word scores the best field it hits (a title, label, - * site, reason or practitioner counts more than a kind word or a side, which - * counts more than a note or an indexed-text hit). Results order by how many - * query words they matched, then by score, then by a fixed kind order, then - * newest first, then by id: the same query over the same record always - * returns the same list, which is what the offset cursor relies on. + * Ranking and folding live in `@/lib/search/match`, shared with the in-app + * search (`GET /api/search`), which reads its record kinds through + * `loadRecordCandidates` here as well: one matcher, one set of record reads. * * Module switches. A record kind whose module the account has off (or the * operator switched off) is not read at all: no row, no link, no count. @@ -55,9 +52,7 @@ import { decryptIndexText, decryptVerbatimText, hashQueryTokens, - tokenise, } from "@/lib/documents/content-index"; -import { expandQueryTokens } from "@/lib/documents/search-synonyms"; import { normalizeSearchText } from "@/lib/encounters/procedures"; import { encounterKindLabel } from "@/lib/encounters/kind-label"; import { getServerTranslator } from "@/lib/i18n/server-translator"; @@ -71,15 +66,18 @@ import { customLookupOf, resolveVaccineEntry, } from "@/lib/vaccinations/resolve-vaccine-entry"; +import { + MAX_QUERY_WORDS, + MIN_SUBSTRING_WORD, + WEIGHT, + queryWords, + type SearchCandidate, +} from "@/lib/search/match"; // ── Bounds ─────────────────────────────────────────────────────────── /** Rows one search decrypts and matches, per record kind. */ export const MAX_ROWS_PER_KIND = 500; -/** Query words that take part in matching; the rest are ignored. */ -export const MAX_QUERY_WORDS = 8; -/** Record results one search can return in total, across the four kinds. */ -export const MAX_RECORD_RESULTS = 50; /** Newest records per kind listed when the query is empty. */ export const EMPTY_QUERY_PER_KIND = 10; /** Characters of indexed document text one `fetch` returns. */ @@ -125,251 +123,8 @@ async function readableKinds(userId: string): Promise> { return out; } -// ── Matching and ranking (pure) ────────────────────────────────────── - -/** Field weights: what a query word hitting that field is worth. */ -export const WEIGHT = { primary: 3, secondary: 2, tertiary: 1 } as const; - -export interface SearchField { - text: string; - weight: number; -} - -export interface SearchCandidate { - id: string; - title: string; - url: string; - /** Tie-break order between kinds; lower sorts first. */ - kindOrder: number; - /** Epoch ms of the record's own date, newest first on a tie. */ - date: number | null; - fields: SearchField[]; - /** Query words the blind content index matched (documents only). */ - contentWords?: ReadonlySet; - /** True for the four record kinds; bounds `MAX_RECORD_RESULTS`. */ - record?: boolean; -} - -/** - * Words the index tokeniser keeps but a search box does not need. The - * tokeniser's own list already drops the common function words. - */ -const QUERY_STOPWORDS: ReadonlySet = new Set([ - "mein", - "meine", - "meinen", - "meinem", - "meiner", - "mine", - "our", - "all", - "any", - "some", - "last", - "latest", - "recent", - "letzte", - "letzten", - "letzter", - "letztes", - "show", - "find", - "list", - "get", -]); - -/** - * Body parts, German and English, for matching a body site or a label. A site - * is typed in the owner's language and the question often arrives in the - * assistant's, so "knee" has to find "Knie". Search-time only and local to - * this module; the document vault's synonym list is left as it is. Same - * normal form as the tokeniser: lower case, accents stripped. - */ -const BODY_PART_GROUPS: ReadonlyArray> = [ - ["knee", "knees", "knie", "kniegelenk"], - ["shoulder", "shoulders", "schulter", "schultergelenk"], - ["hip", "hips", "hufte", "huftgelenk"], - ["ankle", "sprunggelenk", "knochel"], - ["foot", "feet", "fuss"], - ["hand", "hands", "hande"], - ["wrist", "handgelenk"], - ["elbow", "ellbogen", "ellenbogen"], - ["back", "rucken", "wirbelsaule", "spine"], - ["neck", "nacken", "hals"], - ["head", "kopf"], - ["eye", "eyes", "auge", "augen"], - ["ear", "ears", "ohr", "ohren"], - ["tooth", "teeth", "zahn", "zahne"], - ["heart", "herz"], - ["lung", "lungs", "lunge"], - ["stomach", "magen"], - ["bowel", "colon", "darm"], - ["skin", "haut"], - ["arm", "arms", "arme"], - ["leg", "legs", "bein", "beine"], - ["finger", "fingers"], - ["toe", "toes", "zeh", "zehe", "zehen"], - ["breast", "brust"], - ["thyroid", "schilddruse"], -]; - -const BODY_PART_BY_WORD: ReadonlyMap> = (() => { - const map = new Map(); - for (const group of BODY_PART_GROUPS) { - for (const word of group) map.set(word, [...group]); - } - return map; -})(); - -/** A query word's alternatives: the vault's synonyms plus body parts. */ -function synonymsOf(word: string): string[] { - const out = new Set([ - ...expandQueryTokens([word]), - ...(BODY_PART_BY_WORD.get(word) ?? []), - ]); - out.delete(word); - return [...out]; -} - -/** - * The words of a query that take part in matching: folded, split, short and - * filler words dropped, deduplicated, capped. - */ -export function queryWords(query: string): string[] { - return tokenise(normalizeSearchText(query)) - .filter((word) => !QUERY_STOPWORDS.has(word)) - .slice(0, MAX_QUERY_WORDS); -} - -interface PreparedField { - folded: string; - tokens: ReadonlySet; - weight: number; -} - -function prepareField(field: SearchField): PreparedField { - const folded = normalizeSearchText(field.text); - return { - folded, - tokens: new Set(folded.split(/[^\p{L}\p{N}]+/u).filter(Boolean)), - weight: field.weight, - }; -} - -export interface CandidateScore { - /** Query words that hit anything. */ - matched: number; - /** Sum of each matched word's best field weight, plus the phrase bonus. */ - score: number; - /** The whole query appears as typed inside one field. */ - phrase: boolean; -} - -/** Bonus for the whole query appearing inside one field. */ -const PHRASE_BONUS = 5; - -/** - * Score one candidate against the query. - * - * A word hits a field when the field contains it (so "knee" finds "Knee - * replacement") or when one of its curated synonyms is a whole word of the - * field (so "Zucker" finds "glucose"). Synonyms match whole words only: they - * are wider than the word the person typed, and a substring match on them - * would find "rate" inside "moderate". - */ -/** Query words shorter than this match whole words or word starts only. */ -export const MIN_SUBSTRING_WORD = 4; - -/** - * Whether a query word hits a field by its own spelling. A word of four - * letters or more may sit anywhere ("knee" in "kneecap"); a shorter one has - * to start a word, so "arm" finds "Arm" and "Armpit" but not "Darm" or "warm". - */ -function hitsField(field: PreparedField, word: string): boolean { - if (word.length >= MIN_SUBSTRING_WORD) return field.folded.includes(word); - for (const token of field.tokens) if (token.startsWith(word)) return true; - return false; -} - -export function scoreCandidate( - candidate: Pick, - words: readonly string[], - foldedQuery: string, -): CandidateScore { - const fields = candidate.fields - .filter((f) => f.text.trim().length > 0) - .map(prepareField); - let matched = 0; - let score = 0; - for (const word of words) { - const synonyms = synonymsOf(word); - let best = 0; - for (const field of fields) { - if (field.weight <= best) continue; - if (hitsField(field, word) || synonyms.some((s) => field.tokens.has(s))) { - best = field.weight; - } - } - if (best < WEIGHT.tertiary && candidate.contentWords?.has(word)) { - best = WEIGHT.tertiary; - } - if (best > 0) { - matched += 1; - score += best; - } - } - const phrase = - foldedQuery.length > 0 && - fields.some((field) => - foldedQuery.includes(" ") - ? field.folded.includes(foldedQuery) - : hitsField(field, foldedQuery), - ); - if (phrase) score += PHRASE_BONUS; - return { matched, score, phrase }; -} - -/** - * Filter and order candidates for one query. Pure and deterministic. - * - * An empty query keeps every candidate. Otherwise a candidate stays when a - * query word hit it or the whole query appears in one of its fields (the - * substring match the older search kinds were built on, kept so a short - * query like "hr" still finds what it found before). - */ -export function rankCandidates( - candidates: readonly SearchCandidate[], - query: string, -): SearchCandidate[] { - const foldedQuery = normalizeSearchText(query); - const words = queryWords(query); - const scored = candidates.flatMap((candidate) => { - if (!foldedQuery) { - return [{ candidate, matched: 0, score: 0 }]; - } - const s = scoreCandidate(candidate, words, foldedQuery); - if (s.matched === 0 && !s.phrase) return []; - return [{ candidate, matched: s.matched, score: s.score }]; - }); - scored.sort( - (a, b) => - b.matched - a.matched || - b.score - a.score || - a.candidate.kindOrder - b.candidate.kindOrder || - (b.candidate.date ?? 0) - (a.candidate.date ?? 0) || - (a.candidate.id < b.candidate.id - ? -1 - : a.candidate.id > b.candidate.id - ? 1 - : 0), - ); - let records = 0; - return scored.flatMap(({ candidate }) => { - if (!candidate.record) return [candidate]; - records += 1; - return records <= MAX_RECORD_RESULTS ? [candidate] : []; - }); -} +// ── Matching and ranking ───────────────────────────────────────────── +// Shared with the in-app search; see `@/lib/search/match`. // ── Labels ─────────────────────────────────────────────────────────── @@ -500,19 +255,51 @@ const KIND_ORDER: Record = { interface Owner { locale: Locale; timezone: string; + /** The language of the in-app display data (`SearchCandidate.hit`). */ + display: Locale; } -async function loadOwner(userId: string): Promise { +async function loadOwner(userId: string, display?: Locale): Promise { const row = await prisma.user.findUnique({ where: { id: userId }, select: { locale: true, timezone: true }, }); + const locale = resolveLocale(row?.locale); return { - locale: resolveLocale(row?.locale), + locale, timezone: row?.timezone ?? DEFAULT_TIMEZONE, + display: display ?? locale, }; } +/** The in-app path of one record: `recordUrl` without an origin. */ +export function recordPath(kind: RecordKind, id: string): string { + return recordUrl("", kind, id); +} + +/** "left Knee" in the display language, or the site alone. */ +function sitePhrase( + site: string | null, + laterality: Laterality | null, + display: Locale, +): string | null { + if (!site) return null; + if (!laterality) return site; + const key = + laterality === "LEFT" + ? "encounters.laterality.left" + : laterality === "RIGHT" + ? "encounters.laterality.right" + : "encounters.laterality.both"; + return `${site} (${getServerTranslator(display).t(key)})`; +} + +function compact(values: ReadonlyArray): string[] { + return values + .map((v) => v?.replace(/\s+/g, " ").trim() ?? "") + .filter((v) => v.length > 0); +} + function visitTitle(args: { kind: EncounterKind; day: string; @@ -583,6 +370,8 @@ async function visitCandidates( const outcome = decryptText(row.outcomeEncrypted); const bodySite = decryptText(row.bodySiteEncrypted); const day = userDayKey(row.occurredAt, owner.timezone); + const kindLabel = encounterKindLabel(row.kind, owner.display); + const title = reason?.replace(/\s+/g, " ").trim() || null; return { id: `visit:${row.id}`, title: visitTitle({ @@ -597,9 +386,21 @@ async function visitCandidates( kindOrder: KIND_ORDER.visit, date: row.occurredAt.getTime(), record: true, + hit: { + kind: "visit", + recordId: row.id, + title: title ?? kindLabel, + context: compact([ + title ? kindLabel : null, + practitioner?.name, + sitePhrase(bodySite, row.laterality, owner.display), + ]), + day, + path: recordPath("visit", row.id), + }, fields: [ { text: bodySite ?? "", weight: WEIGHT.primary }, - { text: reason ?? "", weight: WEIGHT.primary }, + { text: reason ?? "", weight: WEIGHT.primary, body: true }, { text: practitioner?.name ?? "", weight: WEIGHT.primary }, { text: practitioner?.specialty ?? "", weight: WEIGHT.primary }, { @@ -616,7 +417,7 @@ async function visitCandidates( text: sideTerms(row.laterality, owner.locale) ?? "", weight: WEIGHT.secondary, }, - { text: outcome ?? "", weight: WEIGHT.secondary }, + { text: outcome ?? "", weight: WEIGHT.secondary, body: true }, ], }; }); @@ -696,6 +497,17 @@ async function conditionCandidates( kindOrder: KIND_ORDER.condition, date: row.onsetAt.getTime(), record: true, + hit: { + kind: "condition", + recordId: row.id, + title: row.label, + context: compact([ + illnessTypeLabel(row.type, owner.display), + sitePhrase(bodySite, row.laterality, owner.display), + ]), + day, + path: recordPath("condition", row.id), + }, fields: [ { text: row.label, weight: WEIGHT.primary }, { text: bodySite ?? "", weight: WEIGHT.primary }, @@ -736,6 +548,7 @@ const DOCUMENT_SELECT = { async function contentMatches( userId: string, words: readonly string[], + includeAiDeferred: boolean, ): Promise>> { const byDocument = new Map>(); await Promise.all( @@ -747,8 +560,12 @@ async function contentMatches( userId, searchTokens: { hasSome: hashes }, // A document held back from AI reading is not searched by its - // text over this wire either; its title and kind still are. - document: { deletedAt: null, aiReadDeferred: false }, + // text over MCP either; its title and kind still are. The person's + // own in-app search is not an AI surface and reads it. + document: { + deletedAt: null, + ...(includeAiDeferred ? {} : { aiReadDeferred: false }), + }, }, select: { documentId: true }, take: MAX_ROWS_PER_KIND, @@ -790,6 +607,7 @@ async function documentCandidates( take: number, query: string, words: readonly string[], + includeAiDeferred: boolean, ): Promise { const nameTerms = documentNameTerms(query, words); const [rows, content, named] = await Promise.all([ @@ -800,7 +618,7 @@ async function documentCandidates( take, }), words.length > 0 - ? contentMatches(userId, words) + ? contentMatches(userId, words, includeAiDeferred) : Promise.resolve(new Map>()), // Title and file name are plaintext columns, so an older document is // found by them in SQL, the way the vault list finds it. @@ -849,6 +667,22 @@ async function documentCandidates( kindOrder: KIND_ORDER.document, date: (dated ?? row.createdAt).getTime(), record: true, + hit: { + kind: "document", + recordId: row.id, + title: + row.title ?? + row.filename ?? + documentKindLabel(row.kind, owner.display), + context: compact([ + documentKindLabel(row.kind, owner.display), + row.title ? row.filename : null, + ]), + day: dated + ? dateOnly(dated) + : userDayKey(row.createdAt, owner.timezone), + path: recordPath("document", row.id), + }, contentWords: content.get(row.id), fields: [ { text: row.title ?? "", weight: WEIGHT.primary }, @@ -899,6 +733,19 @@ async function vaccinationCandidates( kindOrder: KIND_ORDER.vaccination, date: row.occurredAt.getTime(), record: true, + hit: { + kind: "vaccination", + recordId: row.id, + title: + row.vaccineName ?? + vaccineCatalogName(row.antigenSlug, owner.display) ?? + own?.name ?? + row.antigenSlug ?? + null, + context: compact([practitioner?.name]), + day, + path: recordPath("vaccination", row.id), + }, fields: [ { text: row.vaccineName ?? "", weight: WEIGHT.primary }, { @@ -927,29 +774,56 @@ async function vaccinationCandidates( }); } +export interface RecordSearchOptions { + /** + * The kinds to read. Defaults to the account's module switches; the in-app + * search passes its own set, narrowed by the module map and by the + * sections a delegate's grant covers. + */ + kinds?: ReadonlySet; + /** The language of `SearchCandidate.hit`; defaults to the owner's. */ + displayLocale?: Locale; + /** + * Match a document's indexed text even when it was held back from AI + * reading. Only the person's own in-app search sets it; MCP never does. + */ + includeAiDeferredText?: boolean; +} + /** * Every record candidate this account can see for one query. Unranked; the - * caller ranks them together with the older search kinds. + * caller ranks them together with its other kinds. */ export async function loadRecordCandidates( userId: string, query: string, origin: string, + options: RecordSearchOptions = {}, ): Promise { const words = queryWords(query); const empty = normalizeSearchText(query).length === 0; const take = empty ? EMPTY_QUERY_PER_KIND : MAX_ROWS_PER_KIND; const [kinds, owner] = await Promise.all([ - readableKinds(userId), - loadOwner(userId), + options.kinds ? Promise.resolve(options.kinds) : readableKinds(userId), + loadOwner(userId, options.displayLocale), ]); const groups = await Promise.all([ - visitCandidates(userId, owner, origin, take, empty), + kinds.has("visit") + ? visitCandidates(userId, owner, origin, take, empty) + : Promise.resolve([]), kinds.has("condition") ? conditionCandidates(userId, owner, origin, take) : Promise.resolve([]), kinds.has("document") - ? documentCandidates(userId, owner, origin, take, query, words) + ? documentCandidates( + userId, + owner, + origin, + take, + query, + words, + options.includeAiDeferredText === true, + ) : Promise.resolve([]), kinds.has("vaccination") ? vaccinationCandidates(userId, owner, origin, take) diff --git a/src/lib/mcp/tools.ts b/src/lib/mcp/tools.ts index c81f80a45..771b10be5 100644 --- a/src/lib/mcp/tools.ts +++ b/src/lib/mcp/tools.ts @@ -90,10 +90,12 @@ import { fetchRecord, isRecordKind, loadRecordCandidates, +} from "@/lib/mcp/record-search"; +import { rankCandidates, WEIGHT as SEARCH_WEIGHT, type SearchCandidate, -} from "@/lib/mcp/record-search"; +} from "@/lib/search/match"; import type { McpAuthContext } from "./auth"; import { dueSchedules } from "@/lib/medications/intake-tracking"; import { liveEraStartsByMedication } from "@/lib/medications/scheduling/live-era"; diff --git a/src/lib/search/match.ts b/src/lib/search/match.ts new file mode 100644 index 000000000..7e04cd7ea --- /dev/null +++ b/src/lib/search/match.ts @@ -0,0 +1,469 @@ +/** + * Matching and ranking for the record search, shared by the MCP `search` tool + * and the in-app search (`GET /api/search`). Pure: no database, no crypto. + * + * Folding. A field and a query are compared after the body-sites service's + * folding (`normalizeSearchText`: Unicode compatibility form, marks stripped, + * lower case, whitespace collapsed) plus the German spellings a keyboard + * without umlauts produces: "ß" reads as "ss", and "ae", "oe", "ue" read as + * "a", "o", "u". Both sides fold the same way, so "Schulter" finds + * "schulter", "Hüfte" finds "huefte" and "Fuß" finds "fuss". + * + * Ranking. Every query word scores the best field it hits (a title, label, + * site, reason or practitioner counts more than a kind word or a side, which + * counts more than a note or an indexed-text hit). Results order by how many + * query words they matched, then by score, then by a fixed kind order, then + * newest first, then by id: the same query over the same record always + * returns the same list, which is what the MCP offset cursor relies on. + */ +import { tokenise } from "@/lib/documents/content-index"; +import { expandQueryTokens } from "@/lib/documents/search-synonyms"; +import { normalizeSearchText } from "@/lib/encounters/procedures"; + +/** Query words that take part in matching; the rest are ignored. */ +export const MAX_QUERY_WORDS = 8; +/** Record results one MCP search can return in total, across its kinds. */ +export const MAX_RECORD_RESULTS = 50; + +/** Field weights: what a query word hitting that field is worth. */ +export const WEIGHT = { primary: 3, secondary: 2, tertiary: 1 } as const; + +export interface SearchField { + text: string; + weight: number; + /** + * Free text a person wrote (a note, a reason, an outcome). The in-app + * search cuts its snippet from the first such field a query word hits. + */ + body?: boolean; +} + +/** + * What the in-app search shows for a candidate. The MCP wire ignores it; it + * builds its own English title from the same row. + */ +export interface CandidateHit { + /** The group the hit is listed under. */ + kind: string; + /** The record's own id (without the `kind:` prefix the MCP ids carry). */ + recordId: string; + /** The record's own name, or null when the kind's label names it. */ + title: string | null; + /** Short context: a practitioner, a body site, a dose. Plain values. */ + context: string[]; + /** The record's calendar day, `YYYY-MM-DD`, or null. */ + day: string | null; + /** In-app path that opens the record. */ + path: string; + /** For a note: which kind of entry it was written on. */ + source?: string; +} + +export interface SearchCandidate { + id: string; + title: string; + url: string; + /** Tie-break order between kinds; lower sorts first. */ + kindOrder: number; + /** Epoch ms of the record's own date, newest first on a tie. */ + date: number | null; + fields: SearchField[]; + /** Query words the blind content index matched (documents only). */ + contentWords?: ReadonlySet; + /** True for the record kinds; bounds `MAX_RECORD_RESULTS`. */ + record?: boolean; + /** Display data for the in-app search. */ + hit?: CandidateHit; +} + +// ── Folding ────────────────────────────────────────────────────────── + +/** + * The German keyboard spellings, folded onto the plain vowel. Applied to text + * that `normalizeSearchText` already folded, so "ä" is "a" by now and only + * the typed digraph is left to fold. + */ +function foldGerman(folded: string): string { + return folded + .replace(/ß/g, "ss") + .replace(/ae/g, "a") + .replace(/oe/g, "o") + .replace(/ue/g, "u"); +} + +/** The form a field and a query are compared in. */ +export function foldForMatch(text: string): string { + return foldGerman(normalizeSearchText(text)); +} + +/** + * Words the index tokeniser keeps but a search box does not need. The + * tokeniser's own list already drops the common function words. + */ +const QUERY_STOPWORDS: ReadonlySet = new Set([ + "mein", + "meine", + "meinen", + "meinem", + "meiner", + "mine", + "our", + "all", + "any", + "some", + "last", + "latest", + "recent", + "letzte", + "letzten", + "letzter", + "letztes", + "show", + "find", + "list", + "get", +]); + +/** + * Body parts, German and English, for matching a body site or a label. A site + * is typed in the owner's language and the question often arrives in another, + * so "knee" has to find "Knie". Search-time only; the document vault's synonym + * list is left as it is. Same normal form as the tokeniser: lower case, + * accents stripped. + */ +const BODY_PART_GROUPS: ReadonlyArray> = [ + ["knee", "knees", "knie", "kniegelenk"], + ["shoulder", "shoulders", "schulter", "schultergelenk"], + ["hip", "hips", "hufte", "huftgelenk"], + ["ankle", "sprunggelenk", "knochel"], + ["foot", "feet", "fuss"], + ["hand", "hands", "hande"], + ["wrist", "handgelenk"], + ["elbow", "ellbogen", "ellenbogen"], + ["back", "rucken", "wirbelsaule", "spine"], + ["neck", "nacken", "hals"], + ["head", "kopf"], + ["eye", "eyes", "auge", "augen"], + ["ear", "ears", "ohr", "ohren"], + ["tooth", "teeth", "zahn", "zahne"], + ["heart", "herz"], + ["lung", "lungs", "lunge"], + ["stomach", "magen"], + ["bowel", "colon", "darm"], + ["skin", "haut"], + ["arm", "arms", "arme"], + ["leg", "legs", "bein", "beine"], + ["finger", "fingers"], + ["toe", "toes", "zeh", "zehe", "zehen"], + ["breast", "brust"], + ["thyroid", "schilddruse"], +]; + +const BODY_PART_BY_WORD: ReadonlyMap> = (() => { + const map = new Map(); + for (const group of BODY_PART_GROUPS) { + for (const word of group) map.set(word, [...group]); + } + return map; +})(); + +/** A query word's alternatives: the vault's synonyms plus body parts. */ +function synonymsOf(word: string): string[] { + const out = new Set( + [ + ...expandQueryTokens([word]), + ...(BODY_PART_BY_WORD.get(word) ?? + BODY_PART_BY_WORD.get(foldGerman(word)) ?? + []), + ].map(foldGerman), + ); + out.delete(foldGerman(word)); + return [...out]; +} + +/** + * The words of a query that take part in matching: folded, split, short and + * filler words dropped, deduplicated, capped. These are also the words the + * blind document index is asked for, so they keep the tokeniser's own form. + */ +export function queryWords(query: string): string[] { + return tokenise(normalizeSearchText(query)) + .filter((word) => !QUERY_STOPWORDS.has(word)) + .slice(0, MAX_QUERY_WORDS); +} + +interface PreparedField { + folded: string; + tokens: ReadonlySet; + weight: number; +} + +function prepareField(field: SearchField): PreparedField { + const folded = foldForMatch(field.text); + return { + folded, + tokens: new Set(folded.split(/[^\p{L}\p{N}]+/u).filter(Boolean)), + weight: field.weight, + }; +} + +export interface CandidateScore { + /** Query words that hit anything. */ + matched: number; + /** Sum of each matched word's best field weight, plus the phrase bonus. */ + score: number; + /** The whole query appears as typed inside one field. */ + phrase: boolean; +} + +/** Bonus for the whole query appearing inside one field. */ +const PHRASE_BONUS = 5; + +/** Query words shorter than this match whole words or word starts only. */ +export const MIN_SUBSTRING_WORD = 4; + +/** + * Whether a query word hits a field by its own spelling. A word of four + * letters or more may sit anywhere ("knee" in "kneecap"); a shorter one has + * to start a word, so "arm" finds "Arm" and "Armpit" but not "Darm" or "warm". + */ +function hitsField(field: PreparedField, word: string): boolean { + if (word.length >= MIN_SUBSTRING_WORD) return field.folded.includes(word); + for (const token of field.tokens) if (token.startsWith(word)) return true; + return false; +} + +/** + * Score one candidate against the query. + * + * A word hits a field when the field contains it (so "knee" finds "Knee + * replacement") or when one of its curated synonyms is a whole word of the + * field (so "Zucker" finds "glucose"). Synonyms match whole words only: they + * are wider than the word the person typed, and a substring match on them + * would find "rate" inside "moderate". + */ +export function scoreCandidate( + candidate: Pick, + words: readonly string[], + foldedQuery: string, +): CandidateScore { + const fields = candidate.fields + .filter((f) => f.text.trim().length > 0) + .map(prepareField); + const query = foldGerman(foldedQuery); + let matched = 0; + let score = 0; + for (const word of words) { + const folded = foldGerman(word); + const synonyms = synonymsOf(word); + let best = 0; + for (const field of fields) { + if (field.weight <= best) continue; + if ( + hitsField(field, folded) || + synonyms.some((s) => field.tokens.has(s)) + ) { + best = field.weight; + } + } + if (best < WEIGHT.tertiary && candidate.contentWords?.has(word)) { + best = WEIGHT.tertiary; + } + if (best > 0) { + matched += 1; + score += best; + } + } + const phrase = + query.length > 0 && + fields.some((field) => + query.includes(" ") + ? field.folded.includes(query) + : hitsField(field, query), + ); + if (phrase) score += PHRASE_BONUS; + return { matched, score, phrase }; +} + +/** Whether a scored candidate stays in the result at all. */ +export function isHit(score: CandidateScore): boolean { + return score.matched > 0 || score.phrase; +} + +/** + * Filter and order candidates for one query. Pure and deterministic. + * + * An empty query keeps every candidate. Otherwise a candidate stays when a + * query word hit it or the whole query appears in one of its fields (the + * substring match the older search kinds were built on, kept so a short + * query like "hr" still finds what it found before). + */ +export function rankCandidates( + candidates: readonly SearchCandidate[], + query: string, + maxRecords: number = MAX_RECORD_RESULTS, +): SearchCandidate[] { + const foldedQuery = normalizeSearchText(query); + const words = queryWords(query); + const scored = candidates.flatMap((candidate) => { + if (!foldedQuery) { + return [{ candidate, matched: 0, score: 0 }]; + } + const s = scoreCandidate(candidate, words, foldedQuery); + if (!isHit(s)) return []; + return [{ candidate, matched: s.matched, score: s.score }]; + }); + scored.sort( + (a, b) => + b.matched - a.matched || + b.score - a.score || + a.candidate.kindOrder - b.candidate.kindOrder || + (b.candidate.date ?? 0) - (a.candidate.date ?? 0) || + (a.candidate.id < b.candidate.id + ? -1 + : a.candidate.id > b.candidate.id + ? 1 + : 0), + ); + let records = 0; + return scored.flatMap(({ candidate }) => { + if (!candidate.record) return [candidate]; + records += 1; + return records <= maxRecords ? [candidate] : []; + }); +} + +// ── Highlighting and snippets ──────────────────────────────────────── + +/** A half-open `[start, end)` character range of the original text. */ +export type MarkRange = [number, number]; + +export interface MarkedText { + text: string; + /** Ranges of `text` a query word matched, sorted and disjoint. */ + marks: MarkRange[]; +} + +/** + * Fold `text` the way `foldForMatch` does, keeping for every folded character + * the range of the original it came from. The two have different lengths + * ("ß" becomes two characters, "ue" one), so a match found in the folded form + * is mapped back through this table. + */ +function foldWithMap(text: string): { + folded: string; + from: number[]; + to: number[]; +} { + const chars: string[] = []; + const from: number[] = []; + const to: number[] = []; + // Whether the vowel at that position has already taken in its "e". + const absorbed: boolean[] = []; + let index = 0; + for (const ch of text) { + const end = index + ch.length; + let piece = ch.normalize("NFKD").replace(/\p{M}/gu, "").toLowerCase(); + if (/\s/u.test(ch)) piece = " "; + piece = piece.replace(/ß/g, "ss"); + for (const out of piece) { + const prev = chars.length - 1; + // "ae", "oe", "ue" fold onto the vowel: the "e" widens the vowel's + // source range instead of adding a character. + if ( + out === "e" && + prev >= 0 && + /[aou]/.test(chars[prev]) && + !absorbed[prev] + ) { + to[prev] = end; + absorbed[prev] = true; + continue; + } + chars.push(out); + from.push(index); + to.push(end); + absorbed.push(false); + } + index = end; + } + return { folded: chars.join(""), from, to }; +} + +/** + * The ranges of `text` the query's words hit by their own spelling, under the + * same rules as the matcher: a word of four letters or more anywhere, a + * shorter one only at the start of a word. Synonym hits are not marked (the + * text holds a different word), so a result can match with nothing marked. + */ +export function markMatches(text: string, query: string): MarkRange[] { + const words = [ + ...new Set(queryWords(query).map((w) => foldGerman(w))), + ].filter(Boolean); + if (words.length === 0 || text.length === 0) return []; + const { folded, from, to } = foldWithMap(text); + const ranges: MarkRange[] = []; + for (const word of words) { + let at = folded.indexOf(word); + while (at !== -1) { + const startsWord = at === 0 || !/[\p{L}\p{N}]/u.test(folded[at - 1]); + if (word.length >= MIN_SUBSTRING_WORD || startsWord) { + ranges.push([from[at], to[at + word.length - 1]]); + } + at = folded.indexOf(word, at + 1); + } + } + ranges.sort((a, b) => a[0] - b[0] || b[1] - a[1]); + const merged: MarkRange[] = []; + for (const range of ranges) { + const last = merged[merged.length - 1]; + if (last && range[0] <= last[1]) last[1] = Math.max(last[1], range[1]); + else merged.push([range[0], range[1]]); + } + return merged; +} + +/** `text` with its marks for `query`. */ +export function marked(text: string, query: string): MarkedText { + return { text, marks: markMatches(text, query) }; +} + +/** Characters a snippet holds at most, before the ellipses. */ +export const SNIPPET_CHARS = 140; +/** Characters a snippet keeps ahead of its first mark. */ +const SNIPPET_LEAD = 40; + +/** + * A short window of free text around its first match, whitespace collapsed, + * cut at word boundaries and marked with "…" where it was cut. Null when the + * query hits nothing in the text by its own spelling. + */ +export function snippetOf( + raw: string, + query: string, + max: number = SNIPPET_CHARS, +): MarkedText | null { + const text = raw.replace(/\s+/g, " ").trim(); + const marks = markMatches(text, query); + if (marks.length === 0) return null; + if (text.length <= max) return { text, marks }; + let start = Math.max(0, marks[0][0] - SNIPPET_LEAD); + if (start > 0) { + const space = text.indexOf(" ", start); + if (space !== -1 && space < marks[0][0]) start = space + 1; + } + let end = Math.min(text.length, start + max); + if (end < text.length) { + const space = text.lastIndexOf(" ", end); + if (space > marks[0][1]) end = space; + } + const lead = start > 0 ? "…" : ""; + const tail = end < text.length ? "…" : ""; + const shift = lead.length - start; + return { + text: `${lead}${text.slice(start, end)}${tail}`, + marks: marks + .filter(([s, e]) => s >= start && e <= end) + .map(([s, e]) => [s + shift, e + shift] as MarkRange), + }; +} From f19dfc4572767925816288761e82f1d710e70088 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 12:21:51 +0200 Subject: [PATCH 03/61] feat(notifications): resolve a tap target per event and offer mood quick answers --- src/__tests__/session-surface-guard.test.ts | 8 + .../api/notifications/action/[token]/route.ts | 152 ++++++++++++++ src/components/mood/mood-list.tsx | 2 + src/lib/logging/redact.ts | 4 + src/lib/logging/types.ts | 3 +- src/lib/mood/create-from-telegram.ts | 15 +- src/lib/notifications/action-token.ts | 197 ++++++++++++++++++ src/lib/notifications/delivery-links.ts | 105 ++++++++++ src/lib/notifications/dispatcher.ts | 7 +- src/lib/notifications/senders/apns.ts | 9 + src/lib/notifications/senders/email.ts | 6 +- src/lib/notifications/senders/ntfy.ts | 60 +++++- src/lib/notifications/senders/telegram.ts | 34 ++- src/lib/notifications/senders/web-push.ts | 13 +- src/lib/notifications/senders/webhook.ts | 9 + src/lib/notifications/tap-target.ts | 154 ++++++++++++++ src/lib/notifications/types.ts | 24 +++ src/lib/openapi/routes/notifications.ts | 63 ++++++ src/lib/telegram.ts | 8 +- src/lib/validations/mood.ts | 2 + src/proxy.ts | 5 + 21 files changed, 859 insertions(+), 21 deletions(-) create mode 100644 src/app/api/notifications/action/[token]/route.ts create mode 100644 src/lib/notifications/action-token.ts create mode 100644 src/lib/notifications/delivery-links.ts create mode 100644 src/lib/notifications/tap-target.ts diff --git a/src/__tests__/session-surface-guard.test.ts b/src/__tests__/session-surface-guard.test.ts index b06ac7b19..ebf46edd7 100644 --- a/src/__tests__/session-surface-guard.test.ts +++ b/src/__tests__/session-surface-guard.test.ts @@ -271,6 +271,13 @@ describe("S2 — the URL-token authentication set is frozen", () => { // renders nothing about the profile before the preview answers. "app/auth/claim/page.tsx": "carries the handover token from the query string into the claim requests' body", + // v1.42.2 — notification quick answers. A notification app (ntfy's + // `http` action) calls it with no session, so the signed, user- and + // action-bound, short-lived token in the path is the credential. Rate + // limited before verification, single-use through the mood entry's unique + // key, and scrubbed from logs by `PATH_SECRET_PATHS`. + "app/api/notifications/action/[token]/route.ts": + "signed single-use quick-answer token from a notification action", }; /** @@ -304,6 +311,7 @@ describe("S2 — the URL-token authentication set is frozen", () => { expect(bySegment.length).toBeGreaterThan(0); expect(bySegment).toEqual([ "app/api/c/[token]/unlock/route.ts", + "app/api/notifications/action/[token]/route.ts", "app/api/whoop/webhook/[token]/route.ts", "app/api/withings/webhook/[token]/route.ts", "app/c/[token]/d/[id]/route.ts", diff --git a/src/app/api/notifications/action/[token]/route.ts b/src/app/api/notifications/action/[token]/route.ts new file mode 100644 index 000000000..bd6e22f0f --- /dev/null +++ b/src/app/api/notifications/action/[token]/route.ts @@ -0,0 +1,152 @@ +/** + * `POST /api/notifications/action/` — a quick answer tapped inside a + * notification. + * + * The caller is a notification app (ntfy's `http` action), not a browser + * with a session, so the signed path segment is the whole credential. What + * the token proves and how it is kept single-use is in + * `src/lib/notifications/action-token.ts`; this route only applies it: + * + * 1. Rate-limit per client address BEFORE looking at the token, so the + * route is no oracle for guessing one (the anonymous-surface limiter + * collapses a broken proxy trust chain into one tight bucket). + * 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 + * one: a repeat tap, or a different answer on the same reminder, is + * refused with 409 and writes nothing. + * + * 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 + * URL-credential inventory in `session-surface-guard.test.ts`. + */ +import { NextRequest } from "next/server"; + +import { Prisma } from "@/generated/prisma/client"; +import { apiHandler } from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +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 { + MOOD_QUICK_ACTIONS, + quickActionExternalId, + verifyActionToken, +} from "@/lib/notifications/action-token"; +import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; + +export const dynamic = "force-dynamic"; + +/** Generous for one person tapping, tight for anyone enumerating. */ +const RATE_LIMIT = 20; +const RATE_WINDOW_MS = 10 * 60 * 1000; + +interface RouteContext { + params: Promise<{ token: string }>; +} + +function isUniqueViolation(err: unknown): boolean { + return ( + err instanceof Prisma.PrismaClientKnownRequestError && err.code === "P2002" + ); +} + +export const POST = apiHandler( + async (request: NextRequest, context: RouteContext) => { + annotate({ action: { name: "notification.action.invoke" } }); + + const rl = await checkAuthSurfaceRateLimit( + request, + "notification-action", + RATE_LIMIT, + RATE_WINDOW_MS, + ); + if (!rl.allowed) { + const response = apiError("Too many requests", 429); + for (const [k, v] of Object.entries(rateLimitHeaders(rl))) { + response.headers.set(k, v); + } + return response; + } + + const { token } = await context.params; + const verdict = verifyActionToken(token); + if (!verdict.ok) { + annotate({ + action: { name: "notification.action.refused" }, + meta: { reason: verdict.reason }, + }); + return verdict.reason === "expired" + ? apiError("This answer has expired", 410, { + errorCode: "notification_action.expired", + }) + : apiError("Invalid action", 401, { + errorCode: "notification_action.invalid", + }); + } + + const user = await prisma.user.findUnique({ + where: { id: verdict.userId }, + select: { id: true, timezone: true }, + }); + if (!user) { + annotate({ + action: { name: "notification.action.refused" }, + meta: { reason: "unknown_user" }, + }); + return apiError("Invalid action", 401, { + errorCode: "notification_action.invalid", + }); + } + getEvent()?.setAuth({ + user_id: user.id, + auth_method: "notification_action_token", + }); + + if (!(await isModuleEnabled(user.id, "mood"))) { + annotate({ + action: { name: "notification.action.refused" }, + meta: { reason: "module_disabled" }, + }); + return apiError("Mood tracking is turned off", 403, { + errorCode: "notification_action.module_disabled", + }); + } + + const score = MOOD_QUICK_ACTIONS[verdict.action]; + let created: boolean; + try { + const result = await logTelegramMood({ + userId: user.id, + score, + tz: user.timezone, + externalId: quickActionExternalId(verdict.nonce), + source: "NOTIFICATION", + }); + created = result.created; + } catch (err) { + // Two taps raced past the existence check; the unique key let one in. + if (!isUniqueViolation(err)) 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", + }); + } + + annotate({ + action: { name: "notification.action.mood_logged" }, + meta: { action: verdict.action }, + }); + return apiSuccess({ logged: true, action: verdict.action }); + }, +); diff --git a/src/components/mood/mood-list.tsx b/src/components/mood/mood-list.tsx index d97fe4327..03602aad7 100644 --- a/src/components/mood/mood-list.tsx +++ b/src/components/mood/mood-list.tsx @@ -1395,6 +1395,8 @@ function formatMoodSource( return t("mood.sourceTelegram"); case "DAYLIO": return t("mood.sourceDaylio"); + case "NOTIFICATION": + return t("mood.sourceNotification"); default: return source; } diff --git a/src/lib/logging/redact.ts b/src/lib/logging/redact.ts index ef4bbf2ef..84f9ce9df 100644 --- a/src/lib/logging/redact.ts +++ b/src/lib/logging/redact.ts @@ -68,6 +68,10 @@ export const PATH_SECRET_PATHS: ReadonlyArray<{ // and its last segment is a route name. A full URL carrying the token is // still scrubbed, by the `hlp_` rule in `redactSecrets`. { prefix: "/claim/", pathStart: true }, + // v1.42.2 — notification quick answers (ntfy `http` actions). The signed + // single-use action token is the request's only credential and rides the + // trailing path segment of `/api/notifications/action/`. + { prefix: "/api/notifications/action/" }, ]; function redactPathSegments(input: string): string { diff --git a/src/lib/logging/types.ts b/src/lib/logging/types.ts index 8a5119cdb..086357230 100644 --- a/src/lib/logging/types.ts +++ b/src/lib/logging/types.ts @@ -59,7 +59,8 @@ export interface WideEvent { | "api_key" | "cron_secret" | "telegram_webhook" - | "webhook_secret"; + | "webhook_secret" + | "notification_action_token"; /** * v1.36.0 — the account whose record this request read or wrote, when it * is not the caller's own. `user_id` above always stays the ACTOR: the diff --git a/src/lib/mood/create-from-telegram.ts b/src/lib/mood/create-from-telegram.ts index e54e527eb..298ac79d4 100644 --- a/src/lib/mood/create-from-telegram.ts +++ b/src/lib/mood/create-from-telegram.ts @@ -7,8 +7,9 @@ * write so the two entry points agree on the canonical shape: the same * `getScoreForMood` / `moodDateKey` derivation, the same per-user `tz` * anchoring, the same cache invalidation + DAY-rollup recompute the route - * fires. `source` is pinned to "TELEGRAM" - * (already an accepted free-text source value, no enum migration needed). + * fires. `source` is "TELEGRAM" by default; a notification quick answer + * (`/api/notifications/action/`) writes the same shape under + * "NOTIFICATION". Both are free-text source values, no enum migration. * * The `userId` is NEVER taken from the Telegram payload — the caller * passes the id it resolved from the stored `telegramChatId` binding. @@ -25,6 +26,9 @@ import { invalidateUserMood } from "@/lib/cache/invalidate"; import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; import { getEvent } from "@/lib/logging/context"; +/** The quick-write sources that share this helper. */ +export type QuickMoodSource = "TELEGRAM" | "NOTIFICATION"; + export interface TelegramMoodResult { /** True when a new row was written; false when the idempotency key already had one. */ created: boolean; @@ -47,8 +51,11 @@ export async function logTelegramMood(input: { /** Stable per-tap id for Telegram redelivery dedup. */ externalId: string; note?: string | null; + /** Provenance of the quick write. Default "TELEGRAM". */ + source?: QuickMoodSource; client?: PrismaClient | Prisma.TransactionClient; }): Promise { + const source = input.source ?? "TELEGRAM"; const prisma = (input.client ?? defaultPrisma) as PrismaClient; const mood = MOOD_ENUM_BY_SCORE[input.score]; if (!mood) { @@ -63,7 +70,7 @@ export async function logTelegramMood(input: { where: { userId_source_externalId: { userId: input.userId, - source: "TELEGRAM", + source, externalId: input.externalId, }, }, @@ -88,7 +95,7 @@ export async function logTelegramMood(input: { // v1.23 — encrypt the note at rest; legacy plaintext column nulled. note: null, noteEncrypted: encryptNote(input.note ?? null), - source: "TELEGRAM", + source, externalId: input.externalId, moodLoggedAt, }, diff --git a/src/lib/notifications/action-token.ts b/src/lib/notifications/action-token.ts new file mode 100644 index 000000000..9870d248c --- /dev/null +++ b/src/lib/notifications/action-token.ts @@ -0,0 +1,197 @@ +/** + * Signed, short-lived, single-use tokens behind notification quick actions. + * + * A quick action is a button inside a notification ("Good", "Okay", "Bad" + * on the mood reminder) that the notification app calls directly, with no + * HealthLog session: ntfy's `http` action POSTs to a URL and nothing else. + * The URL therefore carries the whole credential, and this module is what + * makes that safe: + * + * - Bound. The token names the user and the one action it performs. A + * token minted for one user cannot act for another (the user id is inside + * the signed payload), and a token for "okay" cannot log "bad". + * - Signed. HMAC-SHA256 over the payload, under a subkey derived from + * `API_TOKEN_HMAC_KEY` with a fixed label, so the tag can never collide + * with an OAuth state or a Bearer-token hash made from the same root key. + * Compared with `timingSafeEqual`. + * - Short-lived. The expiry is inside the signed payload; a token is good + * for `ACTION_TOKEN_TTL_MS` after the reminder was sent. + * - Single-use. The three answers of one reminder share one nonce, and the + * write it authorises is keyed on that nonce (`MoodEntry`'s unique + * `(userId, source, externalId)`), so the first answer wins and every + * later tap on the same reminder, whichever button, is refused. The + * database constraint is the arbiter, so two concurrent taps cannot both + * write. + * + * Layout: `na1.....`. Every field is + * free of `.` by construction (a cuid, a fixed action id, digits, base64url), + * so the split is unambiguous, and every field is URL-path safe. + */ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; + +/** + * The mood quick answers and the five-point mood scale value each one logs + * (`MOOD_ENUM_BY_SCORE`: 4 = GUT, 3 = OKAY, 2 = SCHLECHT). Three answers, + * because ntfy shows at most three actions; the scale's outer points + * (1 and 5) stay in the app and in the Telegram keyboard, which shows all + * five. + */ +export const MOOD_QUICK_ACTIONS = { + "mood-good": 4, + "mood-okay": 3, + "mood-bad": 2, +} as const; + +export type QuickActionId = keyof typeof MOOD_QUICK_ACTIONS; + +export const QUICK_ACTION_IDS = Object.keys( + MOOD_QUICK_ACTIONS, +) as QuickActionId[]; + +/** + * Six hours: long enough to answer a reminder sent at the chosen evening + * hour before going to bed, short enough that a notification left on a + * phone overnight no longer writes anything the next day. + */ +export const ACTION_TOKEN_TTL_MS = 6 * 60 * 60 * 1000; + +const VERSION = "na1"; +const SEP = "."; +const MAX_TOKEN_LENGTH = 300; +const KEY_LABEL = "healthlog:notification-action:v1"; + +function signingKey(): Buffer { + const root = process.env.API_TOKEN_HMAC_KEY; + if (!root || root.length < 32) { + throw new Error( + "API_TOKEN_HMAC_KEY must be set (>=32 chars) to sign notification actions", + ); + } + return createHmac("sha256", root).update(KEY_LABEL).digest(); +} + +function sign(payload: string): string { + return createHmac("sha256", signingKey()).update(payload).digest("base64url"); +} + +function isQuickActionId(value: string): value is QuickActionId { + return Object.prototype.hasOwnProperty.call(MOOD_QUICK_ACTIONS, value); +} + +/** A fresh nonce shared by every action offered on one notification. */ +export function newActionNonce(): string { + return randomBytes(16).toString("base64url"); +} + +export function mintActionToken(input: { + userId: string; + action: QuickActionId; + nonce: string; + now?: number; +}): string { + const expiry = (input.now ?? Date.now()) + ACTION_TOKEN_TTL_MS; + const payload = [ + VERSION, + input.userId, + input.action, + String(expiry), + input.nonce, + ].join(SEP); + return `${payload}${SEP}${sign(payload)}`; +} + +export type ActionTokenVerdict = + | { + ok: true; + userId: string; + action: QuickActionId; + nonce: string; + expiry: number; + } + | { ok: false; reason: "malformed" | "bad_signature" | "expired" }; + +/** + * Verify a token. The signature is checked before the expiry, so an expired + * verdict is only ever given for a token this server actually minted; a + * forged or altered token reads as `bad_signature` whatever its expiry says. + * Never throws on bad input (only on a missing server key, which is an + * operator misconfiguration). + */ +export function verifyActionToken( + token: string | null | undefined, + now: number = Date.now(), +): ActionTokenVerdict { + if (!token || token.length > MAX_TOKEN_LENGTH) { + return { ok: false, reason: "malformed" }; + } + const parts = token.split(SEP); + if (parts.length !== 6) return { ok: false, reason: "malformed" }; + const [version, userId, action, expiryStr, nonce, tag] = parts as [ + string, + string, + string, + string, + string, + string, + ]; + if (version !== VERSION || !userId || !nonce || !tag) { + return { ok: false, reason: "malformed" }; + } + if (!isQuickActionId(action)) return { ok: false, reason: "malformed" }; + const expiry = Number.parseInt(expiryStr, 10); + if (!Number.isFinite(expiry) || String(expiry) !== expiryStr) { + return { ok: false, reason: "malformed" }; + } + + const expected = Buffer.from( + sign([version, userId, action, expiryStr, nonce].join(SEP)), + ); + const received = Buffer.from(tag); + if ( + received.length !== expected.length || + !timingSafeEqual(received, expected) + ) { + return { ok: false, reason: "bad_signature" }; + } + if (now >= expiry) return { ok: false, reason: "expired" }; + + return { ok: true, userId, action, nonce, expiry }; +} + +/** The `MoodEntry.externalId` that makes one reminder's answers single-use. */ +export function quickActionExternalId(nonce: string): string { + return `notification-action:${nonce}`; +} + +/** The public route a quick action calls. */ +export const QUICK_ACTION_ROUTE_PREFIX = "/api/notifications/action/"; + +export interface NotificationQuickAction { + id: QuickActionId; + label: string; + /** Absolute URL the notification app POSTs to. */ + url: string; +} + +/** + * The three mood answers for one reminder, sharing one nonce. Needs the + * public origin: an app on a phone can only call an absolute URL. + */ +export function buildMoodQuickActions(input: { + userId: string; + origin: string; + labels: Record; + now?: number; +}): NotificationQuickAction[] { + const nonce = newActionNonce(); + return QUICK_ACTION_IDS.map((id) => ({ + id, + label: input.labels[id], + url: `${input.origin}${QUICK_ACTION_ROUTE_PREFIX}${mintActionToken({ + userId: input.userId, + action: id, + nonce, + now: input.now, + })}`, + })); +} diff --git a/src/lib/notifications/delivery-links.ts b/src/lib/notifications/delivery-links.ts new file mode 100644 index 000000000..fb955d4c8 --- /dev/null +++ b/src/lib/notifications/delivery-links.ts @@ -0,0 +1,105 @@ +/** + * Attach the tap target and the quick actions to a delivery. + * + * Called by the dispatcher once per recipient, after the delivery identity + * is known and before the channel cascade, so every channel of one dispatch + * carries the same target and the same (single-use) action tokens. The + * senders only translate these fields into their own wire format; none of + * them decides where a notification leads. + */ +import { prisma } from "@/lib/db"; +import { defaultLocale, locales, type Locale } from "@/lib/i18n/config"; +import { getServerTranslator } from "@/lib/i18n/server-translator"; +import { getEvent } from "@/lib/logging/context"; +import { + buildMoodQuickActions, + type QuickActionId, +} from "@/lib/notifications/action-token"; +import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery"; +import { + absoluteAppUrl, + configuredAppOrigin, + resolveTapPath, +} from "@/lib/notifications/tap-target"; +import type { NotificationPayload } from "@/lib/notifications/types"; + +const QUICK_ACTION_LABEL_KEYS: Record = { + "mood-good": "notifications.quickActions.moodGood", + "mood-okay": "notifications.quickActions.moodOkay", + "mood-bad": "notifications.quickActions.moodBad", +}; + +async function recipientLocale(userId: string): Promise { + try { + const row = await prisma.user.findUnique({ + where: { id: userId }, + select: { locale: true }, + }); + const value = row?.locale; + if (value && (locales as readonly string[]).includes(value)) { + return value as Locale; + } + } catch { + // A failed read only costs the label its language. + } + return defaultLocale; +} + +/** + * Whether this delivery may offer the mood quick answers: a mood reminder + * delivered to the account it is about. A Guardian never gets a button that + * writes into a record they are only notified about. + */ +function offersMoodActions(payload: NotificationPayload): boolean { + return ( + payload.eventType === "MOOD_REMINDER" && + !payload.suppressActions && + !payload.discreet && + !isManagedGuardianDelivery(payload) + ); +} + +export async function withDeliveryLinks( + payload: NotificationPayload, + recipientUserId: string, + now: number = Date.now(), +): Promise { + const path = resolveTapPath(payload); + const moodActions = offersMoodActions(payload); + if (!path && !moodActions) return payload; + + const origin = configuredAppOrigin(); + const { t } = getServerTranslator(await recipientLocale(recipientUserId)); + + const next: NotificationPayload = { ...payload }; + if (path) { + next.tap = { + path, + url: absoluteAppUrl(path, origin), + label: t("notifications.links.open"), + }; + } + + if (moodActions && origin) { + try { + next.quickActions = buildMoodQuickActions({ + userId: recipientUserId, + origin, + labels: { + "mood-good": t(QUICK_ACTION_LABEL_KEYS["mood-good"]), + "mood-okay": t(QUICK_ACTION_LABEL_KEYS["mood-okay"]), + "mood-bad": t(QUICK_ACTION_LABEL_KEYS["mood-bad"]), + }, + now, + }); + } catch (err) { + // No signing key: the reminder still goes out, only without answers. + getEvent()?.addWarning( + `notification quick actions unavailable: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + return next; +} diff --git a/src/lib/notifications/dispatcher.ts b/src/lib/notifications/dispatcher.ts index e8ccc3f25..9edf7ad71 100644 --- a/src/lib/notifications/dispatcher.ts +++ b/src/lib/notifications/dispatcher.ts @@ -36,6 +36,7 @@ import { hasClientManagedApnsGate, } from "@/lib/notifications/client-managed-apns"; import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record"; +import { withDeliveryLinks } from "@/lib/notifications/delivery-links"; import { claimManagedGuardianEgressAuthorization, completeManagedGuardianEgressAuthorization, @@ -131,8 +132,10 @@ export async function dispatchNotification( recordUserId: delivery.recordUserId, recipientUserId: delivery.recipientUserId, }; - const recipientPayload = await renderForRecipient( - deliveryPayload, + // Tap target + quick actions, resolved once so every channel of this + // delivery carries the same target and the same single-use tokens. + const recipientPayload = await withDeliveryLinks( + await renderForRecipient(deliveryPayload, delivery.recipientUserId), delivery.recipientUserId, ); const channels = await prisma.notificationChannel.findMany({ diff --git a/src/lib/notifications/senders/apns.ts b/src/lib/notifications/senders/apns.ts index 118195217..8fb2e9ac3 100644 --- a/src/lib/notifications/senders/apns.ts +++ b/src/lib/notifications/senders/apns.ts @@ -33,6 +33,7 @@ import { recordPushAttemptForPayload } from "@/lib/notifications/senders/push-at import { plainPushText } from "@/lib/notifications/strip-emoji"; import { stripHtml } from "@/lib/notifications/strip-html"; import { isManagedGuardianDelivery } from "@/lib/notifications/managed-delivery"; +import type { NotificationTap } from "@/lib/notifications/types"; export interface ApnsPayload { /** APNs `aps.alert.title` + `aps.alert.body`. */ @@ -597,6 +598,7 @@ export async function sendViaApns( suppressActions?: boolean; recordUserId?: string; recipientUserId?: string; + tap?: NotificationTap; }, ): Promise { const config = loadApnsConfig(); @@ -751,6 +753,13 @@ export async function sendViaApns( ? stripCycleMetadata(payload.metadata) : payload.metadata, ), + // The resolved tap target (`tap-target.ts`): a same-origin path + // the app opens on tap. Overrides a producer's `metadata.url`, + // which the resolver already took into account. Never on a + // managed delivery or a discreet one (the resolver returns null). + ...(!managedDelivery && payload.tap?.path + ? { url: payload.tap.path } + : {}), }, threadId: routingEvent, // The iOS app registers a `UNNotificationCategory` per event-type diff --git a/src/lib/notifications/senders/email.ts b/src/lib/notifications/senders/email.ts index 22011e45e..703edfc4f 100644 --- a/src/lib/notifications/senders/email.ts +++ b/src/lib/notifications/senders/email.ts @@ -163,7 +163,11 @@ export async function sendViaEmail( // Plain text only — strip HTML + decorative emoji on routine reminders. const subject = plainPushText(payload.title, payload.eventType); - const text = plainPushText(stripHtml(payload.message), payload.eventType); + const body = plainPushText(stripHtml(payload.message), payload.eventType); + // The tap target as a plain link line (plain text body, no markup). + const text = payload.tap?.url + ? `${body}\n\n${payload.tap.label}: ${payload.tap.url}` + : body; try { await transport.transporter.sendMail({ diff --git a/src/lib/notifications/senders/ntfy.ts b/src/lib/notifications/senders/ntfy.ts index a2fe63401..13b1fab78 100644 --- a/src/lib/notifications/senders/ntfy.ts +++ b/src/lib/notifications/senders/ntfy.ts @@ -58,7 +58,7 @@ export async function sendViaNtfy( ? "reminder" : payload.eventType.toLowerCase().replace(/_/g, "-"); const headers: Record = { - Title: plainPushText(payload.title, payload.eventType), + 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 @@ -71,6 +71,16 @@ export async function sendViaNtfy( 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); @@ -187,3 +197,51 @@ 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() || "?"; +} + +/** + * The `Actions` header for the payload's quick answers, in ntfy's short + * format (https://docs.ntfy.sh/publish/#action-buttons): + * + * http, + } + /> +

{t("notifications.content.detail")}

+ {msg && ( +

+ {msg} +

+ )} + + ); +} diff --git a/src/components/settings/notifications-section.tsx b/src/components/settings/notifications-section.tsx index 09989f9da..b52b26676 100644 --- a/src/components/settings/notifications-section.tsx +++ b/src/components/settings/notifications-section.tsx @@ -1,13 +1,14 @@ "use client"; import Link from "next/link"; -import { Bell } from "lucide-react"; +import { Bell, Smartphone } from "lucide-react"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import { LowStockCard } from "@/components/settings/low-stock-card"; import { SectionHeading } from "@/components/ui/section-heading"; import { MoodReminderCard } from "@/components/settings/mood-reminder-card"; +import { NotificationContentCard } from "@/components/settings/notification-content-card"; import { Button } from "@/components/ui/button"; /** @@ -72,6 +73,18 @@ export function NotificationsSection() { ) : null} ) : null} + + {/* Group 2 — How notifications look on the phone. Not module-gated: + it applies to every notification the account receives. */} +
+ +
+ +
+
{/* Cross-links to the other two notification surfaces. At the END of the page: a screen that opens with two ways off it never says what it is for. */} diff --git a/src/lib/daily/daily-briefing-push.ts b/src/lib/daily/daily-briefing-push.ts index 003854f8d..5e9c2eea2 100644 --- a/src/lib/daily/daily-briefing-push.ts +++ b/src/lib/daily/daily-briefing-push.ts @@ -16,7 +16,8 @@ * `ok` ledger row; the frequency-cap check suppresses the second — one push per * user per local day, no "you haven't opened the app" second push, ever. * - * Privacy + calm posture (load-bearing): the body is `digest.line`, which + * Privacy + calm posture (load-bearing): the title carries the score only + * when the user chose detailed notifications; the body is `digest.line`, which * carries no number by construction (a briefing lead sentence or the top * signal's headline without a figure in it, "your score is ready", or the * honest all-clear — never a reading, a dose or the score itself, since the @@ -39,6 +40,8 @@ import { dispatchNotification } from "@/lib/notifications/dispatcher"; import { loadDailyDigest } from "@/lib/daily/load-digest"; import type { DailyDigest } from "@/lib/daily/digest"; import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item"; +import { SCORE_BANDS } from "@/lib/analytics/score/record"; +import { notificationsShowFigures } from "@/lib/validations/notification-prefs"; /** * The local-morning window this nudge may fire in, [earliest, latest). Both the @@ -115,14 +118,37 @@ export function digestHasSubstance(digest: DailyDigest): boolean { * still provisional (last night's sleep not yet in), the honest sleep-pending * wording is appended so the read is never silently presented as final. */ +function isScoreBand(band: string): band is (typeof SCORE_BANDS)[number] { + return (SCORE_BANDS as readonly string[]).includes(band); +} + export function buildDailyBriefingPush( digest: DailyDigest, t: ServerTranslator["t"], + options: { showFigures?: boolean } = {}, ): { title: string; body: string } { - const title = t("daily.push.title"); + // The score rides the title only when the user chose detailed + // notifications (`notificationPrefs.content.detail`); the default keeps the + // lock screen free of figures. The body stays `digest.line`, number-free by + // construction, so it reads as the one-line label under the score. + const title = + options.showFigures && digest.score + ? t("daily.push.titleScore", { + score: String(Math.round(digest.score.value)), + }) + : t("daily.push.title"); + // With the score in the title, the floor line "your score is ready" only + // repeats it; the band says something instead. + const line = + options.showFigures && + digest.score && + isScoreBand(digest.score.band) && + digest.line === t("daily.line.scoreReady") + ? t(`daily.push.band.${digest.score.band}`) + : digest.line; const body = digest.sleepPending - ? t("daily.push.bodyProvisional", { line: digest.line }) - : digest.line; + ? t("daily.push.bodyProvisional", { line }) + : line; return { title, body }; } @@ -217,7 +243,9 @@ export async function maybeDispatchDailyBriefing( } const { t } = getServerTranslator(locale); - const { title, body } = buildDailyBriefingPush(digest, t); + const { title, body } = buildDailyBriefingPush(digest, t, { + showFigures: notificationsShowFigures(user.notificationPrefs), + }); const outcome = await dispatch({ // NON-URGENT by construction: no `urgent` flag, and DAILY_BRIEFING is not diff --git a/src/lib/geo.ts b/src/lib/geo.ts index b8c647e14..fcae66f66 100644 --- a/src/lib/geo.ts +++ b/src/lib/geo.ts @@ -427,7 +427,10 @@ async function evaluateOfflineGeoNotice(): Promise { host: resolveGeoProviderHost(), docsUrl: GEOLITE2_DOCS_URL, }, - metadata: { source: "geo-offline-detection" }, + metadata: { + source: "geo-offline-detection", + url: "/admin/system-status", + }, }); } } catch (err) { diff --git a/src/lib/illness/red-flag-notify.ts b/src/lib/illness/red-flag-notify.ts index 19ff68069..4558826df 100644 --- a/src/lib/illness/red-flag-notify.ts +++ b/src/lib/illness/red-flag-notify.ts @@ -80,6 +80,8 @@ export async function notifyIllnessRedFlag(input: { messageKey, eventType: "SYSTEM_ALERT", urgent: true, + // Tap target: the illness episode the red flag belongs to. + metadata: { url: `/illness/${encodeURIComponent(episodeId)}` }, }); getEvent()?.addMeta("illness_red_flag_notified", episodeId); diff --git a/src/lib/illness/safety-floor-notify.ts b/src/lib/illness/safety-floor-notify.ts index 532d1bafd..61517210e 100644 --- a/src/lib/illness/safety-floor-notify.ts +++ b/src/lib/illness/safety-floor-notify.ts @@ -133,6 +133,8 @@ export async function notifySafetyFloor(input: { eventType: "SYSTEM_ALERT", managedFanoutEvent: "SAFETY_FLOOR_ALERT", urgent: true, + // Tap target: the readings the alert is about. + metadata: { url: "/measurements" }, }); getEvent()?.addMeta("safety_floor_notified", decision.reason); diff --git a/src/lib/integrations/__tests__/status.test.ts b/src/lib/integrations/__tests__/status.test.ts index dc672b92f..88eb2b38d 100644 --- a/src/lib/integrations/__tests__/status.test.ts +++ b/src/lib/integrations/__tests__/status.test.ts @@ -209,6 +209,8 @@ describe("recordSyncFailure — at threshold", () => { affectedUserId: "u1", consecutiveFailures: 3, errorCode: "503", + // The affected user is not the admin, so the tap opens the admin page. + url: "/admin/integrations", }); // alertedAt is stamped to gate re-alerting for 24h. diff --git a/src/lib/integrations/status.ts b/src/lib/integrations/status.ts index 96e730cb5..d00fbdad5 100644 --- a/src/lib/integrations/status.ts +++ b/src/lib/integrations/status.ts @@ -1277,7 +1277,15 @@ async function maybeAlertAdmins(input: AlertInput): Promise { userId: admin.id, title: payload.title, message: payload.message, - metadata: payload.metadata, + metadata: { + ...payload.metadata, + // Tap target: an admin whose own sync fails fixes it in their own + // integration settings; anyone else's shows on the admin page. + url: + admin.id === input.userId + ? "/settings/integrations" + : "/admin/integrations", + }, }); outcome.dispatched ||= adminOutcome.dispatched; outcome.channelsAttempted += adminOutcome.channelsAttempted; diff --git a/src/lib/jobs/tls-pin-monitor.ts b/src/lib/jobs/tls-pin-monitor.ts index 095fe8334..3c1c5e394 100644 --- a/src/lib/jobs/tls-pin-monitor.ts +++ b/src/lib/jobs/tls-pin-monitor.ts @@ -95,6 +95,7 @@ async function alertAdminsOfPinChange( }, metadata: { source: "tls-pin-monitor", + url: "/admin/system-status", host, servedPin: probe.pin, knownPins: knownPins.join(","), diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index 12d7da622..e249366d4 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -335,6 +335,13 @@ const notificationPrefsResolved = z .enum(["weekly", "biweekly"]) .describe("Nudge frequency cap: one per 7 or per 14 days."), }), + content: z.object({ + detail: z + .enum(["generic", "detailed"]) + .describe( + 'What a notification may show. "generic" (default) names what it is about with no health figure; "detailed" adds figures such as the morning score. Tap targets and quick answers are the same in both.', + ), + }), }) .meta({ id: "NotificationPrefs", diff --git a/src/lib/validations/notification-prefs.ts b/src/lib/validations/notification-prefs.ts index af346cd24..ef800422d 100644 --- a/src/lib/validations/notification-prefs.ts +++ b/src/lib/validations/notification-prefs.ts @@ -11,7 +11,8 @@ * { * "medication": { "clientManaged": boolean }, * "mood": { "reminderHour": 0..23 }, - * "cycle": { "clientManaged": boolean } + * "cycle": { "clientManaged": boolean }, + * "content": { "detail": "generic" | "detailed" } * } * * The schema is intentionally additive — every new category grows the @@ -139,6 +140,23 @@ const coachPrefsSchema = z }) .partial(); +/** + * v1.42.2 — what a notification may show. `generic` (the default) says what + * the notification is about and carries no health figure, so a lock screen + * reads "Your morning briefing" rather than a score; `detailed` adds the + * figures (the morning score). Tap targets and quick answers are the same in + * both. Read by the producers that have a figure to show. + */ +export const NOTIFICATION_CONTENT_DETAILS = ["generic", "detailed"] as const; +export type NotificationContentDetail = + (typeof NOTIFICATION_CONTENT_DETAILS)[number]; + +const contentPrefsSchema = z + .object({ + detail: z.enum(NOTIFICATION_CONTENT_DETAILS), + }) + .partial(); + /** * v1.7.0 — per-device delivery override schema for the device PATCH. * NULL clears the override (the device inherits the user-level roaming @@ -159,6 +177,7 @@ export const notificationPrefsSchema = z cycle: cyclePrefsSchema, coach: coachPrefsSchema, measurementReminder: measurementReminderPrefsSchema, + content: contentPrefsSchema, }) .partial(); @@ -225,6 +244,10 @@ export interface NotificationPrefs { */ clientManaged: boolean; }; + content: { + /** v1.42.2 — whether notifications may carry health figures. */ + detail: NotificationContentDetail; + }; } /** @@ -277,6 +300,9 @@ export const DEFAULT_NOTIFICATION_PREFS: NotificationPrefs = { measurementReminder: { clientManaged: false, }, + content: { + detail: "generic", + }, }; /** @@ -325,6 +351,10 @@ export function resolveNotificationPrefs( ...base.measurementReminder, ...(incoming.measurementReminder ?? {}), }, + content: { + ...base.content, + ...(incoming.content ?? {}), + }, }); } @@ -354,6 +384,7 @@ function applyDeliveryDefaultMapping( cycle: { ...prefs.cycle }, coach: { ...prefs.coach }, measurementReminder: { ...prefs.measurementReminder }, + content: { ...prefs.content }, }; } @@ -492,6 +523,7 @@ function cloneDefaults(): NotificationPrefs { measurementReminder: { ...DEFAULT_NOTIFICATION_PREFS.measurementReminder, }, + content: { ...DEFAULT_NOTIFICATION_PREFS.content }, }; } @@ -517,5 +549,17 @@ function mergeOverDefaults(input: NotificationPrefsInput): NotificationPrefs { ...DEFAULT_NOTIFICATION_PREFS.measurementReminder, ...(input.measurementReminder ?? {}), }, + content: { + ...DEFAULT_NOTIFICATION_PREFS.content, + ...(input.content ?? {}), + }, }); } + +/** + * v1.42.2 — producer-side helper. Whether this user's notifications may carry + * health figures. A null / drifted row resolves to the default (`generic`). + */ +export function notificationsShowFigures(raw: unknown): boolean { + return parseNotificationPrefs(raw).content.detail === "detailed"; +} From 9b86ec2e10a068c6543faa3f532d7ee6ad4f28f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 12:29:41 +0200 Subject: [PATCH 06/61] test(search): cover the matcher, the scan bounds and the route --- src/app/api/search/__tests__/route.test.ts | 176 ++++++++++++++++++ src/lib/search/__tests__/match.test.ts | 141 ++++++++++++++ .../search/__tests__/search-records.test.ts | 121 ++++++++++++ src/lib/search/match.ts | 43 ++++- 4 files changed, 471 insertions(+), 10 deletions(-) create mode 100644 src/app/api/search/__tests__/route.test.ts create mode 100644 src/lib/search/__tests__/match.test.ts create mode 100644 src/lib/search/__tests__/search-records.test.ts diff --git a/src/app/api/search/__tests__/route.test.ts b/src/app/api/search/__tests__/route.test.ts new file mode 100644 index 000000000..82e3ae25f --- /dev/null +++ b/src/app/api/search/__tests__/route.test.ts @@ -0,0 +1,176 @@ +/** + * `GET /api/search` — admission, validation, the rate limit, and what the + * route hands the search and the log. + * + * The search itself is `searchRecords`, covered by its own unit tests and by + * `tests/integration/record-search.test.ts` against Postgres; here it is a + * spy, so the test can assert exactly what the route passes it. + * + * Mutation checks (each run, each seen red): + * - admit on `("read", "profile")` instead of the whole record → "admits + * the whole record only" goes red; + * - drop `.min(SEARCH_QUERY_MIN)` → "refuses a one-letter query" goes red; + * - put the query into the annotation → "never logs the query" goes red; + * - pass `actor.id` as the record → "searches the record the session acts + * for" goes red. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +const mocks = vi.hoisted(() => ({ + requireRecordAuth: vi.fn(), + checkRateLimit: vi.fn(), + searchRecords: vi.fn(), + annotate: vi.fn(), + resolveModuleMap: vi.fn(), + domainVisible: vi.fn(() => true), +})); + +vi.mock("@/lib/api-handler", () => ({ + apiHandler: (handler: unknown) => handler, + requireRecordAuth: mocks.requireRecordAuth, +})); +vi.mock("@/lib/logging/context", () => ({ + annotate: mocks.annotate, + getEvent: () => undefined, +})); +vi.mock("@/lib/db", () => ({ prisma: {} })); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: mocks.checkRateLimit })); +vi.mock("@/lib/modules/gate", () => ({ + resolveModuleMap: mocks.resolveModuleMap, +})); +vi.mock("@/lib/sharing/acting-domains", () => ({ + actingDomainVisibility: vi.fn(async () => mocks.domainVisible), +})); +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn(async () => "Europe/Berlin"), +})); +vi.mock("@/lib/search/search-records", () => ({ + searchRecords: mocks.searchRecords, +})); + +import { GET } from "../route"; + +const handler = GET as unknown as (req: NextRequest) => Promise; + +function request(query: string): NextRequest { + return new NextRequest(`http://localhost/api/search?${query}`); +} + +const OWNER = { id: "owner-1", locale: "de" }; +const ACTOR = { id: "actor-1", locale: "en" }; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.requireRecordAuth.mockResolvedValue({ + user: OWNER, + actor: ACTOR, + grantId: "grant-1", + }); + mocks.checkRateLimit.mockResolvedValue({ allowed: true }); + mocks.resolveModuleMap.mockResolvedValue({ illness: false }); + mocks.searchRecords.mockResolvedValue({ + groups: [ + { + kind: "visit", + hits: [ + { + id: "visit:v1", + kind: "visit", + title: { text: "Schulter", marks: [[0, 8]] }, + context: [], + snippet: null, + day: "2026-01-02", + path: "/checkups?visit=v1", + source: null, + }, + ], + more: false, + }, + ], + truncated: false, + stats: { sources: 12, rows: 40, candidates: 1, elapsedMs: 3 }, + }); +}); + +describe("GET /api/search", () => { + it("admits the whole record only, at read", async () => { + await handler(request("q=schulter")); + expect(mocks.requireRecordAuth).toHaveBeenCalledWith("read", "record"); + }); + + it("propagates a refused admission without searching", async () => { + mocks.requireRecordAuth.mockRejectedValueOnce(new Error("denied")); + await expect(handler(request("q=schulter"))).rejects.toThrow("denied"); + expect(mocks.searchRecords).not.toHaveBeenCalled(); + }); + + it("searches the record the session acts for, with its masks", async () => { + const res = await handler(request("q=Schulter&limit=7")); + expect(res.status).toBe(200); + expect(mocks.searchRecords).toHaveBeenCalledTimes(1); + const args = mocks.searchRecords.mock.calls[0][0]; + expect(args.recordId).toBe("owner-1"); + expect(args.query).toBe("Schulter"); + expect(args.limit).toBe(7); + expect(args.modules).toEqual({ illness: false }); + expect(args.domainVisible).toBe(mocks.domainVisible); + expect(args.timezone).toBe("Europe/Berlin"); + // Labels follow the reader, not the record. + expect(args.displayLocale).toBe("en"); + const body = await res.json(); + expect(body.data.groups[0].hits[0].path).toBe("/checkups?visit=v1"); + expect(body.data.truncated).toBe(false); + expect(body.data.stats).toBeUndefined(); + }); + + it("defaults the limit", async () => { + await handler(request("q=knie")); + expect(mocks.searchRecords.mock.calls[0][0].limit).toBe(5); + }); + + it.each([ + ["a one-letter query", "q=a"], + ["a blank query", "q=%20%20%20"], + ["a missing query", "limit=5"], + ["an over-long query", `q=${"x".repeat(101)}`], + ["a zero limit", "q=knie&limit=0"], + ["a limit above the cap", "q=knie&limit=21"], + ["a non-numeric limit", "q=knie&limit=lots"], + ])("refuses %s with 422 and never searches", async (_label, query) => { + const res = await handler(request(query)); + expect(res.status).toBe(422); + expect(mocks.searchRecords).not.toHaveBeenCalled(); + }); + + it("does not echo a refused query", async () => { + const secret = "Psychotherapie".repeat(8); + const res = await handler(request(`q=${secret}`)); + expect(res.status).toBe(422); + expect(await res.text()).not.toContain("Psychotherapie"); + }); + + it("rate limits per actor before reading anything", async () => { + mocks.checkRateLimit.mockResolvedValueOnce({ allowed: false }); + const res = await handler(request("q=schulter")); + expect(res.status).toBe(429); + expect(mocks.checkRateLimit.mock.calls[0][0]).toBe("search:actor-1"); + expect(mocks.searchRecords).not.toHaveBeenCalled(); + }); + + it("never logs the query, only its length and the counts", async () => { + await handler(request("q=Psychotherapie")); + const logged = JSON.stringify(mocks.annotate.mock.calls); + expect(logged).not.toMatch(/psychotherapie/i); + const run = mocks.annotate.mock.calls.find( + ([e]) => e.action.name === "search.query.run", + ); + expect(run?.[0].meta).toMatchObject({ + query_length: 14, + hits: 1, + groups: 1, + truncated: false, + delegated: true, + }); + }); +}); diff --git a/src/lib/search/__tests__/match.test.ts b/src/lib/search/__tests__/match.test.ts new file mode 100644 index 000000000..d70fcc870 --- /dev/null +++ b/src/lib/search/__tests__/match.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; + +import { + foldForMatch, + isHit, + markMatches, + scoreForQuery, + queryWords, + scoreCandidate, + snippetOf, + WEIGHT, + type SearchCandidate, +} from "../match"; + +function candidate( + fields: SearchCandidate["fields"], +): Pick { + return { fields }; +} + +const hit = (text: string, query: string, weight: number = WEIGHT.primary) => + isHit(scoreForQuery(candidate([{ text, weight }]), query)); + +/** The marked substrings, for readable assertions. */ +const marks = (text: string, query: string) => + markMatches(text, query).map(([s, e]) => text.slice(s, e)); + +describe("folding", () => { + it("ignores case, accents and the German keyboard spellings", () => { + expect(foldForMatch("Schulter")).toBe("schulter"); + expect(foldForMatch("Hüfte")).toBe(foldForMatch("Huefte")); + expect(foldForMatch("Fuß")).toBe(foldForMatch("Fuss")); + expect(foldForMatch("Müdigkeit")).toBe("mudigkeit"); + expect(foldForMatch(" FSME Impfung ")).toBe("fsme impfung"); + }); + + it("matches a term whatever way either side spells the umlaut", () => { + expect(hit("Schulter links", "schulter")).toBe(true); + expect(hit("schulter links", "SCHULTER")).toBe(true); + expect(hit("Hüftschmerzen", "hueft")).toBe(true); + expect(hit("Hueftschmerzen", "hüft")).toBe(true); + expect(hit("Fußgelenk", "fuss")).toBe(true); + expect(hit("Schläfrig", "schlaefrig")).toBe(true); + }); + + it("does not match what is not there", () => { + expect(hit("Knie rechts", "schulter")).toBe(false); + // A short word must start a word: "arm" is not inside "Darm". + expect(hit("Darmspiegelung", "arm")).toBe(false); + expect(hit("Armbruch", "arm")).toBe(true); + }); + + it("finds a body part across languages", () => { + expect(hit("Schulter", "shoulder")).toBe(true); + expect(hit("left shoulder", "Schulter")).toBe(true); + }); +}); + +describe("queryWords", () => { + it("drops filler and keeps the tokeniser's form", () => { + expect(queryWords("meine letzte Schulter OP")).toEqual(["schulter"]); + }); +}); + +describe("marks", () => { + it("marks the original characters, not the folded ones", () => { + expect(marks("Hüftgelenk und Hüfte", "hueft")).toEqual(["Hüft", "Hüft"]); + expect(marks("Fußgelenk", "fuss")).toEqual(["Fuß"]); + expect(marks("Huefte", "hüfte")).toEqual(["Huefte"]); + expect(marks("SCHULTER links", "schulter")).toEqual(["SCHULTER"]); + }); + + it("marks a short word only at a word start", () => { + expect(marks("Arm und Darm", "arm")).toEqual(["Arm"]); + }); + + it("marks a query too short for the tokeniser by itself", () => { + expect(marks("Hb und HbA1c", "hb")).toEqual(["Hb", "Hb"]); + }); + + it("merges overlapping marks", () => { + expect(markMatches("Schulterschmerz", "schulter schulterschmerz")).toEqual([ + [0, 15], + ]); + }); + + it("keeps ranges inside the text for multi-unit characters", () => { + const text = "😀 Schulter"; + const [[start, end]] = markMatches(text, "schulter"); + expect(text.slice(start, end)).toBe("Schulter"); + }); +}); + +describe("snippetOf", () => { + it("returns the whole short text with its marks", () => { + const s = snippetOf("Schmerzen in der Schulter", "schulter"); + expect(s?.text).toBe("Schmerzen in der Schulter"); + expect(s?.marks).toEqual([[17, 25]]); + }); + + it("cuts a long text around the first match at word boundaries", () => { + const filler = "Lorem ipsum dolor sit amet ".repeat(20); + const s = snippetOf(`${filler}die Schulter tut weh ${filler}`, "schulter"); + expect(s).not.toBeNull(); + expect(s!.text.startsWith("…")).toBe(true); + expect(s!.text.endsWith("…")).toBe(true); + expect(s!.text.length).toBeLessThanOrEqual(142); + const [[start, end]] = s!.marks; + expect(s!.text.slice(start, end)).toBe("Schulter"); + }); + + it("is null when the text does not contain the query", () => { + expect(snippetOf("Knie", "schulter")).toBeNull(); + }); +}); + +describe("ranking inputs", () => { + it("a title hit scores above a note hit", () => { + const title = scoreForQuery( + candidate([{ text: "Schulter", weight: WEIGHT.primary }]), + "schulter", + ); + const note = scoreForQuery( + candidate([{ text: "Schulter", weight: WEIGHT.tertiary, body: true }]), + "schulter", + ); + expect(title.score).toBeGreaterThan(note.score); + }); + + it("counts each matched word once", () => { + const s = scoreCandidate( + candidate([ + { text: "Schulter links", weight: WEIGHT.primary }, + { text: "links", weight: WEIGHT.secondary }, + ]), + ["schulter", "links"], + "schulter links", + ); + expect(s.matched).toBe(2); + }); +}); diff --git a/src/lib/search/__tests__/search-records.test.ts b/src/lib/search/__tests__/search-records.test.ts new file mode 100644 index 000000000..297838958 --- /dev/null +++ b/src/lib/search/__tests__/search-records.test.ts @@ -0,0 +1,121 @@ +/** + * The bounds of the record search and the masks that pick its sources. The + * reads themselves are exercised against Postgres in + * `tests/integration/record-search.test.ts`. + * + * Mutation checks (each run, each seen red): + * - drop the deadline check in `scanNewest` → "stops when the budget has + * run out" goes red; + * - drop the `scanned >= maxRows` stop → "never reads more than the cap" + * goes red; + * - drop the domain test from `readableSources` → "drops a source the grant + * does not cover" goes red. + */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ prisma: {} })); + +import type { SearchCandidate } from "../match"; +import { + readableSources, + scanNewest, + type ScanBudget, +} from "../search-records"; + +function budget(deadlineIn = 10_000, clock = { t: 0 }): ScanBudget { + return { + deadline: clock.t + deadlineIn, + now: () => clock.t, + truncated: false, + rows: 0, + }; +} + +/** A source of `total` rows, newest first, every `hitEvery`-th one a hit. */ +function source(total: number, hitEvery = 1) { + const rows = Array.from({ length: total }, (_, i) => ({ id: `r${i}`, i })); + const reads: number[] = []; + return { + reads, + read: async (cursor: string | null, take: number) => { + const start = cursor ? rows.findIndex((r) => r.id === cursor) + 1 : 0; + reads.push(take); + return rows.slice(start, start + take); + }, + match: (row: { id: string; i: number }) => + row.i % hitEvery === 0 + ? ({ id: row.id } as unknown as SearchCandidate) + : null, + }; +} + +describe("scanNewest", () => { + it("reads a small source to its end and is not truncated", async () => { + const b = budget(); + const s = source(30, 10); + const out = await scanNewest({ budget: b, wanted: 5, batch: 10, ...s }); + expect(out.map((c) => c.id)).toEqual(["r0", "r10", "r20"]); + expect(b.truncated).toBe(false); + expect(b.rows).toBe(30); + }); + + it("stops once it holds more hits than wanted", async () => { + const b = budget(); + const s = source(10_000, 1); + const out = await scanNewest({ budget: b, wanted: 3, batch: 10, ...s }); + expect(out.length).toBe(10); + expect(s.reads).toEqual([10]); + expect(b.truncated).toBe(false); + }); + + it("never reads more than the cap, and says so", async () => { + const b = budget(); + const s = source(10_000, 1_000_000); + await scanNewest({ budget: b, wanted: 3, batch: 100, maxRows: 450, ...s }); + expect(b.rows).toBe(450); + expect(s.reads).toEqual([100, 100, 100, 100, 50]); + expect(b.truncated).toBe(true); + }); + + it("stops when the budget has run out, and says so", async () => { + const clock = { t: 0 }; + const b = budget(100, clock); + const s = source(10_000, 1_000_000); + const read = async (cursor: string | null, take: number) => { + clock.t += 60; + return s.read(cursor, take); + }; + await scanNewest({ budget: b, wanted: 3, batch: 10, read, match: s.match }); + expect(s.reads.length).toBe(2); + expect(b.truncated).toBe(true); + }); +}); + +describe("readableSources", () => { + const all = () => true; + + it("reads everything on an owner's record with every module on", () => { + expect(readableSources({}, all)).toHaveLength(13); + }); + + it("drops the sources of a switched-off module, never a core one", () => { + const sources = readableSources( + { illness: false, mood: false, inboundDocuments: false }, + all, + ); + expect(sources).not.toContain("condition"); + expect(sources).not.toContain("symptom"); + expect(sources).not.toContain("symptomNote"); + expect(sources).not.toContain("illnessNote"); + expect(sources).not.toContain("moodNote"); + expect(sources).not.toContain("document"); + expect(sources).toContain("visit"); + expect(sources).toContain("measurementNote"); + }); + + it("drops a source the grant does not cover", () => { + const sources = readableSources({}, (domain) => domain !== "mind"); + expect(sources).not.toContain("moodNote"); + expect(sources).toContain("visit"); + }); +}); diff --git a/src/lib/search/match.ts b/src/lib/search/match.ts index 7e04cd7ea..2bc858975 100644 --- a/src/lib/search/match.ts +++ b/src/lib/search/match.ts @@ -285,6 +285,18 @@ export function scoreCandidate( return { matched, score, phrase }; } +/** `scoreCandidate` for a raw query. */ +export function scoreForQuery( + candidate: Pick, + query: string, +): CandidateScore { + return scoreCandidate( + candidate, + queryWords(query), + normalizeSearchText(query), + ); +} + /** Whether a scored candidate stays in the result at all. */ export function isHit(score: CandidateScore): boolean { return score.matched > 0 || score.phrase; @@ -355,7 +367,9 @@ function foldWithMap(text: string): { from: number[]; to: number[]; } { - const chars: string[] = []; + // One entry per UTF-16 unit of the folded string, so an index `indexOf` + // returns can be looked up directly, astral characters included. + const units: string[] = []; const from: number[] = []; const to: number[] = []; // Whether the vowel at that position has already taken in its "e". @@ -367,27 +381,29 @@ function foldWithMap(text: string): { if (/\s/u.test(ch)) piece = " "; piece = piece.replace(/ß/g, "ss"); for (const out of piece) { - const prev = chars.length - 1; + const prev = units.length - 1; // "ae", "oe", "ue" fold onto the vowel: the "e" widens the vowel's // source range instead of adding a character. if ( out === "e" && prev >= 0 && - /[aou]/.test(chars[prev]) && + /[aou]/.test(units[prev]) && !absorbed[prev] ) { to[prev] = end; absorbed[prev] = true; continue; } - chars.push(out); - from.push(index); - to.push(end); - absorbed.push(false); + for (let k = 0; k < out.length; k++) { + units.push(out[k]); + from.push(index); + to.push(end); + absorbed.push(false); + } } index = end; } - return { folded: chars.join(""), from, to }; + return { folded: units.join(""), from, to }; } /** @@ -397,9 +413,16 @@ function foldWithMap(text: string): { * text holds a different word), so a result can match with nothing marked. */ export function markMatches(text: string, query: string): MarkRange[] { + // The matcher's words; a query too short for the tokeniser ("Hb") is + // found by the phrase rule, so it is marked by its folded self. + const tokens = queryWords(query); const words = [ - ...new Set(queryWords(query).map((w) => foldGerman(w))), - ].filter(Boolean); + ...new Set( + (tokens.length > 0 ? tokens : foldForMatch(query).split(" ")).map((w) => + foldGerman(w), + ), + ), + ].filter((w) => w.length >= 2); if (words.length === 0 || text.length === 0) return []; const { folded, from, to } = foldWithMap(text); const ranges: MarkRange[] = []; From f65d25b0d0c8a21d530a2d08125c7b5890f4ce1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 12:31:37 +0200 Subject: [PATCH 07/61] fix(db): read the clock as UTC in raw SQL and prove it on a Berlin database Every raw-SQL clock read against a timestamp column without a zone now writes NOW() AT TIME ZONE 'UTC', so passkey and login challenges, rate limits, provider pauses, coach usage stamps, tombstone and measurement windows no longer pass through the session time zone even where the per-connection pin cannot apply. The regression-window bounds stay naive UTC timestamps instead of being turned into timestamptz. A structural guard keeps bare NOW() / CURRENT_TIMESTAMP out of raw SQL outside a short list of timestamptz targets. The integration test sets the database default to Europe/Berlin and drives challenge claims, session expiry, the native sign-in handoff freshness check and the rate-limit window through the app's client, plus the challenge claim through an unpinned client. Self-hosting docs describe the pin for an existing or shared Postgres. --- docs/self-hosting/getting-started.md | 20 ++ docs/self-hosting/scaling.md | 7 +- prisma/schema.prisma | 3 +- src/__tests__/sql-now-utc-guard.test.ts | 97 +++++++ src/app/api/measurements/series/route.ts | 2 +- src/lib/ai/coach/budget.ts | 8 +- src/lib/ai/provider-health-ledger.ts | 14 +- .../__tests__/summaries-slice.test.ts | 12 +- src/lib/analytics/source-rank-sql.ts | 4 +- src/lib/analytics/summaries-slice.ts | 29 +- src/lib/auth/passkey.ts | 6 +- src/lib/db.ts | 28 +- src/lib/export/measurement-bulk-insert.ts | 2 +- src/lib/insights/comprehensive-aggregator.ts | 29 +- src/lib/jobs/__tests__/boss-instance.test.ts | 2 + src/lib/jobs/data-arrival.ts | 2 +- src/lib/jobs/reminder/cleanup-handlers.ts | 2 +- src/lib/jobs/step-consolidation-repair.ts | 2 +- .../repair-apple-health-distance.ts | 2 +- src/lib/notifications/delivery-identity.ts | 4 +- src/lib/rate-limit.ts | 8 +- tests/integration/session-time-zone.test.ts | 252 ++++++++++++++++++ 22 files changed, 469 insertions(+), 66 deletions(-) create mode 100644 src/__tests__/sql-now-utc-guard.test.ts create mode 100644 tests/integration/session-time-zone.test.ts diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index c55033afd..9967227e6 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -77,6 +77,26 @@ process (`pnpm dev` against a host Postgres) or a **custom compose** that does not derive it — in that case uncomment and set `DATABASE_URL` explicitly in `.env` (see the commented example there). +### Using an existing or shared Postgres + +HealthLog can live in a database on a Postgres instance you already run. +It stores every time as UTC and pins each connection it opens to UTC +(`TimeZone=UTC`, sent with the libpq `options` startup parameter), so the +server's or the database's own `timezone` setting does not matter: an +instance set to `Europe/Berlin` works the same as one set to UTC. The app +checks the setting at startup and logs a warning naming the zone if a +connection still reports something else. + +Behind PgBouncer with `DATABASE_SESSION_OPTIONS_DISABLED=1` (see +[Scaling](./scaling.md#postgres-connection-pool-sizing)) the app pins the +zone with `SET TIME ZONE 'UTC'` on each new connection instead; PgBouncer +keeps that setting per client in transaction pooling too. If the startup +warning still appears, set the database default as well: + +```sql +ALTER DATABASE healthlog SET timezone TO 'UTC'; +``` + ## 3. Bring the stack up ```bash diff --git a/docs/self-hosting/scaling.md b/docs/self-hosting/scaling.md index 2512ecad9..c39d30b5a 100644 --- a/docs/self-hosting/scaling.md +++ b/docs/self-hosting/scaling.md @@ -188,13 +188,16 @@ already`. Either raise `max_connections` on the Postgres side, lower `DATABASE_POOL_MAX`, or front the database with PgBouncer in transaction-pooling mode. -The app sets its statement timeout and `work_mem` on every connection -through the libpq `options` startup parameter. PgBouncer refuses that +The app sets its statement timeout, `work_mem` and `TimeZone=UTC` on every +connection through the libpq `options` startup parameter. PgBouncer refuses that parameter (`unsupported startup parameter: options`) unless it is listed in `ignore_startup_parameters`. Either list it there, or set `DATABASE_SESSION_OPTIONS_DISABLED=1` so the app sends no session settings; set the timeout and `work_mem` on the database role instead (`ALTER ROLE healthlog SET work_mem = '16MB'`) if you still want them. +The time zone is the exception: with the options off the app pins it with +`SET TIME ZONE 'UTC'` on each new connection, which PgBouncer tracks per +client, and warns at startup if a connection still reports another zone. The measurement maintenance job (Admin, System status) then applies its own settings with `SET` after connecting. Its concurrent index rebuild needs a session, so run it through a session-mode pool or a direct connection. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 99eb867c2..8cce98cc9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,7 +11,8 @@ // round-trip the same instant. The invariant every reader, writer, and // raw-SQL query MUST hold is: // -// * The application server and the Postgres session run in UTC. +// * The application server and the Postgres session run in UTC (every +// connection pins TimeZone=UTC, see src/lib/db-session.ts). // * Wall-clock / calendar-local anchoring lives in an explicit `tz` // string column (e.g. `MoodEntry.tz`) or the user's `timezone`, NOT // in the column type. diff --git a/src/__tests__/sql-now-utc-guard.test.ts b/src/__tests__/sql-now-utc-guard.test.ts new file mode 100644 index 000000000..8846c7622 --- /dev/null +++ b/src/__tests__/sql-now-utc-guard.test.ts @@ -0,0 +1,97 @@ +/** + * Raw SQL never reads the clock through the session time zone. + * + * Stored times are UTC in `timestamp` columns without a zone. Postgres + * compares such a column with `NOW()` / `CURRENT_TIMESTAMP` / + * `clock_timestamp()` by converting through the session time zone, and + * assigns one of them to such a column the same way, so on a database whose + * default is not UTC a fresh passkey challenge read as already expired. Every + * connection is pinned to UTC (`@/lib/db-session`), and on top of that every + * raw-SQL clock read writes `NOW() AT TIME ZONE 'UTC'`, which yields the naive + * UTC timestamp whatever the session says. + * + * The few bare reads left are listed below with the reason they are safe: + * each feeds a `timestamptz` column or returns the instant to JavaScript. + */ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; + +const ROOT = join(__dirname, "..", ".."); +const SRC = join(ROOT, "src"); + +/** `file: reason` for every bare clock read that is deliberately left. */ +const ALLOWED: Record = { + "src/lib/rollups/mood-rollups.ts": "computed_at is timestamptz", + "src/lib/rollups/medication-compliance-rollups.ts": + "computed_at is timestamptz", + "src/lib/jobs/job-failures.ts": "pg-boss tables are timestamptz", + "src/lib/jobs/reminder/registrar-shared.ts": "pg-boss tables are timestamptz", + "src/lib/db-compat.ts": "DDL default on a timestamptz column", +}; + +const SQL_CLOCK = + /(?]|=)\s*now\(\)\s*(?:$|[,)]|[-+]\s*(?:make_interval|INTERVAL))|\bnow\(\)\s*[-+]\s*(?:make_interval|INTERVAL)/; +const ANCHORED = + /(?:NOW\(\)|CURRENT_TIMESTAMP|clock_timestamp\(\)) AT TIME ZONE 'UTC'/g; + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const full = join(dir, name); + if (name === "generated" || name === "__tests__") continue; + if (statSync(full).isDirectory()) walk(full, out); + else if (/\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name)) + out.push(full); + } + return out; +} + +function offenders(): string[] { + const found: string[] = []; + for (const file of walk(SRC)) { + const rel = relative(ROOT, file); + if (ALLOWED[rel]) continue; + readFileSync(file, "utf8") + .split("\n") + .forEach((line, index) => { + if (/^\s*(\/\/|\*|\/\*|--)/.test(line)) return; + if (SQL_CLOCK.test(line.replace(ANCHORED, ""))) { + found.push(`${rel}:${index + 1}: ${line.trim()}`); + } + }); + } + return found; +} + +describe("raw SQL clock reads are anchored to UTC", () => { + it("finds the anchored form across the swept files", () => { + // The matcher must see something, or a broken walk would pass. + const passkey = readFileSync(join(SRC, "lib/auth/passkey.ts"), "utf8"); + expect(passkey.match(ANCHORED)?.length).toBeGreaterThanOrEqual(3); + expect(SQL_CLOCK.test("AND expires_at > NOW()")).toBe(true); + expect(SQL_CLOCK.test("updated_at = now()")).toBe(true); + expect(SQL_CLOCK.test("const started = now();")).toBe(false); + expect(SQL_CLOCK.test("elapsedMs: () => now() - started,")).toBe(false); + expect(SQL_CLOCK.test("const ends = now() + perCallMs;")).toBe(false); + expect( + SQL_CLOCK.test("deleted_at >= now() - make_interval(days => 1)"), + ).toBe(true); + expect(SQL_CLOCK.test("Date.now()")).toBe(false); + }); + + it("leaves no bare NOW() / CURRENT_TIMESTAMP outside the allowlist", () => { + expect(offenders()).toEqual([]); + }); + + it("keeps the allowlist honest: every entry still has a bare read", () => { + for (const rel of Object.keys(ALLOWED)) { + const lines = readFileSync(join(ROOT, rel), "utf8") + .replace(ANCHORED, "") + .split("\n"); + expect( + lines.some((line) => SQL_CLOCK.test(line)), + rel, + ).toBe(true); + } + }); +}); diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts index 17049bf4c..a60dd7992 100644 --- a/src/app/api/measurements/series/route.ts +++ b/src/app/api/measurements/series/route.ts @@ -378,7 +378,7 @@ export const GET = apiHandler(async (request: NextRequest) => { AND "type" = 'BLOOD_PRESSURE_SYS'::"measurement_type" AND "deleted_at" IS NULL AND "value" >= $3 - AND "measured_at" >= date_trunc('day', $2::timestamptz) + AND "measured_at" >= date_trunc('day', $2::timestamptz AT TIME ZONE 'UTC') ORDER BY date_trunc('day', "measured_at"), (${rank}), "source"::text ) c ON c.d = date_trunc('day', m."measured_at") diff --git a/src/lib/ai/coach/budget.ts b/src/lib/ai/coach/budget.ts index 88e1e0097..f1d387e0a 100644 --- a/src/lib/ai/coach/budget.ts +++ b/src/lib/ai/coach/budget.ts @@ -321,12 +321,12 @@ export async function reserveBudget( { total_tokens: number; operator_tokens: number }[] >` INSERT INTO coach_usage (id, user_id, date_key, total_tokens, operator_tokens, message_count, created_at, updated_at) - VALUES (gen_random_uuid()::text, ${userId}, ${dateKey}, ${reserved}, ${operatorReserved}, ${messageBump}, NOW(), NOW()) + VALUES (gen_random_uuid()::text, ${userId}, ${dateKey}, ${reserved}, ${operatorReserved}, ${messageBump}, (NOW() AT TIME ZONE 'UTC'), (NOW() AT TIME ZONE 'UTC')) ON CONFLICT (user_id, date_key) DO UPDATE SET total_tokens = coach_usage.total_tokens + ${reserved}, operator_tokens = coach_usage.operator_tokens + ${operatorReserved}, message_count = coach_usage.message_count + ${messageBump}, - updated_at = NOW() + updated_at = (NOW() AT TIME ZONE 'UTC') RETURNING total_tokens, operator_tokens `; const totalAfter = Number(rows[0]?.total_tokens ?? reserved); @@ -453,7 +453,7 @@ export async function reconcileSpend( UPDATE coach_usage SET total_tokens = GREATEST(0, total_tokens + ${delta}), operator_tokens = GREATEST(0, operator_tokens + ${operatorDelta}), - updated_at = NOW() + updated_at = (NOW() AT TIME ZONE 'UTC') WHERE user_id = ${userId} AND date_key = ${dateKey} `; } @@ -472,7 +472,7 @@ async function refundReservation( SET total_tokens = GREATEST(0, total_tokens - ${reserved}), operator_tokens = GREATEST(0, operator_tokens - ${operatorReserved}), message_count = GREATEST(0, message_count - ${messageBump}), - updated_at = NOW() + updated_at = (NOW() AT TIME ZONE 'UTC') WHERE user_id = ${userId} AND date_key = ${dateKey} `; } diff --git a/src/lib/ai/provider-health-ledger.ts b/src/lib/ai/provider-health-ledger.ts index 64c49388c..1113fe373 100644 --- a/src/lib/ai/provider-health-ledger.ts +++ b/src/lib/ai/provider-health-ledger.ts @@ -203,14 +203,14 @@ export const postgresProviderHealthLedger: ProviderHealthLedger = { next_retry_at, updated_at) VALUES (gen_random_uuid()::text, ${userId}, ${providerType}, 'ok', NULL, - 0, NOW(), NULL, NULL, NOW()) + 0, (NOW() AT TIME ZONE 'UTC'), NULL, NULL, (NOW() AT TIME ZONE 'UTC')) ON CONFLICT (user_id, provider_type) DO UPDATE SET last_result = 'ok', last_status = NULL, consecutive_failures = 0, - last_ok_at = NOW(), + last_ok_at = (NOW() AT TIME ZONE 'UTC'), next_retry_at = NULL, - updated_at = NOW() + updated_at = (NOW() AT TIME ZONE 'UTC') `; } catch { // Fire-and-forget — a failed write never blocks the hot path. @@ -240,13 +240,13 @@ export const postgresProviderHealthLedger: ProviderHealthLedger = { next_retry_at, updated_at) VALUES (gen_random_uuid()::text, ${userId}, ${providerType}, ${result}, - ${status}, 1, NULL, NOW(), NOW() + ${interval}::interval, NOW()) + ${status}, 1, NULL, (NOW() AT TIME ZONE 'UTC'), (NOW() AT TIME ZONE 'UTC') + ${interval}::interval, (NOW() AT TIME ZONE 'UTC')) ON CONFLICT (user_id, provider_type) DO UPDATE SET last_result = ${result}, last_status = ${status}, consecutive_failures = provider_health.consecutive_failures + 1, - last_failure_at = NOW(), - next_retry_at = NOW() + CASE + last_failure_at = (NOW() AT TIME ZONE 'UTC'), + next_retry_at = (NOW() AT TIME ZONE 'UTC') + CASE WHEN ${result} = 'hard_failed' AND provider_health.consecutive_failures + 1 >= ${pauseFrom}::int THEN LEAST( @@ -258,7 +258,7 @@ export const postgresProviderHealthLedger: ProviderHealthLedger = { ) ELSE ${interval}::interval END, - updated_at = NOW() + updated_at = (NOW() AT TIME ZONE 'UTC') `; } catch { // Fire-and-forget. diff --git a/src/lib/analytics/__tests__/summaries-slice.test.ts b/src/lib/analytics/__tests__/summaries-slice.test.ts index b7e3551df..a503f58e0 100644 --- a/src/lib/analytics/__tests__/summaries-slice.test.ts +++ b/src/lib/analytics/__tests__/summaries-slice.test.ts @@ -662,13 +662,17 @@ describe("computeSummariesSlice", () => { // left with slope90 in v1.37.19). for (const days of ["7 days", "30 days"]) { expect(sql).toContain( - `(date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '${days}') AT TIME ZONE 'UTC'`, + `m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '${days}')`, ); } const anchored = sql.match( - /date_trunc\('day', NOW\(\) AT TIME ZONE 'UTC'\) - INTERVAL '(?:7|30) days'\) AT TIME ZONE 'UTC'/g, + /date_trunc\('day', NOW\(\) AT TIME ZONE 'UTC'\) - INTERVAL '(?:7|30) days'\)/g, ); expect(anchored?.length).toBe(4); + // The bound stays a naive UTC timestamp like measured_at: converting + // it to timestamptz would route the comparison through the session + // time zone. + expect(sql).not.toMatch(/INTERVAL '(?:7|30) days'\) AT TIME ZONE/); // The regression windows must NOT fall back to the bare wall-clock // `NOW() - INTERVAL 'N days'` bound the warm path never uses for the @@ -676,7 +680,9 @@ describe("computeSummariesSlice", () => { // REGR_SLOPE onward) so the avg7/avg30 windows above — which DO stay // wall-clock by design — don't trip the assertion. const slopeBlock = sql.slice(sql.indexOf("REGR_SLOPE")); - expect(slopeBlock).not.toMatch(/WHERE m\."measured_at" >= NOW\(\) -/); + expect(slopeBlock).not.toMatch( + /WHERE m\."measured_at" >= \(?NOW\(\)( AT TIME ZONE 'UTC'\))? -/, + ); }); }); }); diff --git a/src/lib/analytics/source-rank-sql.ts b/src/lib/analytics/source-rank-sql.ts index c53485983..a5f1940b2 100644 --- a/src/lib/analytics/source-rank-sql.ts +++ b/src/lib/analytics/source-rank-sql.ts @@ -134,10 +134,10 @@ export function canonicalMeasurementsCte( onlyType?: MeasurementType, ): string { const sinceInner = sinceInterval - ? `AND "measured_at" >= NOW() - INTERVAL '${assertInterval(sinceInterval)}'` + ? `AND "measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '${assertInterval(sinceInterval)}'` : ""; const sinceOuter = sinceInterval - ? `AND mm."measured_at" >= NOW() - INTERVAL '${assertInterval(sinceInterval)}'` + ? `AND mm."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '${assertInterval(sinceInterval)}'` : ""; // A single-type reader (a series for one metric) scopes both halves to that // type, so the pick scans one metric's rows instead of every row the user diff --git a/src/lib/analytics/summaries-slice.ts b/src/lib/analytics/summaries-slice.ts index 92b2c1ff0..9766410a9 100644 --- a/src/lib/analytics/summaries-slice.ts +++ b/src/lib/analytics/summaries-slice.ts @@ -174,10 +174,14 @@ function windowedMean(filter: string): string { filter, }); } -const AVG7_SQL = windowedMean(`m."measured_at" >= NOW() - INTERVAL '7 days'`); -const AVG30_SQL = windowedMean(`m."measured_at" >= NOW() - INTERVAL '30 days'`); +const AVG7_SQL = windowedMean( + `m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days'`, +); +const AVG30_SQL = windowedMean( + `m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days'`, +); const AVG30_LAST_MONTH_SQL = windowedMean( - `m."measured_at" >= NOW() - INTERVAL '60 days' AND m."measured_at" < NOW() - INTERVAL '30 days'`, + `m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '60 days' AND m."measured_at" < (NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days'`, ); interface NarrowAggregateRow { @@ -566,7 +570,7 @@ async function computeFromRollups(userId: string): Promise { PERCENTILE_CONT(0.5) WITHIN GROUP ( ORDER BY m."value" ) FILTER ( - WHERE m."measured_at" >= NOW() - INTERVAL '90 days' + WHERE m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '90 days' )::double precision AS median FROM ${dayWeightedRows("cm", SESSION_DAY_FRAME)} m GROUP BY m."type" @@ -1041,17 +1045,16 @@ async function computeFromLiveAggregate( PERCENTILE_CONT(0.5) WITHIN GROUP ( ORDER BY m."value" ) FILTER ( - WHERE m."measured_at" >= NOW() - INTERVAL '90 days' + WHERE m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '90 days' )::double precision AS median, -- A2-M2 — anchor the regression windows on the UTC-midnight day -- boundary so this cold-fallback path returns the SAME boundary-day -- membership the warm computeFromRollups composes via -- startOfUtcDay(now - N days) + composeWindowedRegression. The -- expression date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL - -- 'N days' is the SQL twin of startOfUtcDay; the trailing AT TIME - -- ZONE 'UTC' re-materialises the naive truncation as a UTC instant - -- so the measured_at (timestamptz) comparison lands on the same wall - -- the rollup bucketStart sits on. The wall-clock NOW() - INTERVAL + -- 'N days' is the SQL twin of startOfUtcDay. It stays a naive UTC + -- timestamp, like measured_at itself, so the comparison never + -- passes through the session time zone. The wall-clock NOW() - INTERVAL -- bound let the boundary day flip in/out of the slope window across -- a warm-to-cold coverage transition (cache-dependent slope on the -- same request); the avg7/30/median windows above stay wall-clock @@ -1060,25 +1063,25 @@ async function computeFromLiveAggregate( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days') AT TIME ZONE 'UTC' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days') )::double precision AS slope7, REGR_R2( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days') AT TIME ZONE 'UTC' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days') )::double precision AS r2_7, REGR_SLOPE( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') AT TIME ZONE 'UTC' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') )::double precision AS slope30, REGR_R2( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') AT TIME ZONE 'UTC' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') )::double precision AS r2_30 FROM ${dayWeightedRows("cm", SESSION_DAY_FRAME)} m GROUP BY m."type" diff --git a/src/lib/auth/passkey.ts b/src/lib/auth/passkey.ts index d2847ad82..cc7b7a5a3 100644 --- a/src/lib/auth/passkey.ts +++ b/src/lib/auth/passkey.ts @@ -186,7 +186,7 @@ export async function consumeRegistrationChallenge(input: { WHERE id = ${input.challengeId} AND user_id = ${input.userId} AND type = ${expectedType} - AND expires_at > NOW() + AND expires_at > (NOW() AT TIME ZONE 'UTC') RETURNING challenge, created_at `; @@ -225,7 +225,7 @@ export async function claimAuthChallenge(input: { WHERE id = ${input.challengeId} AND type = ${input.type} AND user_id = ${input.userId} - AND expires_at > NOW() + AND expires_at > (NOW() AT TIME ZONE 'UTC') RETURNING challenge, user_id ` : await prisma.$queryRaw< @@ -234,7 +234,7 @@ export async function claimAuthChallenge(input: { DELETE FROM auth_challenges WHERE id = ${input.challengeId} AND type = ${input.type} - AND expires_at > NOW() + AND expires_at > (NOW() AT TIME ZONE 'UTC') RETURNING challenge, user_id `; if (rows.length !== 1) return null; diff --git a/src/lib/db.ts b/src/lib/db.ts index be64fb5b7..9cfff9d20 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,6 +1,6 @@ import { Prisma, PrismaClient } from "@/generated/prisma/client"; import { PrismaPg } from "@prisma/adapter-pg"; -import { Pool } from "pg"; +import { Pool, type PoolConfig } from "pg"; import { pinTimeZoneOnConnect, sessionOptionsDisabled, @@ -144,16 +144,32 @@ export function buildSessionOptions(): string | undefined { return `${timeouts}-c work_mem=${getWorkMem()} ${TIME_ZONE_OPTION}`; } -function createPrismaClient() { - const pool = new Pool({ +export function prismaPoolConfig(): PoolConfig { + return { connectionString: process.env.DATABASE_URL!, max: getPrismaPoolMax(), connectionTimeoutMillis: getPoolConnectionTimeoutMs(), options: buildSessionOptions(), - }); + }; +} + +/** + * The adapter for the process-wide client. The startup options carry the + * UTC pin by default, so the adapter builds its own pool from the config. + * With the options off (a pooler) the pool is built here instead, to pin the + * zone by statement on connect; the adapter then reuses that one pool for + * its whole life, a reconnect included. + */ +export function createPrismaAdapter(): PrismaPg { + const config = prismaPoolConfig(); + if (!sessionOptionsDisabled()) return new PrismaPg(config); + const pool = new Pool(config); pinTimeZoneOnConnect(pool); - const adapter = new PrismaPg(pool, { disposeExternalPool: true }); - return new PrismaClient({ adapter }); + return new PrismaPg(pool); +} + +function createPrismaClient() { + return new PrismaClient({ adapter: createPrismaAdapter() }); } // One client per process, in production too. A production build gives every diff --git a/src/lib/export/measurement-bulk-insert.ts b/src/lib/export/measurement-bulk-insert.ts index e056817a0..adbb848ae 100644 --- a/src/lib/export/measurement-bulk-insert.ts +++ b/src/lib/export/measurement-bulk-insert.ts @@ -123,7 +123,7 @@ function selectExpression( const cast = `${ref}::timestamp(3)`; // Prisma fills both on create when the row does not carry them. return field === "createdAt" || field === "updatedAt" - ? `COALESCE(${cast}, CURRENT_TIMESTAMP)` + ? `COALESCE(${cast}, CURRENT_TIMESTAMP AT TIME ZONE 'UTC')` : cast; } if (kind === "bytea") return `decode(${ref}, 'hex')`; diff --git a/src/lib/insights/comprehensive-aggregator.ts b/src/lib/insights/comprehensive-aggregator.ts index 8d562abd0..63f22ec82 100644 --- a/src/lib/insights/comprehensive-aggregator.ts +++ b/src/lib/insights/comprehensive-aggregator.ts @@ -145,10 +145,14 @@ function windowedMean(filter: string): string { filter, }); } -const AVG7_SQL = windowedMean(`m."measured_at" >= NOW() - INTERVAL '7 days'`); -const AVG30_SQL = windowedMean(`m."measured_at" >= NOW() - INTERVAL '30 days'`); +const AVG7_SQL = windowedMean( + `m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days'`, +); +const AVG30_SQL = windowedMean( + `m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days'`, +); const AVG30_LAST_MONTH_SQL = windowedMean( - `m."measured_at" >= NOW() - INTERVAL '60 days' AND m."measured_at" < NOW() - INTERVAL '30 days'`, + `m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '60 days' AND m."measured_at" < (NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days'`, ); /** @@ -488,7 +492,7 @@ async function buildFromRollups( m."measured_at" AS measured_at FROM measurements m WHERE m."user_id" = $1 - AND m."measured_at" >= NOW() - INTERVAL '90 days' + AND m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '90 days' AND m."deleted_at" IS NULL ORDER BY m."type", date_trunc('day', m."measured_at") DESC, (${rankM}), m."measured_at" DESC `, @@ -718,10 +722,9 @@ async function buildFromLiveAggregate( -- composes from (startOfUtcDay(now - N days) + -- composeWindowedRegression). The expression -- date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL 'N days' - -- is the SQL twin of startOfUtcDay; the trailing AT TIME ZONE 'UTC' - -- re-materialises the naive truncation as a UTC instant so the - -- measured_at (timestamptz) comparison is on the same wall the - -- rollup bucketStart sits on. Without this anchor an account that + -- is the SQL twin of startOfUtcDay. It stays a naive UTC timestamp, + -- like measured_at itself, so the comparison never passes through + -- the session time zone. Without this anchor an account that -- crosses a coverage boundary (warm to cold) saw the boundary day -- flip in/out of the slope window, giving a cache-dependent answer -- to the same request. @@ -729,25 +732,25 @@ async function buildFromLiveAggregate( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days') AT TIME ZONE 'UTC' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days') )::double precision AS slope7, REGR_R2( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days') AT TIME ZONE 'UTC' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '7 days') )::double precision AS r2_7, REGR_SLOPE( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') AT TIME ZONE 'UTC' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') )::double precision AS slope30, REGR_R2( m."value", EXTRACT(EPOCH FROM m."measured_at") / 86400.0 ) FILTER ( - WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') AT TIME ZONE 'UTC' + WHERE m."measured_at" >= (date_trunc('day', NOW() AT TIME ZONE 'UTC') - INTERVAL '30 days') )::double precision AS r2_30 FROM ${dayWeightedRows("cm", SESSION_DAY_FRAME)} m JOIN window_stats ws ON ws."type" = m."type" @@ -765,7 +768,7 @@ async function buildFromLiveAggregate( m."measured_at" AS measured_at FROM measurements m WHERE m."user_id" = $1 - AND m."measured_at" >= NOW() - INTERVAL '90 days' + AND m."measured_at" >= (NOW() AT TIME ZONE 'UTC') - INTERVAL '90 days' AND m."deleted_at" IS NULL ORDER BY m."type", date_trunc('day', m."measured_at") DESC, (${rankM}), m."measured_at" DESC `, diff --git a/src/lib/jobs/__tests__/boss-instance.test.ts b/src/lib/jobs/__tests__/boss-instance.test.ts index 08b70ed83..6b1950999 100644 --- a/src/lib/jobs/__tests__/boss-instance.test.ts +++ b/src/lib/jobs/__tests__/boss-instance.test.ts @@ -70,6 +70,7 @@ describe("web-process pg-boss producer", () => { connectionString: "postgres://healthlog@test/db", max: 2, connectionTimeoutMillis: 20_000, + options: "-c TimeZone=UTC", migrate: false, schedule: false, supervise: false, @@ -111,6 +112,7 @@ describe("worker pg-boss pool", () => { connectionString: "postgres://healthlog@test/db", max: 2, connectionTimeoutMillis: 20_000, + options: "-c TimeZone=UTC", }); }); }); diff --git a/src/lib/jobs/data-arrival.ts b/src/lib/jobs/data-arrival.ts index 3e048ee61..499689940 100644 --- a/src/lib/jobs/data-arrival.ts +++ b/src/lib/jobs/data-arrival.ts @@ -147,7 +147,7 @@ async function claimReaction( ), operator_tokens = GREATEST(0, operator_tokens - ${operatorReserved}), message_count = GREATEST(0, message_count - 1), - updated_at = NOW() + updated_at = (NOW() AT TIME ZONE 'UTC') WHERE user_id = ${arrival.userId} AND date_key = ${current.generationBudgetDateKey} `; diff --git a/src/lib/jobs/reminder/cleanup-handlers.ts b/src/lib/jobs/reminder/cleanup-handlers.ts index 0c6927965..e15318865 100644 --- a/src/lib/jobs/reminder/cleanup-handlers.ts +++ b/src/lib/jobs/reminder/cleanup-handlers.ts @@ -294,7 +294,7 @@ export async function handleRateLimitCleanup( const p = getWorkerPrisma(); try { const result = await p.$executeRaw` - DELETE FROM rate_limits WHERE reset_at < NOW() + DELETE FROM rate_limits WHERE reset_at < (NOW() AT TIME ZONE 'UTC') `; evt.addMeta("rate_limit_cleanup_deleted", result); return jobDone({ deleted: result }); diff --git a/src/lib/jobs/step-consolidation-repair.ts b/src/lib/jobs/step-consolidation-repair.ts index 09c8a3bc3..0c1108a9e 100644 --- a/src/lib/jobs/step-consolidation-repair.ts +++ b/src/lib/jobs/step-consolidation-repair.ts @@ -274,7 +274,7 @@ export async function enqueueBootTimeStepConsolidationRepair( FROM measurements t WHERE t."type" = 'ACTIVITY_STEPS' AND t."deleted_at" IS NOT NULL - AND t."deleted_at" >= now() - make_interval(days => ${TOMBSTONE_RETENTION_DAYS}) + AND t."deleted_at" >= (NOW() AT TIME ZONE 'UTC') - make_interval(days => ${TOMBSTONE_RETENTION_DAYS}) AND t."source" IN ('GOOGLE_HEALTH', 'FITBIT') AND t."external_id" LIKE 'stats:%' AND NOT EXISTS ( diff --git a/src/lib/measurements/repair-apple-health-distance.ts b/src/lib/measurements/repair-apple-health-distance.ts index 19cc9364c..9b0178cbf 100644 --- a/src/lib/measurements/repair-apple-health-distance.ts +++ b/src/lib/measurements/repair-apple-health-distance.ts @@ -326,7 +326,7 @@ export async function applyAppleHealthDistanceRepair( SET value = value * ${factor}, unit = 'm', sync_version = sync_version + 1, - updated_at = NOW() + updated_at = (NOW() AT TIME ZONE 'UTC') WHERE id = ANY(${chunk})`; } await auditLog(APPLE_HEALTH_DISTANCE_REPAIR_ACTION, { diff --git a/src/lib/notifications/delivery-identity.ts b/src/lib/notifications/delivery-identity.ts index 97c9710f0..c68b9ea35 100644 --- a/src/lib/notifications/delivery-identity.ts +++ b/src/lib/notifications/delivery-identity.ts @@ -166,7 +166,7 @@ export async function claimManagedGuardianEgressAuthorization( ${delivery.recipientUserId}, ${channel}, ${eventType}, - clock_timestamp() + (clock_timestamp() AT TIME ZONE 'UTC') WHERE EXISTS ( SELECT 1 FROM "account_grants" @@ -177,7 +177,7 @@ export async function claimManagedGuardianEgressAuthorization( AND "revoked_at" IS NULL AND ( "expires_at" IS NULL - OR "expires_at" > clock_timestamp() + OR "expires_at" > (clock_timestamp() AT TIME ZONE 'UTC') ) ) RETURNING "id" diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 6914c167c..3d764b8e8 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -31,14 +31,14 @@ export async function checkRateLimit( const rows = await prisma.$queryRaw<{ count: number; reset_at: Date }[]>` INSERT INTO rate_limits (key, count, reset_at) - VALUES (${key}, 1, NOW() + ${windowInterval}::interval) + VALUES (${key}, 1, (NOW() AT TIME ZONE 'UTC') + ${windowInterval}::interval) ON CONFLICT (key) DO UPDATE SET count = CASE - WHEN rate_limits.reset_at < NOW() THEN 1 + WHEN rate_limits.reset_at < (NOW() AT TIME ZONE 'UTC') THEN 1 ELSE rate_limits.count + 1 END, reset_at = CASE - WHEN rate_limits.reset_at < NOW() THEN NOW() + ${windowInterval}::interval + WHEN rate_limits.reset_at < (NOW() AT TIME ZONE 'UTC') THEN (NOW() AT TIME ZONE 'UTC') + ${windowInterval}::interval ELSE rate_limits.reset_at END RETURNING count, reset_at @@ -80,7 +80,7 @@ export async function refundRateLimit(key: string): Promise { await prisma.$executeRaw` UPDATE rate_limits SET count = GREATEST(count - 1, 0) - WHERE key = ${key} AND reset_at >= NOW() + WHERE key = ${key} AND reset_at >= (NOW() AT TIME ZONE 'UTC') `; } diff --git a/tests/integration/session-time-zone.test.ts b/tests/integration/session-time-zone.test.ts new file mode 100644 index 000000000..3743de7e8 --- /dev/null +++ b/tests/integration/session-time-zone.test.ts @@ -0,0 +1,252 @@ +/** + * HealthLog works on a database whose default time zone is not UTC. + * + * Stored times are UTC in `timestamp` columns without a zone; Postgres reads + * `now()` through the session time zone. On a shared instance set to + * Europe/Berlin a fresh passkey challenge used to be expired before the + * prompt closed. This file sets the test database's default to Europe/Berlin + * (every connection opened afterwards starts in it) and drives the expiry + * paths through the application's own client, which pins UTC per + * connection. A second block takes the pin away and shows that the raw-SQL + * expiry checks hold on their own. + */ +import { Client } from "pg"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { PrismaClient } from "@/generated/prisma/client"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const ZONE = "Europe/Berlin"; +const USER_ID = "user-session-time-zone"; + +let databaseName = ""; + +async function rawClient(): Promise { + // No startup options: the connection takes the database default. + const client = new Client({ connectionString: process.env.DATABASE_URL }); + await client.connect(); + return client; +} + +async function setDatabaseZone(zone: string | null): Promise { + const client = await rawClient(); + try { + if (!databaseName) { + const { rows } = await client.query<{ name: string }>( + "SELECT current_database() AS name", + ); + databaseName = rows[0].name; + } + await client.query( + zone + ? `ALTER DATABASE "${databaseName}" SET timezone TO '${zone}'` + : `ALTER DATABASE "${databaseName}" RESET timezone`, + ); + } finally { + await client.end(); + } +} + +/** A Prisma client WITHOUT the pin: its sessions run in Europe/Berlin. */ +function unpinnedClient(): PrismaClient { + return new PrismaClient({ + adapter: new PrismaPg({ connectionString: process.env.DATABASE_URL! }), + }); +} + +async function challenge( + db: PrismaClient, + type: string, + expiresInMs = 60_000, +): Promise { + const row = await db.authChallenge.create({ + data: { + userId: USER_ID, + challenge: `material-${type}`, + type, + expiresAt: new Date(Date.now() + expiresInMs), + }, + }); + return row.id; +} + +beforeAll(async () => { + // Before any pooled connection of this file opens. + await setDatabaseZone(ZONE); +}); + +afterAll(async () => { + // The container is shared with every other file of the run. + await setDatabaseZone(null); +}); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + await getPrismaClient().user.create({ + data: { id: USER_ID, username: "tz-user", email: "tz@example.test" }, + }); +}); + +describe("on a database whose default zone is Europe/Berlin", () => { + it("a connection without the pin really runs in Europe/Berlin", async () => { + const client = await rawClient(); + try { + const { rows } = await client.query<{ TimeZone: string }>( + "SHOW TimeZone", + ); + expect(rows[0].TimeZone).toBe(ZONE); + } finally { + await client.end(); + } + }); + + it("the application's pool runs in UTC and the boot check says so", async () => { + const { runBootTimeZoneCheck } = await import("@/lib/boot/time-zone-check"); + await expect(runBootTimeZoneCheck(getPrismaClient())).resolves.toEqual({ + state: "utc", + timeZone: "UTC", + }); + const unpinned = unpinnedClient(); + const warn = vi.spyOn(console, "warn").mockImplementation(() => {}); + try { + await expect(runBootTimeZoneCheck(unpinned)).resolves.toEqual({ + state: "mismatch", + timeZone: ZONE, + }); + expect(warn).toHaveBeenCalledTimes(1); + } finally { + warn.mockRestore(); + await unpinned.$disconnect(); + } + }); + + it("a fresh passkey registration challenge is accepted", async () => { + const { consumeRegistrationChallenge } = await import("@/lib/auth/passkey"); + const { hashToken } = await import("@/lib/auth/hmac"); + const sessionId = "session-tz"; + const id = await challenge( + getPrismaClient(), + `passkey-registration:v1:${hashToken(sessionId)}`, + ); + const claim = await consumeRegistrationChallenge({ + challengeId: id, + userId: USER_ID, + sessionId, + }); + expect(claim.ok).toBe(true); + if (claim.ok) { + // Stamped by the column default: UTC, not Berlin wall time. + expect(Math.abs(claim.createdAt.getTime() - Date.now())).toBeLessThan( + 60_000, + ); + } + }); + + it("a fresh login challenge is claimed, an expired one is not", async () => { + const { claimAuthChallenge } = await import("@/lib/auth/passkey"); + const fresh = await challenge(getPrismaClient(), "authentication"); + await expect( + claimAuthChallenge({ challengeId: fresh, type: "authentication" }), + ).resolves.not.toBeNull(); + const stale = await challenge(getPrismaClient(), "authentication", -1_000); + await expect( + claimAuthChallenge({ challengeId: stale, type: "authentication" }), + ).resolves.toBeNull(); + }); + + it("a session is valid until it expires and is stamped in UTC", async () => { + const { validateSessionFromCookieValue } = + await import("@/lib/auth/session"); + const { hashToken } = await import("@/lib/auth/hmac"); + const live = await getPrismaClient().session.create({ + data: { + userId: USER_ID, + tokenHash: hashToken("hls_live-secret"), + expiresAt: new Date(Date.now() + 60 * 60_000), + }, + }); + await getPrismaClient().session.create({ + data: { + userId: USER_ID, + tokenHash: hashToken("hls_expired-secret"), + expiresAt: new Date(Date.now() - 60_000), + }, + }); + expect(Math.abs(live.createdAt.getTime() - Date.now())).toBeLessThan( + 60_000, + ); + await expect( + validateSessionFromCookieValue("hls_live-secret"), + ).resolves.not.toBeNull(); + await expect( + validateSessionFromCookieValue("hls_expired-secret"), + ).resolves.toBeNull(); + }); + + it("the native sign-in handoff sees a session begun inside it as fresh", async () => { + // The handoff link is good only for a session created after it began: + // `session.createdAt >= startedAt`, with `startedAt` read from the + // database clock and `createdAt` stamped by the column default. + const { nativeHandoffDbNow } = + await import("@/lib/auth/native-web-handoff"); + const startedAt = await nativeHandoffDbNow(); + // The driver adapter reads a `timestamptz` as if its offset were +00:00, + // so on a Berlin session this instant came back two hours late. + expect(Math.abs(startedAt.getTime() - Date.now())).toBeLessThan(60_000); + const created = await getPrismaClient().$queryRaw<{ created_at: Date }[]>` + INSERT INTO sessions (id, user_id, token_hash, expires_at) + VALUES ('session-handoff', ${USER_ID}, 'handoff-hash', + (NOW() AT TIME ZONE 'UTC') + INTERVAL '1 hour') + RETURNING created_at + `; + const createdAt = created[0].created_at; + expect(createdAt.getTime()).toBeGreaterThanOrEqual(startedAt.getTime()); + expect(createdAt.getTime() - startedAt.getTime()).toBeLessThan(60_000); + }); + + it("a rate-limit window resets one window from now", async () => { + const { checkRateLimit } = await import("@/lib/rate-limit"); + const before = Date.now(); + const result = await checkRateLimit("tz:window", 5, 60_000); + expect(result.allowed).toBe(true); + expect(result.resetAt - before).toBeGreaterThan(55_000); + expect(result.resetAt - before).toBeLessThan(65_000); + }); +}); + +describe("without the per-connection pin", () => { + it("the raw-SQL challenge expiry still holds on a Berlin session", async () => { + const unpinned = unpinnedClient(); + try { + const [{ TimeZone }] = + await unpinned.$queryRawUnsafe<{ TimeZone: string }[]>("SHOW TimeZone"); + expect(TimeZone).toBe(ZONE); + vi.resetModules(); + vi.doMock("@/lib/db", async (importOriginal) => ({ + ...(await importOriginal()), + prisma: unpinned, + })); + const { claimAuthChallenge } = await import("@/lib/auth/passkey"); + const fresh = await challenge(unpinned, "authentication"); + await expect( + claimAuthChallenge({ challengeId: fresh, type: "authentication" }), + ).resolves.not.toBeNull(); + const stale = await challenge(unpinned, "authentication", -1_000); + await expect( + claimAuthChallenge({ challengeId: stale, type: "authentication" }), + ).resolves.toBeNull(); + } finally { + vi.doUnmock("@/lib/db"); + await unpinned.$disconnect(); + } + }); +}); From 543589027597f3334bf4566da5634e8ea66556c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 12:33:02 +0200 Subject: [PATCH 08/61] test(notifications): cover tap targets, quick-answer tokens and the action route; document them --- docs/api/openapi.yaml | 112 ++++++++++++ docs/self-hosting/notifications.md | 89 ++++++++++ .../action/[token]/__tests__/route.test.ts | 154 ++++++++++++++++ .../__tests__/daily-briefing-push.test.ts | 63 +++++++ .../__tests__/action-token.test.ts | 168 ++++++++++++++++++ .../__tests__/delivery-links.test.ts | 115 ++++++++++++ .../__tests__/tap-target.test.ts | 166 +++++++++++++++++ .../__tests__/web-push-dose-clear.test.ts | 33 ++++ .../senders/__tests__/apns.test.ts | 46 +++++ .../senders/__tests__/email.test.ts | 22 +++ .../senders/__tests__/ntfy.test.ts | 85 +++++++++ .../senders/__tests__/telegram.test.ts | 114 ++++++++++++ .../senders/__tests__/webhook.test.ts | 29 +++ src/lib/notifications/tap-target.ts | 1 - src/lib/openapi/routes/notifications.ts | 2 +- .../notification-action-mood.test.ts | 103 +++++++++++ 16 files changed, 1300 insertions(+), 2 deletions(-) create mode 100644 src/app/api/notifications/action/[token]/__tests__/route.test.ts create mode 100644 src/lib/notifications/__tests__/action-token.test.ts create mode 100644 src/lib/notifications/__tests__/delivery-links.test.ts create mode 100644 src/lib/notifications/__tests__/tap-target.test.ts create mode 100644 tests/integration/notification-action-mood.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 734d96df3..9b95a5e98 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -13103,6 +13103,7 @@ paths: - WEB - TELEGRAM - DAYLIO + - NOTIFICATION - in: query name: from schema: @@ -21632,6 +21633,56 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + /api/notifications/action/{token}: + post: + tags: + - Notifications + summary: Log a quick answer tapped inside a notification + description: "Called by a notification app (ntfy `http` action), never by a signed-in client: the signed path token is + the only credential. The token is bound to one user and one answer, expires six hours after the reminder was + sent, and is single-use across all answers offered on that reminder. Rate-limited per client address before the + token is read. Writes a mood entry with source `NOTIFICATION`; refused with 403 when the mood module is off." + parameters: + - in: path + name: token + schema: + type: string + description: Signed action token from the notification's URL. + required: true + description: Signed action token from the notification's URL. + responses: + "200": + description: The answer was logged. + content: + application/json: + schema: + $ref: "#/components/schemas/NotificationActionResponse" + "401": + description: Invalid token (forged, altered, or unknown user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "403": + description: The mood module is turned off for this account. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: This reminder was already answered. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "410": + description: The answer expired. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a4 + "429": *a5 /api/notifications/preferences: get: tags: @@ -27417,6 +27468,14 @@ components: properties: clientManaged: type: boolean + content: + type: object + properties: + detail: + type: string + enum: + - generic + - detailed baseUpdatedAt: description: "Optimistic-concurrency base token: the `updatedAt` the client last read for this resource. Omit it for the legacy unconditional write (older clients are unaffected). When present, the write is guarded on the stored @@ -28816,6 +28875,7 @@ components: - WEB - TELEGRAM - DAYLIO + - NOTIFICATION externalId: description: "iOS-side source-stable id for idempotent dedup on the `(userId, source, externalId)` key. Must be stable across app restarts — an object description or memory address (``, `0x12ab34cd`) is a new value @@ -29336,6 +29396,7 @@ components: - WEB - TELEGRAM - DAYLIO + - NOTIFICATION externalId: type: string minLength: 1 @@ -52677,6 +52738,19 @@ components: - nudgeRoutine - nudgeFrequency additionalProperties: false + content: + type: object + properties: + detail: + type: string + enum: + - generic + - detailed + description: What a notification may show. "generic" (default) names what it is about with no health figure; "detailed" + adds figures such as the morning score. Tap targets and quick answers are the same in both. + required: + - detail + additionalProperties: false updatedAt: description: "Optimistic-concurrency token: the stored row's `updatedAt` at read/write time. Echo it back as `baseUpdatedAt` on the next write. Opaque — only ever echo a server-returned value, never parse or @@ -52689,6 +52763,7 @@ components: - mood - cycle - coach + - content additionalProperties: false PatchNotificationPrefsResponse: type: object @@ -65116,6 +65191,7 @@ components: - WEB - TELEGRAM - DAYLIO + - NOTIFICATION externalId: anyOf: - type: string @@ -78271,6 +78347,42 @@ components: required: - ok additionalProperties: false + NotificationActionResponse: + type: object + properties: + data: + $ref: "#/components/schemas/NotificationActionResult" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + NotificationActionResult: + type: object + properties: + logged: + type: boolean + const: true + action: + type: string + enum: + - mood-good + - mood-okay + - mood-bad + description: "The answer that was logged: mood-good = 4 (GUT), mood-okay = 3 (OKAY), mood-bad = 2 (SCHLECHT) on the + five-point mood scale." + required: + - logged + - action + additionalProperties: false + description: A quick answer from a notification, logged as a mood entry. GetNotificationPreferences: type: object properties: diff --git a/docs/self-hosting/notifications.md b/docs/self-hosting/notifications.md index c631f5138..0aa0d0761 100644 --- a/docs/self-hosting/notifications.md +++ b/docs/self-hosting/notifications.md @@ -234,6 +234,8 @@ choice existed keeps sending: message are masked as they are on every other channel. - `priority` is a word: `urgent` for an event flagged urgent, `high` for medication reminders, `default` for everything else. +- `url`, when `APP_URL` is set, is the page the notification leads to (see + [Tap targets](#what-tapping-a-notification-opens)). Absent otherwise. **Gotify** sends the body Gotify's `POST /message` endpoint expects: @@ -450,6 +452,93 @@ Two gotchas worth knowing before you blame a stale token: > in the web UI. If you only run the PWA, ignore it entirely — your > medication reminders come from the server cron over the channels above. +## Tap targets, content and quick answers + +### What tapping a notification opens + +Every notification leads somewhere in the app. The server decides where, +once per event type, and every channel carries the same target: + +| Notification | Opens | +| -------------------------------------------- | ---------------------------------------------------------------------------------- | +| Mood reminder | `/mood`, with the mood entry sheet already open | +| Morning briefing | the dashboard | +| Medication reminder, low supply, shared dose | `/medications` (low supply opens the medication) | +| Check-up and measurement reminders | `/checkups` | +| Proactive Coach nudge | the Coach conversation it started | +| Cycle reminders | `/cycle` (nothing with discreet notifications on) | +| New sign-in | Settings, Security | +| Integration sync failing | Settings, Integrations (an admin alert about another account opens the admin page) | +| Illness red flag | the illness episode | +| Other system alerts | the dashboard, or the admin status page for operator alerts | + +How each channel carries it: + +- **ntfy**: the `Click` header, 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 + on `localhost` gets no link. +- **Web Push**: the service worker opens the page, or focuses an open + HealthLog tab and navigates it there. +- **APNs**: the path rides the payload as `url`. +- **Webhook**: the generic body gains a `url` field; the Gotify body gains + `extras["client::notification"].click.url`, which the Gotify app opens. +- **Email**: a link line under the message. + +ntfy, Telegram, the webhook and email need an absolute link, built from +`APP_URL` (or `NEXT_PUBLIC_APP_URL`). Without one those channels deliver as +before, just without a link. A notification about a profile you look after +as a Guardian carries no deep link, because your session opens on your own +record. + +### Health figures in notifications + +Settings, Notifications, **Health figures in notifications** decides what a +notification may say on a lock screen: + +- **Off** (the default): a notification says what it is about and shows no + health figure. The morning briefing reads "Your morning briefing". +- **On**: notifications add their figures. The morning briefing becomes + "Morning score 78" with the day's one-line summary underneath. + +Tap targets and quick answers work the same either way. The setting is +stored as `content.detail` (`generic` or `detailed`) in the notification +preferences (`PATCH /api/auth/me/notification-prefs`) and travels with the +account backup. + +### Quick answers on the mood reminder + +On **ntfy** the mood reminder carries three buttons, **Good**, **Okay** and +**Bad**. Tapping one logs a mood entry straight from the notification, +without opening the app, and clears the notification. They map onto the +five-point mood scale as Good = 4, Okay = 3, Bad = 2; the outer points +stay in the app. The entry is marked with the source **Notification**. + +Each button calls `POST /api/notifications/action/`. The +token is the only credential, so it is built to be harmless on its own: + +- it is signed with `API_TOKEN_HMAC_KEY` and names one account and one + 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; +- 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. + +**Telegram** keeps its own keyboard on the mood reminder: the full 1 to 5 +scale, a note and a "later" button. + +**Web Push** offers no buttons. Most browsers show at most two actions, +which cannot carry a three-way answer, and Safari on iPhone shows none. +Tapping the notification opens the mood entry sheet instead. + +**APNs**: the iOS app draws its own buttons from the notification category +it registers for the mood reminder. + ## Which channel should I pick? | Your setup | Recommended channel | diff --git a/src/app/api/notifications/action/[token]/__tests__/route.test.ts b/src/app/api/notifications/action/[token]/__tests__/route.test.ts new file mode 100644 index 000000000..a77eda6cb --- /dev/null +++ b/src/app/api/notifications/action/[token]/__tests__/route.test.ts @@ -0,0 +1,154 @@ +/** + * POST /api/notifications/action/{token} — a quick answer from a + * notification. Pins the order of the gates and every refusal class: rate + * limit before the token is read, then signature, expiry, account, module, + * and the single-use write. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +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() } }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ + checkAuthSurfaceRateLimit: vi.fn(), + rateLimitHeaders: vi.fn(() => ({ "Retry-After": "60" })), +})); +vi.mock("@/lib/modules/gate", () => ({ isModuleEnabled: vi.fn() })); +vi.mock("@/lib/mood/create-from-telegram", () => ({ + logTelegramMood: vi.fn(), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; +import { isModuleEnabled } from "@/lib/modules/gate"; +import { logTelegramMood } from "@/lib/mood/create-from-telegram"; +import { + ACTION_TOKEN_TTL_MS, + mintActionToken, +} from "@/lib/notifications/action-token"; + +function call(token: string) { + const req = new NextRequest( + `http://localhost/api/notifications/action/${token}`, + { method: "POST" }, + ); + return POST(req, { params: Promise.resolve({ token }) }); +} + +function token(over: Partial[0]> = {}) { + return mintActionToken({ + userId: "user-1", + action: "mood-okay", + nonce: "nonce-1", + ...over, + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.stubEnv("API_TOKEN_HMAC_KEY", TEST_HMAC_KEY); + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: true, + remaining: 19, + resetAt: new Date(), + ip: "203.0.113.5", + } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + id: "user-1", + timezone: "Europe/Berlin", + } as never); + vi.mocked(isModuleEnabled).mockResolvedValue(true); + vi.mocked(logTelegramMood).mockResolvedValue({ + created: true, + moodEntryId: "m1", + score: 3, + }); +}); + +describe("POST /api/notifications/action/{token}", () => { + it("logs the answer under the reminder's nonce with the notification source", async () => { + const res = await call(token()); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ + data: { logged: true, action: "mood-okay" }, + error: null, + }); + expect(logTelegramMood).toHaveBeenCalledWith({ + userId: "user-1", + score: 3, + tz: "Europe/Berlin", + externalId: "notification-action:nonce-1", + source: "NOTIFICATION", + }); + }); + + it("is rate-limited before the token is read", async () => { + vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ + allowed: false, + remaining: 0, + resetAt: new Date(), + ip: "203.0.113.5", + } as never); + const res = await call(token()); + expect(res.status).toBe(429); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + expect(logTelegramMood).not.toHaveBeenCalled(); + }); + + it("refuses a tampered token with 401 and writes nothing", async () => { + const forged = token().replace("user-1", "user-2"); + const res = await call(forged); + expect(res.status).toBe(401); + expect(prisma.user.findUnique).not.toHaveBeenCalled(); + expect(logTelegramMood).not.toHaveBeenCalled(); + }); + + it("refuses an expired token with 410", async () => { + const res = await call( + token({ now: Date.now() - ACTION_TOKEN_TTL_MS - 1 }), + ); + expect(res.status).toBe(410); + expect(logTelegramMood).not.toHaveBeenCalled(); + }); + + it("refuses a token for an account that no longer exists", async () => { + vi.mocked(prisma.user.findUnique).mockResolvedValue(null); + const res = await call(token()); + expect(res.status).toBe(401); + expect(logTelegramMood).not.toHaveBeenCalled(); + }); + + it("writes only for the user the token names", async () => { + await call(token({ userId: "user-9" })); + expect(prisma.user.findUnique).toHaveBeenCalledWith( + expect.objectContaining({ where: { id: "user-9" } }), + ); + }); + + it("refuses with 403 when the mood module was switched off", async () => { + vi.mocked(isModuleEnabled).mockResolvedValue(false); + const res = await call(token()); + expect(res.status).toBe(403); + expect(isModuleEnabled).toHaveBeenCalledWith("user-1", "mood"); + expect(logTelegramMood).not.toHaveBeenCalled(); + }); + + it("refuses a reused reminder with 409", async () => { + vi.mocked(logTelegramMood).mockResolvedValue({ + created: false, + moodEntryId: "m1", + score: 3, + }); + const res = await call(token({ action: "mood-bad" })); + expect(res.status).toBe(409); + }); +}); diff --git a/src/lib/daily/__tests__/daily-briefing-push.test.ts b/src/lib/daily/__tests__/daily-briefing-push.test.ts index f1191cc27..1bffd2983 100644 --- a/src/lib/daily/__tests__/daily-briefing-push.test.ts +++ b/src/lib/daily/__tests__/daily-briefing-push.test.ts @@ -123,6 +123,69 @@ describe("buildDailyBriefingPush", () => { }); }); +describe("buildDailyBriefingPush — content detail", () => { + const { t } = getServerTranslator("en"); + + it("generic (the default) keeps the score off the lock screen", () => { + const { title, body } = buildDailyBriefingPush(makeDigest(), t); + expect(title).toBe("Your morning briefing"); + expect(`${title} ${body}`).not.toMatch(/\d/); + }); + + it("detailed puts the score in the title and keeps the line as the label", () => { + const { title, body } = buildDailyBriefingPush(makeDigest(), t, { + showFigures: true, + }); + expect(title).toBe("Morning score 82"); + expect(body).toBe("Sleep looked solid last night."); + }); + + it("detailed swaps the 'score is ready' floor for the band label", () => { + const digest = makeDigest({ + briefingLead: null, + score: { value: 64.6, band: "yellow", delta: null }, + line: t("daily.line.scoreReady"), + }); + const { title, body } = buildDailyBriefingPush(digest, t, { + showFigures: true, + }); + expect(title).toBe("Morning score 65"); + expect(body).toBe(t("daily.push.band.yellow")); + }); + + it("detailed without a score falls back to the plain title", () => { + const { title } = buildDailyBriefingPush(makeDigest({ score: null }), t, { + showFigures: true, + }); + expect(title).toBe("Your morning briefing"); + }); + + it("reads the choice from the user's notification preferences", async () => { + const detailed = makeDeps(); + await maybeDispatchDailyBriefing( + makePrisma({ + user: { + id: "u1", + timezone: "Europe/Berlin", + locale: "en", + morningDigestRefreshedOn: null, + notificationPrefs: { content: { detail: "detailed" } }, + }, + }), + "u1", + IN_WINDOW, + detailed, + ); + expect(detailed.dispatch.mock.calls[0][0].title).toBe("Morning score 82"); + + const generic = makeDeps(); + await maybeDispatchDailyBriefing(makePrisma({}), "u1", IN_WINDOW, generic); + expect(generic.dispatch.mock.calls[0][0].title).toBe( + "Your morning briefing", + ); + }); +}); + describe("digestHasSubstance", () => { it("is false for an empty account (score/lead/signal all null, no items)", () => { expect( diff --git a/src/lib/notifications/__tests__/action-token.test.ts b/src/lib/notifications/__tests__/action-token.test.ts new file mode 100644 index 000000000..1c0eb4774 --- /dev/null +++ b/src/lib/notifications/__tests__/action-token.test.ts @@ -0,0 +1,168 @@ +/** + * Notification quick-action tokens: what a URL holding one can and cannot do. + */ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { + ACTION_TOKEN_TTL_MS, + MOOD_QUICK_ACTIONS, + QUICK_ACTION_IDS, + buildMoodQuickActions, + mintActionToken, + quickActionExternalId, + verifyActionToken, +} from "@/lib/notifications/action-token"; + +const ORIGINAL_KEY = process.env.API_TOKEN_HMAC_KEY; +const NOW = Date.UTC(2026, 9, 11, 20, 0, 0); + +beforeEach(() => { + process.env.API_TOKEN_HMAC_KEY = "k".repeat(64); +}); + +afterEach(() => { + if (ORIGINAL_KEY === undefined) delete process.env.API_TOKEN_HMAC_KEY; + else process.env.API_TOKEN_HMAC_KEY = ORIGINAL_KEY; +}); + +function mint(over: Partial[0]> = {}) { + return mintActionToken({ + userId: "user_a", + action: "mood-good", + nonce: "nonce1", + now: NOW, + ...over, + }); +} + +/** Replace one dot-separated field, keeping the original signature. */ +function withField(token: string, index: number, value: string): string { + const parts = token.split("."); + parts[index] = value; + return parts.join("."); +} + +describe("mood quick actions", () => { + it("maps good / okay / bad onto the five-point scale (4 / 3 / 2)", () => { + expect(MOOD_QUICK_ACTIONS).toEqual({ + "mood-good": 4, + "mood-okay": 3, + "mood-bad": 2, + }); + expect(QUICK_ACTION_IDS).toHaveLength(3); + }); + + it("builds three path-safe URLs sharing one nonce", () => { + const actions = buildMoodQuickActions({ + userId: "user_a", + origin: "https://health.example", + labels: { "mood-good": "Good", "mood-okay": "Okay", "mood-bad": "Bad" }, + now: NOW, + }); + expect(actions.map((a) => a.id)).toEqual([ + "mood-good", + "mood-okay", + "mood-bad", + ]); + const nonces = new Set(); + for (const action of actions) { + expect(action.url).toMatch( + /^https:\/\/health\.example\/api\/notifications\/action\/[A-Za-z0-9._-]+$/, + ); + const token = action.url.split("/").at(-1) ?? ""; + const verdict = verifyActionToken(token, NOW); + expect(verdict.ok).toBe(true); + if (verdict.ok) { + expect(verdict.action).toBe(action.id); + nonces.add(verdict.nonce); + } + } + // One nonce: whichever answer lands first uses up the other two. + expect(nonces.size).toBe(1); + }); + + it("keys the single-use write on the nonce", () => { + expect(quickActionExternalId("abc")).toBe("notification-action:abc"); + }); +}); + +describe("verifyActionToken", () => { + it("accepts a fresh token and returns what it is bound to", () => { + const verdict = verifyActionToken(mint(), NOW + 1000); + expect(verdict).toEqual({ + ok: true, + userId: "user_a", + action: "mood-good", + nonce: "nonce1", + expiry: NOW + ACTION_TOKEN_TTL_MS, + }); + }); + + it("refuses a token at and after its expiry", () => { + const token = mint(); + expect(verifyActionToken(token, NOW + ACTION_TOKEN_TTL_MS - 1).ok).toBe( + true, + ); + expect(verifyActionToken(token, NOW + ACTION_TOKEN_TTL_MS)).toEqual({ + ok: false, + reason: "expired", + }); + }); + + it("refuses a token moved to another user", () => { + expect(verifyActionToken(withField(mint(), 1, "user_b"), NOW)).toEqual({ + ok: false, + reason: "bad_signature", + }); + }); + + it("refuses a token switched to another answer", () => { + expect(verifyActionToken(withField(mint(), 2, "mood-bad"), NOW)).toEqual({ + ok: false, + reason: "bad_signature", + }); + }); + + it("refuses an extended expiry as a forgery, not as fresh", () => { + const later = String(NOW + 10 * ACTION_TOKEN_TTL_MS); + expect( + verifyActionToken( + withField(mint(), 3, later), + NOW + 2 * ACTION_TOKEN_TTL_MS, + ), + ).toEqual({ ok: false, reason: "bad_signature" }); + }); + + it("refuses a tampered signature and a token signed under another key", () => { + const token = mint(); + const tag = token.split(".").at(-1) ?? ""; + const flipped = `${tag.slice(0, -1)}${tag.endsWith("A") ? "B" : "A"}`; + expect(verifyActionToken(withField(token, 5, flipped), NOW).ok).toBe(false); + + process.env.API_TOKEN_HMAC_KEY = "z".repeat(64); + expect(verifyActionToken(token, NOW)).toEqual({ + ok: false, + reason: "bad_signature", + }); + }); + + it.each([ + [""], + ["na1.user.mood-good.1.n"], + ["na2.user_a.mood-good.1.n.tag"], + ["na1.user_a.mood-great.1.n.tag"], + ["na1.user_a.mood-good.01.n.tag"], + ["na1..mood-good.1.n.tag"], + ["x".repeat(400)], + ])("refuses the malformed token %j", (token) => { + expect(verifyActionToken(token, NOW)).toEqual({ + ok: false, + reason: "malformed", + }); + }); + + it("refuses to mint without a signing key", () => { + delete process.env.API_TOKEN_HMAC_KEY; + expect(() => mint()).toThrow(/API_TOKEN_HMAC_KEY/); + }); +}); diff --git a/src/lib/notifications/__tests__/delivery-links.test.ts b/src/lib/notifications/__tests__/delivery-links.test.ts new file mode 100644 index 000000000..b609b26b0 --- /dev/null +++ b/src/lib/notifications/__tests__/delivery-links.test.ts @@ -0,0 +1,115 @@ +/** + * The dispatcher's one place for tap targets and quick actions. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { userFindUnique } = vi.hoisted(() => ({ userFindUnique: vi.fn() })); + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: userFindUnique } }, +})); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addWarning: vi.fn() }), +})); + +import { withDeliveryLinks } from "@/lib/notifications/delivery-links"; +import { verifyActionToken } from "@/lib/notifications/action-token"; +import type { NotificationPayload } from "@/lib/notifications/types"; + +const ORIGINAL = { + APP_URL: process.env.APP_URL, + NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, + API_TOKEN_HMAC_KEY: process.env.API_TOKEN_HMAC_KEY, +}; + +beforeEach(() => { + process.env.APP_URL = "https://health.example"; + delete process.env.NEXT_PUBLIC_APP_URL; + process.env.API_TOKEN_HMAC_KEY = "k".repeat(64); + userFindUnique.mockResolvedValue({ locale: "de" }); +}); + +afterEach(() => { + vi.clearAllMocks(); + for (const [key, value] of Object.entries(ORIGINAL)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +}); + +function payload(over: Partial = {}): NotificationPayload { + return { + eventType: "MOOD_REMINDER", + userId: "u1", + recordUserId: "u1", + recipientUserId: "u1", + title: "t", + message: "m", + ...over, + }; +} + +describe("withDeliveryLinks", () => { + it("attaches the tap target in the recipient's language", async () => { + const out = await withDeliveryLinks( + payload({ eventType: "DAILY_BRIEFING" }), + "u1", + ); + expect(out.tap).toEqual({ + path: "/", + url: "https://health.example/", + label: "In HealthLog öffnen", + }); + expect(out.quickActions).toBeUndefined(); + }); + + it("gives a self-delivered mood reminder three signed answers for its recipient", async () => { + const now = Date.UTC(2026, 9, 11, 20); + const out = await withDeliveryLinks(payload(), "u1", now); + expect(out.tap?.url).toBe("https://health.example/mood?add=1"); + expect(out.quickActions?.map((a) => [a.id, a.label])).toEqual([ + ["mood-good", "Gut"], + ["mood-okay", "Okay"], + ["mood-bad", "Schlecht"], + ]); + for (const action of out.quickActions ?? []) { + const verdict = verifyActionToken(action.url.split("/").at(-1), now); + expect(verdict.ok && verdict.userId).toBe("u1"); + } + }); + + it("offers no answers to a Guardian", async () => { + const out = await withDeliveryLinks( + payload({ recordUserId: "record-1", recipientUserId: "guardian-1" }), + "guardian-1", + ); + expect(out.quickActions).toBeUndefined(); + expect(out.tap).toBeUndefined(); + }); + + it("keeps the path but drops absolute URLs and answers without a public origin", async () => { + delete process.env.APP_URL; + const out = await withDeliveryLinks(payload(), "u1"); + expect(out.tap).toEqual({ + path: "/mood?add=1", + url: null, + label: "In HealthLog öffnen", + }); + expect(out.quickActions).toBeUndefined(); + }); + + it("still delivers, without answers, when no signing key is configured", async () => { + delete process.env.API_TOKEN_HMAC_KEY; + const out = await withDeliveryLinks(payload(), "u1"); + expect(out.quickActions).toBeUndefined(); + expect(out.tap?.path).toBe("/mood?add=1"); + }); + + it("returns the silent sync push untouched, without a locale read", async () => { + const input = payload({ eventType: "MEDICATION_INTAKE_SYNC" }); + const out = await withDeliveryLinks(input, "u1"); + expect(out).toBe(input); + expect(userFindUnique).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/notifications/__tests__/tap-target.test.ts b/src/lib/notifications/__tests__/tap-target.test.ts new file mode 100644 index 000000000..676e44e4b --- /dev/null +++ b/src/lib/notifications/__tests__/tap-target.test.ts @@ -0,0 +1,166 @@ +/** + * The event → tap-target map and its resolver. + * + * Walks `EVENT_TYPES` rather than the map's own keys, so an event type added + * to the union without a decision about where it leads fails here (the + * `Record` type already refuses to compile without one; this + * pins that every entry is also a usable path). + */ +import { afterEach, describe, expect, it } from "vitest"; + +import { EVENT_TYPES, type EventType } from "@/lib/notifications/types"; +import { + NOTIFICATION_TAP_PATHS, + absoluteAppUrl, + configuredAppOrigin, + isSafeAppPath, + resolveTapPath, + telegramLinkMode, +} from "@/lib/notifications/tap-target"; + +/** Events never shown to the user, so they carry no target. */ +const SILENT: ReadonlySet = new Set(["MEDICATION_INTAKE_SYNC"]); + +const ORIGINAL = { + APP_URL: process.env.APP_URL, + NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, +}; + +afterEach(() => { + for (const [key, value] of Object.entries(ORIGINAL)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } +}); + +describe("NOTIFICATION_TAP_PATHS", () => { + it("names a target for every event type", () => { + expect(EVENT_TYPES.length).toBeGreaterThan(0); + for (const eventType of EVENT_TYPES) { + expect( + Object.prototype.hasOwnProperty.call(NOTIFICATION_TAP_PATHS, eventType), + `${eventType} has no tap target`, + ).toBe(true); + const path = NOTIFICATION_TAP_PATHS[eventType]; + if (SILENT.has(eventType)) { + expect(path, `${eventType} is silent`).toBeNull(); + } else { + expect(isSafeAppPath(path), `${eventType} → ${path}`).toBe(true); + expect(resolveTapPath({ eventType }), eventType).toBe(path); + } + } + }); + + it("keeps the targets the issue names", () => { + expect(NOTIFICATION_TAP_PATHS.MOOD_REMINDER).toBe("/mood?add=1"); + expect(NOTIFICATION_TAP_PATHS.DAILY_BRIEFING).toBe("/"); + expect(NOTIFICATION_TAP_PATHS.WITHINGS_SYNC_FAILED).toBe( + "/settings/integrations", + ); + expect(NOTIFICATION_TAP_PATHS.MEDICATION_REMINDER).toBe("/medications"); + }); + + it("has no entry for an event type outside the union", () => { + expect(Object.keys(NOTIFICATION_TAP_PATHS).sort()).toEqual( + [...EVENT_TYPES].sort(), + ); + }); +}); + +describe("resolveTapPath", () => { + it("takes a producer's same-origin path over the default", () => { + expect( + resolveTapPath({ + eventType: "MEDICATION_LOW_STOCK", + metadata: { url: "/medications/m1?tab=bestand" }, + }), + ).toBe("/medications/m1?tab=bestand"); + }); + + it.each([ + "https://evil.example/", + "//evil.example/x", + "/\\evil.example", + "javascript:alert(1)", + "/a b", + "", + 42, + ])("ignores an unsafe override %j", (url) => { + expect( + resolveTapPath({ eventType: "COACH_NUDGE", metadata: { url } }), + ).toBe("/coach"); + }); + + it("gives a managed Guardian delivery no path", () => { + expect( + resolveTapPath({ + eventType: "MEDICATION_REMINDER", + recordUserId: "record-1", + recipientUserId: "guardian-1", + }), + ).toBeNull(); + }); + + it("keeps a self delivery's path when both ids are the same", () => { + expect( + resolveTapPath({ + eventType: "MEDICATION_REMINDER", + recordUserId: "u1", + recipientUserId: "u1", + }), + ).toBe("/medications"); + }); + + it("gives a discreet delivery no path, so nothing names the cycle surface", () => { + expect( + resolveTapPath({ eventType: "CYCLE_PERIOD_SOON", discreet: true }), + ).toBeNull(); + }); + + it("gives the silent sync push no path even with an override", () => { + expect( + resolveTapPath({ + eventType: "MEDICATION_INTAKE_SYNC", + metadata: { url: "/medications" }, + }), + ).toBeNull(); + }); +}); + +describe("configuredAppOrigin / absoluteAppUrl", () => { + it("prefers APP_URL, then NEXT_PUBLIC_APP_URL, and keeps only the origin", () => { + process.env.APP_URL = "https://health.example/some/path"; + process.env.NEXT_PUBLIC_APP_URL = "https://other.example"; + expect(configuredAppOrigin()).toBe("https://health.example"); + delete process.env.APP_URL; + expect(configuredAppOrigin()).toBe("https://other.example"); + }); + + it("returns null without a usable origin, and so no absolute URL", () => { + delete process.env.APP_URL; + process.env.NEXT_PUBLIC_APP_URL = "not a url"; + expect(configuredAppOrigin()).toBeNull(); + expect(absoluteAppUrl("/mood?add=1")).toBeNull(); + }); + + it("joins origin and path", () => { + expect(absoluteAppUrl("/mood?add=1", "https://h.example")).toBe( + "https://h.example/mood?add=1", + ); + expect(absoluteAppUrl(null, "https://h.example")).toBeNull(); + }); +}); + +describe("telegramLinkMode", () => { + it.each([ + ["https://health.example/", "button"], + ["http://nas.lan:3000/", "text"], + ["https://localhost:3000/", "none"], + ["http://127.0.0.1/", "none"], + ["https://app.localhost/", "none"], + ["ftp://health.example/", "none"], + ["nonsense", "none"], + ])("%s → %s", (url, mode) => { + expect(telegramLinkMode(url)).toBe(mode); + }); +}); diff --git a/src/lib/notifications/__tests__/web-push-dose-clear.test.ts b/src/lib/notifications/__tests__/web-push-dose-clear.test.ts index 6b9eb9d66..cbdb11ffe 100644 --- a/src/lib/notifications/__tests__/web-push-dose-clear.test.ts +++ b/src/lib/notifications/__tests__/web-push-dose-clear.test.ts @@ -190,6 +190,39 @@ describe("web-push reminder push — stable tag + badge", () => { }); }); +describe("web-push tap target", () => { + async function sentBody(payload: Parameters[1]) { + findManyMock.mockResolvedValue([SUB]); + vi.spyOn(global, "fetch").mockResolvedValue( + new Response(null, { status: 201 }), + ); + await sendViaWebPush("user-1", payload); + return JSON.parse(generateRequestDetailsMock.mock.calls[0][1] as string); + } + + it("carries the dispatcher's resolved tap path as data url", async () => { + const body = await sentBody({ + eventType: "MOOD_REMINDER", + userId: "user-1", + title: "Log your mood", + message: "How are you feeling today?", + tap: { path: "/mood?add=1", url: null, label: "Open" }, + }); + expect(body.url).toBe("/mood?add=1"); + }); + + it("refuses a protocol-relative metadata url when no tap was resolved", async () => { + const body = await sentBody({ + eventType: "SYSTEM_ALERT", + userId: "user-1", + title: "t", + message: "m", + metadata: { url: "//evil.example/x" }, + }); + expect(body.url).toBe("/"); + }); +}); + describe("clear-on-taken push", () => { it("emits type:clear with the SAME slot tag as the reminder + badge", async () => { findManyMock.mockResolvedValue([SUB]); diff --git a/src/lib/notifications/senders/__tests__/apns.test.ts b/src/lib/notifications/senders/__tests__/apns.test.ts index c7c68b849..f60b60d25 100644 --- a/src/lib/notifications/senders/__tests__/apns.test.ts +++ b/src/lib/notifications/senders/__tests__/apns.test.ts @@ -698,6 +698,52 @@ describe("sendViaApns — dispatcher fan-out", () => { expect(note.payload).toEqual({ eventType: "MEDICATION_REMINDER" }); }); + it("carries the resolved tap path as the `url` payload key", async () => { + vi.mocked(prisma.device.findMany).mockResolvedValueOnce([ + { id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" }, + ] as never); + sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] }); + + await sendViaApns("u-1", { + title: "t", + message: "m", + eventType: "MOOD_REMINDER", + metadata: { localDate: "2026-10-11" }, + tap: { + path: "/mood?add=1", + url: "https://health.example/mood?add=1", + label: "Open", + }, + }); + + const note = sendMock.mock.calls[0][0]; + expect(note.payload).toEqual({ + eventType: "MOOD_REMINDER", + localDate: "2026-10-11", + url: "/mood?add=1", + }); + // Unchanged keys: the category still names the event. + expect(note.category).toBe("MOOD_REMINDER"); + }); + + it("never gives a managed Guardian a tap path, even when one is set", async () => { + vi.mocked(prisma.device.findMany).mockResolvedValueOnce([ + { id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" }, + ] as never); + sendMock.mockResolvedValueOnce({ sent: [{ device: "tok-a" }], failed: [] }); + + await sendViaApns("guardian-1", { + title: "t", + message: "m", + eventType: "MEDICATION_REMINDER", + recordUserId: "record-1", + recipientUserId: "guardian-1", + tap: { path: "/medications", url: null, label: "Open" }, + }); + + expect(sendMock.mock.calls[0][0].payload.url).toBeUndefined(); + }); + it("forwards reminderId on MEASUREMENT_REMINDER and strips unlisted keys", async () => { // The iOS "Erledigt" action POSTs the reminder completion directly // from a foreground / handled notification, so the reminder's id must diff --git a/src/lib/notifications/senders/__tests__/email.test.ts b/src/lib/notifications/senders/__tests__/email.test.ts index b785b1130..1f1452176 100644 --- a/src/lib/notifications/senders/__tests__/email.test.ts +++ b/src/lib/notifications/senders/__tests__/email.test.ts @@ -277,3 +277,25 @@ describe("sendViaEmail — transport and envelope faults are named too", () => { expect(result.failureCode).toBe(failureCode); }); }); + +describe("sendViaEmail — tap target", () => { + it("appends the tap link as a plain-text line", async () => { + loadEmailConfigMock.mockReturnValue(transport); + sendMailMock.mockResolvedValue({ messageId: "1" }); + + await sendViaEmail( + { recipient: "you@example.com" }, + payload({ + tap: { + path: "/settings/security", + url: "https://health.example/settings/security", + label: "Open in HealthLog", + }, + }), + ); + + expect(sendMailMock.mock.calls[0][0].text).toBe( + "Body\n\nOpen in HealthLog: https://health.example/settings/security", + ); + }); +}); diff --git a/src/lib/notifications/senders/__tests__/ntfy.test.ts b/src/lib/notifications/senders/__tests__/ntfy.test.ts index e38efa619..8a28200fb 100644 --- a/src/lib/notifications/senders/__tests__/ntfy.test.ts +++ b/src/lib/notifications/senders/__tests__/ntfy.test.ts @@ -268,3 +268,88 @@ describe("sendViaNtfy — what the server said (#947)", () => { expect((await sendViaNtfy(config, payload())).failureCode).toBe("timeout"); }); }); + +describe("sendViaNtfy — tap target and quick actions", () => { + function headersOfFirstCall(): Record { + const [, init] = safeFetchMock.mock.calls[0] as [ + string, + { headers: Record }, + ]; + return init.headers; + } + + it("sets Click to the resolved absolute tap URL", async () => { + safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); + await sendViaNtfy( + config, + payload({ + eventType: "DAILY_BRIEFING", + tap: { + path: "/", + url: "https://health.example/", + label: "Open in HealthLog", + }, + }), + ); + expect(headersOfFirstCall().Click).toBe("https://health.example/"); + expect(headersOfFirstCall().Actions).toBeUndefined(); + }); + + 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(); + }); + + it("sends the quick answers as three POST http actions that clear the notification", async () => { + safeFetchMock.mockResolvedValue({ ok: true, status: 200 }); + await sendViaNtfy( + config, + payload({ + eventType: "MOOD_REMINDER", + quickActions: [ + { id: "mood-good", label: "Good", url: "https://h.example/a/1" }, + { id: "mood-okay", label: "Okay", url: "https://h.example/a/2" }, + { id: "mood-bad", label: "Bad", url: "https://h.example/a/3" }, + ], + }), + ); + 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", + ); + }); +}); + +describe("ntfy header helpers", () => { + it("caps the actions at three and strips separators from labels", async () => { + const { buildNtfyActionsHeader } = await import("../ntfy"); + const header = buildNtfyActionsHeader({ + quickActions: [ + { id: "mood-good", label: 'Go;od, "yes"', url: "https://h/1" }, + { id: "mood-okay", label: "B", url: "https://h/2" }, + { id: "mood-bad", label: "C", url: "https://h/3" }, + { 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(); + }); + + 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\?/); + }); +}); diff --git a/src/lib/notifications/senders/__tests__/telegram.test.ts b/src/lib/notifications/senders/__tests__/telegram.test.ts index 14455e171..0299caacf 100644 --- a/src/lib/notifications/senders/__tests__/telegram.test.ts +++ b/src/lib/notifications/senders/__tests__/telegram.test.ts @@ -347,3 +347,117 @@ describe("sendViaTelegram — interactive mood + measurement (v1.19.0)", () => { expect(scheduleTelegramAutoDeleteMock).not.toHaveBeenCalled(); }); }); + +describe("sendViaTelegram — tap target", () => { + beforeEach(() => { + sendTelegramMessageMock.mockResolvedValue({ ok: true, messageId: 5 }); + findUniqueMock.mockResolvedValue(null); + upsertMock.mockResolvedValue(undefined); + userFindUniqueMock.mockResolvedValue({ locale: "en" }); + scheduleTelegramAutoDeleteMock.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + function sent(): { + text: string; + replyMarkup?: { inline_keyboard: Record[][] }; + } { + const call = sendTelegramMessageMock.mock.calls[0] as [ + string, + string, + string, + { replyMarkup?: { inline_keyboard: Record[][] } }, + ]; + return { text: call[2], replyMarkup: call[3].replyMarkup }; + } + + it("adds an https URL button as its own row under the action buttons", async () => { + await sendViaTelegram(config, { + ...reminderPayload(), + tap: { + path: "/medications", + url: "https://health.example/medications", + label: "Open in HealthLog", + }, + }); + const rows = sent().replyMarkup?.inline_keyboard ?? []; + // The medication keyboard keeps its two callback rows first. + expect(rows[0]?.[0]?.callback_data).toBe("taken:med-1"); + expect(rows.at(-1)).toEqual([ + { + text: "Open in HealthLog", + url: "https://health.example/medications", + }, + ]); + expect(sent().text).toBe("m"); + }); + + it("creates a keyboard with only the URL button for an event without actions", async () => { + await sendViaTelegram(config, { + eventType: "DAILY_BRIEFING", + userId: "user-1", + title: "t", + message: "line", + tap: { path: "/", url: "https://health.example/", label: "Open" }, + }); + expect(sent().replyMarkup).toEqual({ + inline_keyboard: [[{ text: "Open", url: "https://health.example/" }]], + }); + }); + + it("falls back to an escaped text link on a plain-HTTP origin Telegram refuses as a button", async () => { + await sendViaTelegram(config, { + eventType: "DAILY_BRIEFING", + userId: "user-1", + title: "t", + message: "line", + tap: { + path: "/?a=1&b=2", + url: "http://nas.lan/?a=1&b=2", + label: "Open ", + }, + }); + expect(sent().replyMarkup).toBeUndefined(); + expect(sent().text).toBe( + 'line\n\nOpen <app>', + ); + }); + + it("carries no link at all for a loopback origin", async () => { + await sendViaTelegram(config, { + eventType: "DAILY_BRIEFING", + userId: "user-1", + title: "t", + message: "line", + tap: { path: "/", url: "https://localhost:3000/", label: "Open" }, + }); + expect(sent().replyMarkup).toBeUndefined(); + expect(sent().text).toBe("line"); + }); + + it("keeps the 1–5 mood keyboard and adds the link row to the mood reminder", async () => { + await sendViaTelegram(config, { + eventType: "MOOD_REMINDER", + userId: "user-1", + title: "t", + message: "How are you?", + tap: { + path: "/mood?add=1", + url: "https://health.example/mood?add=1", + label: "Open", + }, + }); + const rows = sent().replyMarkup?.inline_keyboard ?? []; + expect(rows[0]?.map((b) => b.callback_data)).toEqual([ + "mood:1", + "mood:2", + "mood:3", + "mood:4", + "mood:5", + ]); + expect(rows.at(-1)?.[0]?.url).toBe("https://health.example/mood?add=1"); + }); +}); diff --git a/src/lib/notifications/senders/__tests__/webhook.test.ts b/src/lib/notifications/senders/__tests__/webhook.test.ts index 5850fa067..d07aa55c3 100644 --- a/src/lib/notifications/senders/__tests__/webhook.test.ts +++ b/src/lib/notifications/senders/__tests__/webhook.test.ts @@ -566,3 +566,32 @@ describe("sendViaWebhook — Gotify format (#947)", () => { expect(JSON.parse(sentBody()).priority).toBe("default"); }); }); + +describe("buildWebhookBody — tap target", () => { + const tap = { + path: "/checkups", + url: "https://health.example/checkups", + label: "Open in HealthLog", + }; + + it("adds the absolute tap URL to the generic body", () => { + const body = JSON.parse(buildWebhookBody({}, payload({ tap }))); + expect(body.url).toBe("https://health.example/checkups"); + }); + + it("leaves the generic body without `url` when no public origin is set", () => { + const body = JSON.parse( + buildWebhookBody({}, payload({ tap: { ...tap, url: null } })), + ); + expect(body).not.toHaveProperty("url"); + }); + + it("gives Gotify a click URL in its notification extras", () => { + const body = JSON.parse( + buildWebhookBody({ format: "gotify" }, payload({ tap })), + ); + expect(body.extras["client::notification"]).toEqual({ + click: { url: "https://health.example/checkups" }, + }); + }); +}); diff --git a/src/lib/notifications/tap-target.ts b/src/lib/notifications/tap-target.ts index cd3d97208..10bb30de4 100644 --- a/src/lib/notifications/tap-target.ts +++ b/src/lib/notifications/tap-target.ts @@ -63,7 +63,6 @@ export function isSafeAppPath(value: unknown): value is string { if (!value.startsWith("/")) return false; if (value.startsWith("//")) return false; if (value.includes("\\")) return false; - // eslint-disable-next-line no-control-regex if (/[\s\u0000-\u001f\u007f]/.test(value)) return false; return true; } diff --git a/src/lib/openapi/routes/notifications.ts b/src/lib/openapi/routes/notifications.ts index cf3868411..da0d64aad 100644 --- a/src/lib/openapi/routes/notifications.ts +++ b/src/lib/openapi/routes/notifications.ts @@ -188,7 +188,7 @@ export const notificationChannelPaths: NonNullable = "application/json": { schema: dataEnvelope( notificationActionResult, - "NotificationActionResult", + "NotificationActionResponse", ), }, }, diff --git a/tests/integration/notification-action-mood.test.ts b/tests/integration/notification-action-mood.test.ts new file mode 100644 index 000000000..ad7ba364f --- /dev/null +++ b/tests/integration/notification-action-mood.test.ts @@ -0,0 +1,103 @@ +/** + * v1.42.2 — a mood quick answer from a notification writes exactly once. + * + * The three answers on one reminder share a nonce, and the write is keyed on + * it through `MoodEntry`'s unique `(userId, source, externalId)`. Against + * real Postgres and the real route: the first answer logs, a repeat of the + * same answer, a different answer on the same reminder, and two answers + * racing each other all leave exactly one row behind. + */ +import { NextRequest } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const USER = "user-notification-action"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +function call(token: string) { + return import("@/app/api/notifications/action/[token]/route").then( + ({ POST }) => + POST( + new NextRequest(`http://localhost/api/notifications/action/${token}`, { + method: "POST", + }), + { params: Promise.resolve({ token }) }, + ), + ); +} + +async function tokens(nonce: string) { + const { mintActionToken } = await import("@/lib/notifications/action-token"); + return { + good: mintActionToken({ userId: USER, action: "mood-good", nonce }), + bad: mintActionToken({ userId: USER, action: "mood-bad", nonce }), + }; +} + +beforeEach(async () => { + vi.stubEnv( + "API_TOKEN_HMAC_KEY", + "integration-hmac-key-at-least-32-characters", + ); + await truncateAllTables(getPrismaClient()); + await getPrismaClient().user.create({ + data: { + id: USER, + username: "notification-action", + email: "notification-action@example.test", + timezone: "Europe/Berlin", + }, + }); +}); + +describe("POST /api/notifications/action/{token}", () => { + it("logs the first answer once and refuses every later answer on the reminder", async () => { + const { good, bad } = await tokens("reminder-1"); + + const first = await call(good); + expect(first.status).toBe(200); + const repeat = await call(good); + expect(repeat.status).toBe(409); + const other = await call(bad); + expect(other.status).toBe(409); + + const rows = await getPrismaClient().moodEntry.findMany({ + where: { userId: USER }, + select: { source: true, externalId: true, score: true, mood: true }, + }); + expect(rows).toEqual([ + { + source: "NOTIFICATION", + externalId: "notification-action:reminder-1", + score: 4, + mood: "GUT", + }, + ]); + }); + + it("lets only one of two racing answers write", async () => { + const { good, bad } = await tokens("reminder-2"); + + const statuses = (await Promise.all([call(good), call(bad)])) + .map((res) => res.status) + .sort(); + expect(statuses).toEqual([200, 409]); + expect( + await getPrismaClient().moodEntry.count({ where: { userId: USER } }), + ).toBe(1); + }); + + it("answers a separate reminder with its own entry", async () => { + const one = await tokens("reminder-3"); + const two = await tokens("reminder-4"); + expect((await call(one.good)).status).toBe(200); + expect((await call(two.bad)).status).toBe(200); + expect( + await getPrismaClient().moodEntry.count({ where: { userId: USER } }), + ).toBe(2); + }); +}); From fca1260b209d274ec58d29701cd888aaaba61717 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 12:34:33 +0200 Subject: [PATCH 09/61] fix(oura): exchange and refresh tokens at Oura's current token endpoint Apps from Oura's current developer portal receive codes from moi.ouraring.com, and the documented api.ouraring.com/oauth/token answers those codes with 400 invalid_request, so connecting failed and every collection read came back 401. Token requests now go to moi.ouraring.com/oauth/v2/ext/oauth-token first. A code works once, so the documented endpoint is never asked first: a code whose callback names the current server goes nowhere else, and only an older app's code or refresh token gets one retry there after a 400, 401 or 404. The callback checks the RFC 9207 iss parameter when present and refuses a foreign issuer. --- .../api/oura/callback/__tests__/route.test.ts | 46 +++++- src/app/api/oura/callback/route.ts | 13 +- src/lib/oura/__tests__/client.test.ts | 130 +++++++++++++++ src/lib/oura/client.ts | 149 ++++++++++++++++-- 4 files changed, 321 insertions(+), 17 deletions(-) diff --git a/src/app/api/oura/callback/__tests__/route.test.ts b/src/app/api/oura/callback/__tests__/route.test.ts index 07cb60d32..efaf97c1d 100644 --- a/src/app/api/oura/callback/__tests__/route.test.ts +++ b/src/app/api/oura/callback/__tests__/route.test.ts @@ -41,7 +41,12 @@ const { vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: bossMock })); -vi.mock("@/lib/oura/client", () => ({ exchangeCode: exchangeMock })); +vi.mock("@/lib/oura/client", async (importOriginal) => ({ + isAcceptedOuraIssuer: ( + await importOriginal() + ).isAcceptedOuraIssuer, + exchangeCode: exchangeMock, +})); vi.mock("@/lib/oura/credentials", () => ({ getOuraClientCredentials: getCredsMock, })); @@ -65,10 +70,12 @@ function makeReq(opts: { code?: string; state?: string; cookie?: string; + iss?: string; }): NextRequest { const params = new URLSearchParams(); if (opts.code) params.set("code", opts.code); if (opts.state) params.set("state", opts.state); + if (opts.iss) params.set("iss", opts.iss); return { url: `https://app.example/api/oura/callback?${params}`, cookies: { @@ -127,10 +134,11 @@ describe("GET /api/oura/callback", () => { const res = await GET( makeReq({ code: "auth-code", state: "S", cookie: "S" }), ); - expect(exchangeMock).toHaveBeenCalledWith("auth-code", { - clientId: "c", - clientSecret: "s", - }); + expect(exchangeMock).toHaveBeenCalledWith( + "auth-code", + { clientId: "c", clientSecret: "s" }, + null, + ); const data = userUpdate.mock.calls[0]![0].data; expect(data.ouraAccessTokenEncrypted).toBe("enc:at"); expect(data.ouraRefreshTokenEncrypted).toBe("enc:rt"); @@ -139,6 +147,34 @@ describe("GET /api/oura/callback", () => { }); }); +// Oura's current authorization server names itself on the callback +// (RFC 9207); the issuer travels on to pick the token endpoint. +describe("GET /api/oura/callback — issuer", () => { + it("passes Oura's issuer on to the code exchange", async () => { + const iss = "https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous"; + const res = await GET(makeReq({ code: "c", state: "S", cookie: "S", iss })); + expect(exchangeMock).toHaveBeenCalledWith( + "c", + { clientId: "c", clientSecret: "s" }, + iss, + ); + expect(res.headers.get("location")).toContain("oura=connected"); + }); + + it("refuses a callback that names a foreign authorization server", async () => { + const res = await GET( + makeReq({ + code: "c", + state: "S", + cookie: "S", + iss: "https://evil.example/oauth", + }), + ); + expect(res.headers.get("location")).toContain("reason=issuer"); + expect(exchangeMock).not.toHaveBeenCalled(); + }); +}); + /** * v1.32.28 — connecting starts a sync straight away. The poll handler has * accepted a single-user payload since it was written; this callback is the diff --git a/src/app/api/oura/callback/route.ts b/src/app/api/oura/callback/route.ts index 553dd9c64..89f4284ba 100644 --- a/src/app/api/oura/callback/route.ts +++ b/src/app/api/oura/callback/route.ts @@ -4,7 +4,7 @@ import { getSession } from "@/lib/auth/session"; import { annotate, getEvent } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { encrypt } from "@/lib/crypto"; -import { exchangeCode } from "@/lib/oura/client"; +import { exchangeCode, isAcceptedOuraIssuer } from "@/lib/oura/client"; import { getOuraClientCredentials } from "@/lib/oura/credentials"; import { oauthStateCookieName, @@ -67,11 +67,20 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!code) return ERR("nocode"); + // RFC 9207: an authorization server that names itself must be Oura's. The + // issuer also tells which token endpoint the code belongs to. Older apps + // send none, which is fine. + const iss = searchParams.get("iss"); + if (!isAcceptedOuraIssuer(iss)) { + annotate({ meta: { reason: "issuer" } }); + return ERR("issuer"); + } + try { const creds = await getOuraClientCredentials(userId); if (!creds) return ERR("nocreds"); - const tokens = await exchangeCode(code, creds); + const tokens = await exchangeCode(code, creds, iss); await prisma.user.update({ where: { id: userId }, diff --git a/src/lib/oura/__tests__/client.test.ts b/src/lib/oura/__tests__/client.test.ts index 1fead0330..d34407fe9 100644 --- a/src/lib/oura/__tests__/client.test.ts +++ b/src/lib/oura/__tests__/client.test.ts @@ -1,7 +1,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { + OURA_OAUTH_LEGACY_TOKEN_URL, OURA_OAUTH_SCOPE, + OURA_OAUTH_TOKEN_URL, RESILIENCE_LEVELS, derivePeriodDaysFromCyclePhases, exchangeCode, @@ -12,6 +14,7 @@ import { fetchVo2Max, getAuthorizationUrl, getOuraCredentials, + isAcceptedOuraIssuer, mapCardiovascularAge, mapDailyActivity, mapDailySleep, @@ -132,6 +135,133 @@ describe("exchangeCode / refreshAccessToken", () => { }); }); +/** + * Oura moved its authorization server: apps from the current developer + * portal get codes from moi.ouraring.com, which the documented + * api.ouraring.com token endpoint refuses with 400 invalid_request. A code + * works once, so the live endpoint is always asked first. + */ +describe("token endpoint order", () => { + const MOI_ISS = "https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous"; + const OK = { + status: 200, + body: { access_token: "a", refresh_token: "r", expires_in: 86400 }, + }; + const urls = (mock: ReturnType) => + mock.mock.calls.map(([url]) => url); + + it("exchanges a code at the live endpoint, sending the secret in the body", async () => { + const fetchMock = installFetchMock([OK]); + await exchangeCode("code", CREDS, MOI_ISS); + expect(OURA_OAUTH_TOKEN_URL).toBe( + "https://moi.ouraring.com/oauth/v2/ext/oauth-token", + ); + expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]); + const body = String(fetchMock.mock.calls[0]![1]!.body); + expect(body).toContain("grant_type=authorization_code"); + expect(body).toContain("client_id=cid"); + expect(body).toContain("client_secret=csecret"); + }); + + it("never hands a code from the current server to the legacy endpoint", async () => { + const fetchMock = installFetchMock([ + { status: 400, body: { error: "invalid_request" } }, + OK, + ]); + await expect(exchangeCode("code", CREDS, MOI_ISS)).rejects.toMatchObject({ + upstreamError: "invalid_request", + }); + expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]); + expect(urls(fetchMock)).not.toContain(OURA_OAUTH_LEGACY_TOKEN_URL); + }); + + it("asks the legacy endpoint only after the live one refused an older app's code", async () => { + const fetchMock = installFetchMock([ + { status: 401, body: { error: "invalid_client" } }, + OK, + ]); + const t = await exchangeCode("code", CREDS); + expect(t.access_token).toBe("a"); + expect(urls(fetchMock)).toEqual([ + OURA_OAUTH_TOKEN_URL, + OURA_OAUTH_LEGACY_TOKEN_URL, + ]); + }); + + it("does not try the legacy endpoint once the live one accepted the code", async () => { + const fetchMock = installFetchMock([OK]); + await exchangeCode("code", CREDS); + expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]); + }); + + it("refreshes at the live endpoint first", async () => { + const fetchMock = installFetchMock([OK]); + await refreshAccessToken("r1", CREDS); + expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]); + }); + + it.each([ + [400, { error: "invalid_request" }], + [404, null], + ])( + "falls back to the legacy endpoint on a %i from the live one", + async (status, body) => { + const fetchMock = installFetchMock([{ status, body }, OK]); + const t = await refreshAccessToken("r1", CREDS); + expect(t.refresh_token).toBe("r"); + expect(urls(fetchMock)).toEqual([ + OURA_OAUTH_TOKEN_URL, + OURA_OAUTH_LEGACY_TOKEN_URL, + ]); + }, + ); + + it("reports the live endpoint's refusal when both refuse", async () => { + installFetchMock([ + { status: 400, body: { error: "invalid_grant" } }, + { status: 400, body: { error: "invalid_request" } }, + ]); + await expect(refreshAccessToken("dead", CREDS)).rejects.toMatchObject({ + upstreamError: "invalid_grant", + }); + }); + + it("does not fall back on a server error or a rate limit", async () => { + for (const status of [429, 503]) { + const fetchMock = installFetchMock([{ status }, OK]); + await expect(refreshAccessToken("r1", CREDS)).rejects.toBeInstanceOf( + OuraApiError, + ); + expect(urls(fetchMock)).toEqual([OURA_OAUTH_TOKEN_URL]); + vi.unstubAllGlobals(); + } + }); +}); + +describe("isAcceptedOuraIssuer", () => { + it("accepts no issuer and Oura's own servers", () => { + expect(isAcceptedOuraIssuer(null)).toBe(true); + expect( + isAcceptedOuraIssuer( + "https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous", + ), + ).toBe(true); + expect(isAcceptedOuraIssuer("https://cloud.ouraring.com")).toBe(true); + }); + + it("refuses anything else", () => { + for (const iss of [ + "https://evil.example/oauth", + "http://moi.ouraring.com/oauth", + "https://moi.ouraring.com.evil.example/", + "not a url", + "", + ]) { + expect(isAcceptedOuraIssuer(iss), iss).toBe(false); + } + }); +}); + describe("fetchReadiness pagination", () => { it("walks next_token across pages", async () => { installFetchMock([ diff --git a/src/lib/oura/client.ts b/src/lib/oura/client.ts index 0460c315f..073c6a858 100644 --- a/src/lib/oura/client.ts +++ b/src/lib/oura/client.ts @@ -10,7 +10,9 @@ * * Oura specifics: standard authorization-code OAuth WITH refresh tokens (the * sync layer refreshes on 401, persisting BOTH rotated tokens). The token - * endpoint takes client_id/secret in the body. Collections are read by + * endpoint takes client_id/secret in the body (client_secret_post) and lives + * on moi.ouraring.com, with the documented api.ouraring.com endpoint as a + * fallback for older apps. Collections are read by * `start_date` / `end_date` (YYYY-MM-DD) and page via `next_token`. */ import { getEvent } from "@/lib/logging/context"; @@ -22,7 +24,63 @@ import { envOr, envValue } from "@/lib/env"; const OURA_API_BASE = "https://api.ouraring.com"; const OURA_OAUTH_AUTH_URL = "https://cloud.ouraring.com/oauth/authorize"; -const OURA_OAUTH_TOKEN_URL = "https://api.ouraring.com/oauth/token"; + +/** + * Oura's live token endpoint. Apps created in the current developer portal + * get their codes from this authorization server (the callback carries + * `iss=https://moi.ouraring.com/oauth/v2/ext/oauth-anonymous`), and the + * documented `api.ouraring.com/oauth/token` answers those codes with + * `400 invalid_request`. Older apps are served here too. + */ +export const OURA_OAUTH_TOKEN_URL = + "https://moi.ouraring.com/oauth/v2/ext/oauth-token"; + +/** + * The documented endpoint, kept only as a fallback for the older apps the + * live endpoint turns away. Never tried first for a code: an authorization + * code works once (RFC 6749 4.1.2), so a rejected first attempt would burn + * it before the right endpoint saw it. + */ +export const OURA_OAUTH_LEGACY_TOKEN_URL = + "https://api.ouraring.com/oauth/token"; + +/** The origin of Oura's current authorization server. */ +const OURA_MOI_ORIGIN = "https://moi.ouraring.com"; + +/** + * Authorization-server origins an `iss` callback parameter may name + * (RFC 9207). Anything else is a mix-up and refused. + */ +const OURA_ISSUER_ORIGINS = new Set([ + OURA_MOI_ORIGIN, + "https://cloud.ouraring.com", + "https://api.ouraring.com", +]); + +/** + * Check the `iss` the authorization server sent with the callback. Absent is + * fine (older apps never send one); present, it must name one of Oura's own + * authorization servers. + */ +export function isAcceptedOuraIssuer(iss: string | null): boolean { + if (iss === null) return true; + try { + const url = new URL(iss); + return url.protocol === "https:" && OURA_ISSUER_ORIGINS.has(url.origin); + } catch { + return false; + } +} + +/** True when the code was issued by the current authorization server. */ +function issuedByMoi(iss: string | null | undefined): boolean { + if (!iss) return false; + try { + return new URL(iss).origin === OURA_MOI_ORIGIN; + } catch { + return false; + } +} export interface OuraCredentials { clientId: string; @@ -76,12 +134,18 @@ export interface OuraTokenResponse { scope?: string; } -async function postToken( +interface TokenAttempt { + status: number; + json: unknown; +} + +async function requestToken( + url: string, params: URLSearchParams, verb: string, -): Promise { +): Promise { const start = performance.now(); - const res = await safeFetch(OURA_OAUTH_TOKEN_URL, { + const res = await safeFetch(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded", @@ -89,7 +153,7 @@ async function postToken( }, body: params.toString(), }); - const json = await res.json().catch(() => null); + const json: unknown = await res.json().catch(() => null); const verdict = classifyOuraResponse(res.status); getEvent()?.addExternalCall({ service: "oura", @@ -98,33 +162,91 @@ async function postToken( status: res.status, error: verdict.classification === "success" ? undefined : verdict.reason, }); + return { status: res.status, json }; +} + +function upstreamError(json: unknown): string | undefined { + if (json === null || typeof json !== "object") return undefined; + const error = (json as { error?: unknown }).error; + return typeof error === "string" ? error : undefined; +} + +/** + * Whether the legacy endpoint is worth asking after the live one refused: + * a 400, 401 or 404. The live endpoint may not know an older app's client, + * code or refresh token at all, and its refusal does not tell "unknown here" + * apart from "bad grant" reliably, so every such refusal gets the one retry. + * Server errors and rate limits do not: they say nothing about the grant. + */ +const FALLBACK_STATUSES = new Set([400, 401, 404]); + +function belongsElsewhere(attempt: TokenAttempt): boolean { + return FALLBACK_STATUSES.has(attempt.status); +} + +function tokenResult(attempt: TokenAttempt, verb: string): OuraTokenResponse { + const verdict = classifyOuraResponse(attempt.status); if (verdict.classification !== "success") { throw new OuraApiError({ verb, classification: verdict.classification, httpStatus: verdict.httpStatus, reason: verdict.reason, - upstreamError: typeof json?.error === "string" ? json.error : undefined, + upstreamError: upstreamError(attempt.json), }); } // A 2xx with an empty or non-JSON body (captive portal, gateway 200, 204) // leaves `json` null. Casting it straight to the token type handed callers a // null they then dereferenced, surfacing an unclassified TypeError instead of // a handled integration error. Narrow explicitly and classify as transient. - if (json === null || typeof json !== "object") { + if (attempt.json === null || typeof attempt.json !== "object") { throw new OuraApiError({ verb, classification: "transient", - httpStatus: res.status, + httpStatus: attempt.status, reason: "empty_token_body", }); } - return json as OuraTokenResponse; + return attempt.json as OuraTokenResponse; } +/** + * Ask the live endpoint, then the legacy one when `mayFallBack` allows it and + * the live endpoint refused with 400, 401 or 404. The legacy endpoint is + * never asked first. + */ +async function postToken( + params: URLSearchParams, + verb: string, + mayFallBack: boolean, +): Promise { + const live = await requestToken(OURA_OAUTH_TOKEN_URL, params, verb); + if (mayFallBack && belongsElsewhere(live)) { + const legacy = await requestToken( + OURA_OAUTH_LEGACY_TOKEN_URL, + params, + `${verb}.legacy`, + ); + if (legacy.status >= 200 && legacy.status < 300) { + return tokenResult(legacy, verb); + } + // Neither endpoint took it. The live endpoint's answer is the one that + // describes the grant for every current app, so report that one. + } + return tokenResult(live, verb); +} + +/** + * Exchange an authorization code. `iss` is the callback's issuer parameter: + * a code from the current authorization server goes to the live endpoint + * only, since a second attempt elsewhere could never succeed. Without an + * issuer (an older app) the legacy endpoint is the fallback after the live + * one has refused the code; the live endpoint is always asked first. + */ export async function exchangeCode( code: string, creds: OuraCredentials, + iss?: string | null, ): Promise { return postToken( new URLSearchParams({ @@ -135,9 +257,15 @@ export async function exchangeCode( client_secret: creds.clientSecret, }), "exchangeCode", + !issuedByMoi(iss), ); } +/** + * Refresh an access token: live endpoint first, the legacy endpoint when the + * live one refuses it (a refresh token of an older app). + * A refused refresh does not consume the token, so the retry is safe. + */ export async function refreshAccessToken( refreshToken: string, creds: OuraCredentials, @@ -150,6 +278,7 @@ export async function refreshAccessToken( client_secret: creds.clientSecret, }), "refreshAccessToken", + true, ); } From 12a8ab7c63f2fc4f2b73c88cc90d95e2e0d58a0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 11 Oct 2026 12:37:57 +0200 Subject: [PATCH 10/61] feat(documents): open a PDF or image original in a new browser tab The document sheet gains an Open in new tab action for the types the browser renders itself (PDF, JPEG, PNG, WebP, GIF), so a long report gets the browser's own zoom and search instead of the small preview. It opens the object URL the sheet already fetched through the app transport: a plain navigation to the serve route carries no record-session assertion and would be refused inside a shared record. Every other type keeps the download only, and the serve route's matrix now pins SVG and HTML to attachment as well. --- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/pl.json | 1 + .../[id]/original/__tests__/route.test.ts | 4 ++ .../document-detail-download.test.ts | 38 +++++++++++++++ .../documents/document-detail-sheet.tsx | 48 ++++++++++++++++++- 10 files changed, 95 insertions(+), 2 deletions(-) diff --git a/messages/de.json b/messages/de.json index 8395f39a5..b370a245b 100644 --- a/messages/de.json +++ b/messages/de.json @@ -10858,6 +10858,7 @@ "saveError": "Die Änderung konnte nicht gespeichert werden.", "deleteError": "Das Dokument konnte nicht gelöscht werden.", "download": "Herunterladen", + "openInNewTab": "In neuem Tab öffnen", "delete": "Löschen", "titleLabel": "Titel", "titlePlaceholder": "Titel hinzufügen", diff --git a/messages/en.json b/messages/en.json index 164ca09ff..232132002 100644 --- a/messages/en.json +++ b/messages/en.json @@ -10858,6 +10858,7 @@ "saveError": "Couldn't save the change.", "deleteError": "Couldn't delete the document.", "download": "Download", + "openInNewTab": "Open in new tab", "delete": "Delete", "titleLabel": "Title", "titlePlaceholder": "Add a title", diff --git a/messages/es.json b/messages/es.json index 85feaee82..521e07378 100644 --- a/messages/es.json +++ b/messages/es.json @@ -10858,6 +10858,7 @@ "saveError": "No se pudo guardar el cambio.", "deleteError": "No se pudo eliminar el documento.", "download": "Descargar", + "openInNewTab": "Abrir en una pestaña nueva", "delete": "Eliminar", "titleLabel": "Título", "titlePlaceholder": "Añadir un título", diff --git a/messages/fr.json b/messages/fr.json index e84f55fb3..c20ce3479 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -10858,6 +10858,7 @@ "saveError": "Impossible d'enregistrer la modification.", "deleteError": "Impossible de supprimer le document.", "download": "Télécharger", + "openInNewTab": "Ouvrir dans un nouvel onglet", "delete": "Supprimer", "titleLabel": "Titre", "titlePlaceholder": "Ajouter un titre", diff --git a/messages/it.json b/messages/it.json index 6937f4746..292b56dd7 100644 --- a/messages/it.json +++ b/messages/it.json @@ -10858,6 +10858,7 @@ "saveError": "Impossibile salvare la modifica.", "deleteError": "Impossibile eliminare il documento.", "download": "Scarica", + "openInNewTab": "Apri in una nuova scheda", "delete": "Elimina", "titleLabel": "Titolo", "titlePlaceholder": "Aggiungi un titolo", diff --git a/messages/ko.json b/messages/ko.json index ec79c416d..db3908b97 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -10858,6 +10858,7 @@ "saveError": "변경 내용을 저장하지 못했어요.", "deleteError": "문서를 삭제하지 못했어요.", "download": "다운로드", + "openInNewTab": "새 탭에서 열기", "delete": "삭제", "titleLabel": "제목", "titlePlaceholder": "제목 입력", diff --git a/messages/pl.json b/messages/pl.json index 19bdad46a..dba9b65ea 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -10858,6 +10858,7 @@ "saveError": "Nie udało się zapisać zmiany.", "deleteError": "Nie udało się usunąć dokumentu.", "download": "Pobierz", + "openInNewTab": "Otwórz w nowej karcie", "delete": "Usuń", "titleLabel": "Tytuł", "titlePlaceholder": "Dodaj tytuł", diff --git a/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts b/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts index c759ae581..dea3c6a7c 100644 --- a/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts +++ b/src/app/api/documents/inbound/[id]/original/__tests__/route.test.ts @@ -240,6 +240,10 @@ describe("serving-posture header matrix (Class A inline vs Class B attachment)", ["image/heic", "photo.heic"], ["application/xml", "export.xml"], ["application/json", "export.json"], + // Upload refuses markup outright; a row that somehow carries one must + // still never render in-origin, where its script would run. + ["image/svg+xml", "drawing.svg"], + ["text/html", "page.html"], ])( "Class B %s → attachment + octet-stream, never inline", async (mime, name) => { diff --git a/src/components/documents/__tests__/document-detail-download.test.ts b/src/components/documents/__tests__/document-detail-download.test.ts index e3d331e5e..b59f64e1a 100644 --- a/src/components/documents/__tests__/document-detail-download.test.ts +++ b/src/components/documents/__tests__/document-detail-download.test.ts @@ -81,3 +81,41 @@ describe("the detail sheet's original-document transport", () => { ); }); }); + +/** + * "Open in new tab" hands the browser the types it renders itself, so a long + * report gets the browser's zoom and search. It must open the object URL the + * sheet already holds (a bare navigation to the route carries no record-session + * assertion and is refused once the session has been inside a shared record), + * and it must never appear for a type outside the inline serving class. + */ +describe("the detail sheet's open-in-new-tab control", () => { + const control = SHEET.slice( + SHEET.indexOf("function DocumentOpenInTabButton("), + SHEET.indexOf("/** Inline preview for Class A documents"), + ); + + it("exists", () => { + expect(control.length).toBeGreaterThan(0); + expect(SHEET).toMatch(/ { + expect(SHEET).toMatch( + /doc\.servingClass === "inline" \? \(\s*\n\s* { + expect(control).toContain('window.open(readyUrl, "_blank", "noopener")'); + expect(control).not.toContain("/api/documents"); + }); + + it("is disabled until the bytes are in hand, and when they failed", () => { + expect(control).toMatch(/disabled=\{readyUrl === null \|\| unavailable\}/); + expect(SHEET).toMatch( + / { + if (readyUrl) window.open(readyUrl, "_blank", "noopener"); + }} + > + + + {t("documents.detail.openInNewTab")} + + + ); +} + /** Inline preview for Class A documents; skeleton until the blob paints. */ function InlinePreview({ src, @@ -759,8 +797,8 @@ export function DocumentDetailSheet({ doc ? ( // Delete sits bottom-LEFT, quieted to a text button: the // bottom-right slot reads as the primary/confirm action, and a - // destructive control there is a footgun. Share + Download — the - // safe, expected actions — keep the trailing edge. On phones their + // destructive control there is a footgun. Share, open in a new tab and + // Download, the safe, expected actions, keep the trailing edge. On phones their // labels collapse to icon-only (aria-labelled) so three controls // never overflow the sheet at 360 px; the labels return at `sm+`.
@@ -824,6 +862,12 @@ export function DocumentDetailSheet({ )} + {doc.servingClass === "inline" ? ( + + ) : null} Date: Sun, 11 Oct 2026 12:39:54 +0200 Subject: [PATCH 11/61] feat(palette): list hits from the record under the local results --- messages/de.json | 29 +- messages/en.json | 29 +- messages/es.json | 29 +- messages/fr.json | 29 +- messages/it.json | 29 +- messages/ko.json | 29 +- messages/pl.json | 29 +- src/app/checkups/page-client.tsx | 8 + .../__tests__/command-palette.test.tsx | 118 ++++- .../command-palette/command-palette.tsx | 169 ++++++- src/components/command-palette/record-hit.tsx | 105 +++++ .../command-palette/use-record-search.ts | 77 ++++ src/components/encounters/visits-section.tsx | 11 + src/components/symptoms/symptoms-section.tsx | 12 + src/hooks/use-deep-linked-record.ts | 37 +- src/lib/query-keys/index.ts | 2 + src/lib/query-keys/search.ts | 15 + tests/integration/record-search.test.ts | 413 ++++++++++++++++++ 18 files changed, 1136 insertions(+), 34 deletions(-) create mode 100644 src/components/command-palette/record-hit.tsx create mode 100644 src/components/command-palette/use-record-search.ts create mode 100644 src/lib/query-keys/search.ts create mode 100644 tests/integration/record-search.test.ts diff --git a/messages/de.json b/messages/de.json index 8395f39a5..ac5771b9f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -12560,8 +12560,8 @@ }, "palette": { "title": "Suche", - "description": "Finde eine Seite, eine Einstellung, ein Modul oder eine Aktion.", - "placeholder": "Seiten, Einstellungen, Module suchen…", + "description": "Finde eine Seite, eine Einstellung, ein Modul, eine Aktion oder einen Eintrag in deinen Daten.", + "placeholder": "Seiten, Einstellungen, deine Einträge suchen…", "results": "Treffer", "noResults": "Nichts gefunden für „{query}“.", "moduleOn": "An", @@ -12575,7 +12575,8 @@ "insights": "Insights", "settings": "Einstellungen", "modules": "Module", - "actions": "Aktionen" + "actions": "Aktionen", + "records": "In deinen Einträgen" }, "actions": { "capture": "Neuer Eintrag", @@ -12583,6 +12584,28 @@ "backup": "Backup erstellen", "today": "Heute öffnen", "shortcuts": "Tastenkürzel zeigen" + }, + "records": { + "loading": "Deine Einträge werden durchsucht…", + "empty": "Nichts in deinen Einträgen für „{query}“.", + "error": "Deine Einträge konnten nicht durchsucht werden. Versuch es gleich noch einmal.", + "truncated": "Nur die neuesten Einträge wurden durchsucht.", + "kind": { + "visit": "Arztbesuch", + "condition": "Erkrankung", + "document": "Dokument", + "vaccination": "Impfung", + "lab": "Laborwert", + "medication": "Medikament", + "symptom": "Symptom", + "workout": "Training", + "note": "Notiz" + }, + "source": { + "mood": "Stimmungsnotiz", + "measurement": "Notiz zu einem Messwert", + "illness": "Krankheitstagebuch" + } } }, "shortcuts": { diff --git a/messages/en.json b/messages/en.json index 164ca09ff..da23da37b 100644 --- a/messages/en.json +++ b/messages/en.json @@ -12560,8 +12560,8 @@ }, "palette": { "title": "Search", - "description": "Find a page, a setting, a module or an action.", - "placeholder": "Search pages, settings, modules…", + "description": "Find a page, a setting, a module, an action or an entry in your records.", + "placeholder": "Search pages, settings, your records…", "results": "Results", "noResults": "Nothing found for “{query}”.", "moduleOn": "On", @@ -12575,7 +12575,8 @@ "insights": "Insights", "settings": "Settings", "modules": "Modules", - "actions": "Actions" + "actions": "Actions", + "records": "In your records" }, "actions": { "capture": "New entry", @@ -12583,6 +12584,28 @@ "backup": "Create a backup", "today": "Open today", "shortcuts": "Show keyboard shortcuts" + }, + "records": { + "loading": "Searching your records…", + "empty": "Nothing in your records for “{query}”.", + "error": "Your records could not be searched. Try again in a moment.", + "truncated": "Only the most recent entries were searched.", + "kind": { + "visit": "Visit", + "condition": "Condition", + "document": "Document", + "vaccination": "Vaccination", + "lab": "Lab result", + "medication": "Medication", + "symptom": "Symptom", + "workout": "Workout", + "note": "Note" + }, + "source": { + "mood": "Mood note", + "measurement": "Note on a reading", + "illness": "Illness diary" + } } }, "shortcuts": { diff --git a/messages/es.json b/messages/es.json index 85feaee82..cd7e09945 100644 --- a/messages/es.json +++ b/messages/es.json @@ -12560,8 +12560,8 @@ }, "palette": { "title": "Buscar", - "description": "Encuentra una página, un ajuste, un módulo o una acción.", - "placeholder": "Buscar páginas, ajustes, módulos…", + "description": "Encuentra una página, un ajuste, un módulo, una acción o una entrada de tus registros.", + "placeholder": "Buscar páginas, ajustes, tus registros…", "results": "Resultados", "noResults": "No hay resultados para «{query}».", "moduleOn": "Activado", @@ -12575,7 +12575,8 @@ "insights": "Insights", "settings": "Ajustes", "modules": "Módulos", - "actions": "Acciones" + "actions": "Acciones", + "records": "En tus registros" }, "actions": { "capture": "Nueva entrada", @@ -12583,6 +12584,28 @@ "backup": "Crear una copia de seguridad", "today": "Abrir hoy", "shortcuts": "Mostrar atajos de teclado" + }, + "records": { + "loading": "Buscando en tus registros…", + "empty": "Nada en tus registros para «{query}».", + "error": "No se pudo buscar en tus registros. Inténtalo de nuevo en un momento.", + "truncated": "Solo se buscaron las entradas más recientes.", + "kind": { + "visit": "Consulta", + "condition": "Enfermedad", + "document": "Documento", + "vaccination": "Vacuna", + "lab": "Resultado de laboratorio", + "medication": "Medicamento", + "symptom": "Síntoma", + "workout": "Entrenamiento", + "note": "Nota" + }, + "source": { + "mood": "Nota de ánimo", + "measurement": "Nota de una medición", + "illness": "Diario de enfermedad" + } } }, "shortcuts": { diff --git a/messages/fr.json b/messages/fr.json index e84f55fb3..491a9c5d8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -12560,8 +12560,8 @@ }, "palette": { "title": "Recherche", - "description": "Trouver une page, un réglage, un module ou une action.", - "placeholder": "Rechercher des pages, réglages, modules…", + "description": "Trouver une page, un réglage, un module, une action ou une entrée de votre dossier.", + "placeholder": "Rechercher des pages, réglages, votre dossier…", "results": "Résultats", "noResults": "Aucun résultat pour « {query} ».", "moduleOn": "Activé", @@ -12575,7 +12575,8 @@ "insights": "Insights", "settings": "Paramètres", "modules": "Modules", - "actions": "Actions" + "actions": "Actions", + "records": "Dans votre dossier" }, "actions": { "capture": "Nouvelle saisie", @@ -12583,6 +12584,28 @@ "backup": "Créer une sauvegarde", "today": "Ouvrir aujourd’hui", "shortcuts": "Afficher les raccourcis clavier" + }, + "records": { + "loading": "Recherche dans votre dossier…", + "empty": "Rien dans votre dossier pour « {query} ».", + "error": "Votre dossier n’a pas pu être parcouru. Réessayez dans un instant.", + "truncated": "Seules les entrées les plus récentes ont été parcourues.", + "kind": { + "visit": "Consultation", + "condition": "Maladie", + "document": "Document", + "vaccination": "Vaccination", + "lab": "Résultat d’analyse", + "medication": "Médicament", + "symptom": "Symptôme", + "workout": "Entraînement", + "note": "Note" + }, + "source": { + "mood": "Note d’humeur", + "measurement": "Note sur une mesure", + "illness": "Journal de maladie" + } } }, "shortcuts": { diff --git a/messages/it.json b/messages/it.json index 6937f4746..702fa739c 100644 --- a/messages/it.json +++ b/messages/it.json @@ -12560,8 +12560,8 @@ }, "palette": { "title": "Cerca", - "description": "Trova una pagina, un’impostazione, un modulo o un’azione.", - "placeholder": "Cerca pagine, impostazioni, moduli…", + "description": "Trova una pagina, un’impostazione, un modulo, un’azione o una voce dei tuoi dati.", + "placeholder": "Cerca pagine, impostazioni, i tuoi dati…", "results": "Risultati", "noResults": "Nessun risultato per «{query}».", "moduleOn": "Attivo", @@ -12575,7 +12575,8 @@ "insights": "Insights", "settings": "Impostazioni", "modules": "Moduli", - "actions": "Azioni" + "actions": "Azioni", + "records": "Nei tuoi dati" }, "actions": { "capture": "Nuova voce", @@ -12583,6 +12584,28 @@ "backup": "Crea un backup", "today": "Apri oggi", "shortcuts": "Mostra scorciatoie da tastiera" + }, + "records": { + "loading": "Ricerca nei tuoi dati…", + "empty": "Niente nei tuoi dati per «{query}».", + "error": "Non è stato possibile cercare nei tuoi dati. Riprova tra un momento.", + "truncated": "Sono state cercate solo le voci più recenti.", + "kind": { + "visit": "Visita", + "condition": "Malattia", + "document": "Documento", + "vaccination": "Vaccinazione", + "lab": "Esame di laboratorio", + "medication": "Farmaco", + "symptom": "Sintomo", + "workout": "Allenamento", + "note": "Nota" + }, + "source": { + "mood": "Nota sull’umore", + "measurement": "Nota su una misurazione", + "illness": "Diario di malattia" + } } }, "shortcuts": { diff --git a/messages/ko.json b/messages/ko.json index ec79c416d..6d01f8128 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -12560,8 +12560,8 @@ }, "palette": { "title": "검색", - "description": "페이지, 설정, 모듈 또는 작업을 찾습니다.", - "placeholder": "페이지, 설정, 모듈 검색…", + "description": "페이지, 설정, 모듈, 작업 또는 내 기록의 항목을 찾습니다.", + "placeholder": "페이지, 설정, 내 기록 검색…", "results": "결과", "noResults": "“{query}”에 대한 결과가 없습니다.", "moduleOn": "켜짐", @@ -12575,7 +12575,8 @@ "insights": "인사이트", "settings": "설정", "modules": "모듈", - "actions": "작업" + "actions": "작업", + "records": "내 기록에서" }, "actions": { "capture": "새 항목", @@ -12583,6 +12584,28 @@ "backup": "백업 만들기", "today": "오늘 열기", "shortcuts": "키보드 단축키 보기" + }, + "records": { + "loading": "내 기록을 검색하는 중…", + "empty": "내 기록에서 “{query}”에 대한 결과가 없습니다.", + "error": "내 기록을 검색할 수 없습니다. 잠시 후 다시 시도하세요.", + "truncated": "최근 항목만 검색했습니다.", + "kind": { + "visit": "진료", + "condition": "질환", + "document": "문서", + "vaccination": "예방접종", + "lab": "검사 결과", + "medication": "약", + "symptom": "증상", + "workout": "운동", + "note": "메모" + }, + "source": { + "mood": "기분 메모", + "measurement": "측정값 메모", + "illness": "질병 일지" + } } }, "shortcuts": { diff --git a/messages/pl.json b/messages/pl.json index 19bdad46a..2bdd9c0ce 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -12560,8 +12560,8 @@ }, "palette": { "title": "Szukaj", - "description": "Znajdź stronę, ustawienie, moduł lub akcję.", - "placeholder": "Szukaj stron, ustawień, modułów…", + "description": "Znajdź stronę, ustawienie, moduł, akcję lub wpis w swoich danych.", + "placeholder": "Szukaj stron, ustawień, swoich wpisów…", "results": "Wyniki", "noResults": "Brak wyników dla „{query}”.", "moduleOn": "Wł.", @@ -12575,7 +12575,8 @@ "insights": "Insights", "settings": "Ustawienia", "modules": "Moduły", - "actions": "Akcje" + "actions": "Akcje", + "records": "W twoich wpisach" }, "actions": { "capture": "Nowy wpis", @@ -12583,6 +12584,28 @@ "backup": "Utwórz kopię zapasową", "today": "Otwórz dzisiaj", "shortcuts": "Pokaż skróty klawiszowe" + }, + "records": { + "loading": "Przeszukiwanie twoich wpisów…", + "empty": "Brak wyników w twoich wpisach dla „{query}”.", + "error": "Nie udało się przeszukać twoich wpisów. Spróbuj ponownie za chwilę.", + "truncated": "Przeszukano tylko najnowsze wpisy.", + "kind": { + "visit": "Wizyta", + "condition": "Choroba", + "document": "Dokument", + "vaccination": "Szczepienie", + "lab": "Wynik badania", + "medication": "Lek", + "symptom": "Objaw", + "workout": "Trening", + "note": "Notatka" + }, + "source": { + "mood": "Notatka o nastroju", + "measurement": "Notatka do pomiaru", + "illness": "Dziennik choroby" + } } }, "shortcuts": { diff --git a/src/app/checkups/page-client.tsx b/src/app/checkups/page-client.tsx index 77a2197cd..e97672d7c 100644 --- a/src/app/checkups/page-client.tsx +++ b/src/app/checkups/page-client.tsx @@ -69,6 +69,14 @@ export default function CheckupsPageClient() { const [view, setView] = useState( linkedVisit ? "visits" : linkedSite ? "body-sites" : "vorsorge", ); + // A link to a visit can arrive while the page is already open (the search + // opens one from wherever the person is): it moves to the visits tab then + // too, rather than only on the first render. + const [observedVisit, setObservedVisit] = useState(linkedVisit); + if (linkedVisit !== observedVisit) { + setObservedVisit(linkedVisit); + if (linkedVisit) setView("visits"); + } const refresh = useCallback( () => diff --git a/src/components/command-palette/__tests__/command-palette.test.tsx b/src/components/command-palette/__tests__/command-palette.test.tsx index cd23ee416..aeeb64b5f 100644 --- a/src/components/command-palette/__tests__/command-palette.test.tsx +++ b/src/components/command-palette/__tests__/command-palette.test.tsx @@ -4,6 +4,7 @@ import { Activity, Moon, Plus, Smile } from "lucide-react"; import { I18nProvider } from "@/lib/i18n/context"; import type { PaletteEntry } from "@/lib/command-palette/build-index"; +import type { SearchResponse } from "@/lib/search/contract"; vi.mock("next/navigation", () => ({ useRouter: () => ({ push: vi.fn() }), @@ -69,6 +70,18 @@ vi.mock("../use-palette-index", () => ({ usePaletteIndex: () => ENTRIES, })); +const recordSearch = vi.hoisted(() => ({ + state: { + query: null as string | null, + data: undefined as SearchResponse | undefined, + loading: false, + error: false, + }, +})); +vi.mock("../use-record-search", () => ({ + useRecordSearch: () => recordSearch.state, +})); + import { PaletteBody, arrangeResults } from "../command-palette"; function render(isMobile = false) { @@ -122,7 +135,9 @@ describe("", () => { const html = render(); expect(html).toContain('role="group"'); expect(html).toContain('aria-label="Search"'); - expect(html).toContain('placeholder="Search pages, settings, modules…"'); + expect(html).toContain( + 'placeholder="Search pages, settings, your records…"', + ); expect(html).toContain(">Actions<"); }); @@ -133,3 +148,104 @@ describe("", () => { expect(phone).toContain(">Cancel<"); }); }); + +const HITS: SearchResponse = { + groups: [ + { + kind: "visit", + more: false, + hits: [ + { + id: "visit:v1", + kind: "visit", + title: { text: "Arthroskopie", marks: [] }, + context: [{ text: "Schulter (links)", marks: [[0, 8]] }], + snippet: null, + day: "2026-03-02", + path: "/checkups?visit=v1", + source: null, + }, + ], + }, + { + kind: "note", + more: false, + hits: [ + { + id: "moodNote:m1", + kind: "note", + title: null, + context: [], + snippet: { text: "Die SCHULTER zwickt", marks: [[7, 15]] }, + day: "2026-03-05", + path: "/mood?day=2026-03-05", + source: "mood", + }, + ], + }, + ], + truncated: true, +}; + +describe(" in your records", () => { + it("lists the record hits after the local results as options", () => { + recordSearch.state = { + query: "schulter", + data: HITS, + loading: false, + error: false, + }; + const html = render(); + expect(html).toContain('data-group="records"'); + expect(html).toContain(">In your records<"); + expect(html).toContain('data-entry="record:visit:v1"'); + expect(html).toContain('data-entry="record:moodNote:m1"'); + // After every local option. + expect(html.indexOf('data-entry="action:today"')).toBeLessThan( + html.indexOf('data-entry="record:visit:v1"'), + ); + // The matched part is marked, as text. + expect(html).toContain("Schulter (links)"); + // A note without a name is called by what it was written on. + expect(html).toContain("Mood note"); + // Record text is never parsed as markup. + expect(html).not.toContain(""); + expect(html).toContain("<b>"); + expect(html).toContain("Only the most recent entries were searched."); + }); + + it("says when it is searching, and when nothing matched", () => { + recordSearch.state = { + query: "schulter", + data: undefined, + loading: true, + error: false, + }; + expect(render()).toContain("Searching your records…"); + recordSearch.state = { + query: "schulter", + data: { groups: [], truncated: false }, + loading: false, + error: false, + }; + expect(render()).toContain("Nothing in your records for “schulter”."); + recordSearch.state = { + query: "schulter", + data: undefined, + loading: false, + error: true, + }; + expect(render()).toContain("Your records could not be searched."); + }); + + it("shows nothing of the records while no search applies", () => { + recordSearch.state = { + query: null, + data: undefined, + loading: false, + error: false, + }; + expect(render()).not.toContain('data-group="records"'); + }); +}); diff --git a/src/components/command-palette/command-palette.tsx b/src/components/command-palette/command-palette.tsx index f3d97849f..bb284242c 100644 --- a/src/components/command-palette/command-palette.tsx +++ b/src/components/command-palette/command-palette.tsx @@ -36,10 +36,13 @@ import { pushRecent, readRecent } from "@/lib/command-palette/recent"; import { scrollToAnchorWhenReady } from "@/lib/dom/scroll-to-anchor"; import { useCoachLaunch } from "@/lib/insights/coach-launch-context"; import { useTranslations } from "@/lib/i18n/context"; +import type { SearchHit } from "@/lib/search/contract"; import { cn } from "@/lib/utils"; import { setCommandPaletteOpen, useCommandPaletteOpen } from "./palette-store"; +import { RecordHitContent } from "./record-hit"; import { usePaletteIndex } from "./use-palette-index"; +import { useRecordSearch } from "./use-record-search"; /** * The command palette: one field, and below it the places, settings, module @@ -47,6 +50,11 @@ import { usePaletteIndex } from "./use-palette-index"; * Escape closes. A module's entry is a switch: Enter flips it and the palette * stays open. * + * After the local results comes "In your records": the server's search over + * the record (`useRecordSearch`, `GET /api/search`), once the typing pauses. + * Its hits are options like any other, after the local ones, so the arrows + * and Enter work across both; each opens the page that shows the record. + * * Accessibility follows the combobox pattern: the field is the * `role="combobox"` that keeps focus, the results are a `listbox` of grouped * `option`s, and `aria-activedescendant` names the highlighted one. @@ -177,8 +185,15 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) { () => sections.flatMap((section) => section.entries), [sections], ); - const activeIndex = - flat.length === 0 ? -1 : Math.min(active, flat.length - 1); + const records = useRecordSearch(query); + const recordHits = useMemo( + () => records.data?.groups.flatMap((group) => group.hits) ?? [], + [records.data], + ); + const showRecords = records.query !== null; + const shownHits = showRecords ? recordHits : []; + const total = flat.length + shownHits.length; + const activeIndex = total === 0 ? -1 : Math.min(active, total - 1); const optionId = (i: number) => `${listId}-option-${i}`; // Keep the highlighted option in view as the arrows move through a list @@ -231,19 +246,33 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) { } } + function openRecord(path: string) { + setCommandPaletteOpen(false); + router.push(path); + } + + function runAt(index: number) { + if (index < 0) return; + if (index < flat.length) { + run(flat[index]); + return; + } + const hit = shownHits[index - flat.length]; + if (hit) openRecord(hit.path); + } + function onKeyDown(event: React.KeyboardEvent) { if (event.nativeEvent.isComposing) return; if (event.key === "ArrowDown" || event.key === "ArrowUp") { event.preventDefault(); - if (flat.length === 0) return; + if (total === 0) return; const step = event.key === "ArrowDown" ? 1 : -1; - setActive((activeIndex + step + flat.length) % flat.length); + setActive((activeIndex + step + total) % total); return; } if (event.key === "Enter") { event.preventDefault(); - const entry = flat[activeIndex]; - if (entry) run(entry); + runAt(activeIndex); return; } if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") { @@ -278,7 +307,7 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) { autoFocus type="text" role="combobox" - aria-expanded={flat.length > 0} + aria-expanded={total > 0} aria-controls={listId} aria-autocomplete="list" aria-activedescendant={ @@ -319,7 +348,9 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) { data-slot="command-palette-results" className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-2" > - {sections.length === 0 ? ( + {sections.length === 0 && + (!showRecords || + (!records.loading && !records.error && shownHits.length === 0)) ? (

+ ) : null}

{isMobile ? null : ( @@ -444,3 +496,104 @@ export function PaletteBody({ isMobile }: { isMobile: boolean }) { ); } + +/** + * "In your records": the server's hits after the local results. A status line + * (searching, nothing found, failed, older entries not read) is plain text, + * not an option, so the arrows skip it. + */ +function RecordSection({ + headingId, + hits, + firstIndex, + activeIndex, + optionId, + loading, + error, + truncated, + query, + onHover, + onOpen, +}: { + headingId: string; + hits: ReadonlyArray; + firstIndex: number; + activeIndex: number; + optionId: (i: number) => string; + loading: boolean; + error: boolean; + truncated: boolean; + query: string; + onHover: (i: number) => void; + onOpen: (path: string) => void; +}) { + const { t } = useTranslations(); + const status = error + ? t("palette.records.error") + : hits.length === 0 + ? loading + ? t("palette.records.loading") + : t("palette.records.empty", { query }) + : null; + return ( +
+
+ {t("palette.groups.records")} +
+ {hits.map((hit, n) => { + const i = firstIndex + n; + const selected = i === activeIndex; + return ( +
{ + if (!selected) onHover(i); + }} + onMouseDown={(event) => { + // Keep focus in the field; the click opens the record. + event.preventDefault(); + }} + onClick={() => onOpen(hit.path)} + className={cn( + "flex min-h-11 cursor-pointer items-start gap-3 rounded-md px-3 py-2 text-sm", + selected && "bg-accent text-accent-foreground", + )} + > + +
+ ); + })} + {status ? ( +

+ {status} +

+ ) : null} + {truncated && !error ? ( +

+ {t("palette.records.truncated")} +

+ ) : null} +
+ ); +} diff --git a/src/components/command-palette/record-hit.tsx b/src/components/command-palette/record-hit.tsx new file mode 100644 index 000000000..cc7d769a8 --- /dev/null +++ b/src/components/command-palette/record-hit.tsx @@ -0,0 +1,105 @@ +"use client"; + +import { + Activity, + Dumbbell, + FileText, + FlaskConical, + NotebookPen, + Pill, + Stethoscope, + Syringe, + Thermometer, + type LucideIcon, +} from "lucide-react"; + +import { useFormatters, useTranslations } from "@/lib/i18n/context"; +import type { + SearchHit, + SearchHitKind, + SearchMarkedText, +} from "@/lib/search/contract"; + +export const RECORD_HIT_ICON: Record = { + visit: Stethoscope, + condition: Thermometer, + document: FileText, + vaccination: Syringe, + lab: FlaskConical, + medication: Pill, + symptom: Activity, + workout: Dumbbell, + note: NotebookPen, +}; + +/** + * Text with the ranges the query matched set in ``. Plain React text + * children only: the ranges are character offsets the server computed, and + * nothing here is parsed as markup. + */ +export function Marked({ value }: { value: SearchMarkedText }) { + const parts: React.ReactNode[] = []; + let at = 0; + for (const [start, end] of value.marks) { + if (start < at || end > value.text.length || end <= start) continue; + if (start > at) parts.push(value.text.slice(at, start)); + parts.push( + + {value.text.slice(start, end)} + , + ); + at = end; + } + if (at < value.text.length) parts.push(value.text.slice(at)); + return <>{parts}; +} + +/** What a hit is called when its record has no name of its own. */ +export function useRecordHitLabel(): (hit: SearchHit) => string { + const { t } = useTranslations(); + return (hit) => + hit.source + ? t(`palette.records.source.${hit.source}`) + : t(`palette.records.kind.${hit.kind}`); +} + +/** The body of one record option: title, kind and date, context, snippet. */ +export function RecordHitContent({ hit }: { hit: SearchHit }) { + const fmt = useFormatters(); + const label = useRecordHitLabel()(hit); + const Icon = RECORD_HIT_ICON[hit.kind]; + return ( + <> +