From f790e14edd54494a23c723c685646ff1e5330d30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 11 Jun 2026 23:20:38 +0200 Subject: [PATCH] fix(api,lib): consent before reuse, tighter inputs and caches that tell on themselves Per-status texts check AI consent before the unchanged-data refresh can re-stamp them, the comprehensive fingerprint folds in the comparison baseline and generation language so toggling either regenerates once, and profile, threshold and source-priority writes evict the analytics, targets and derived caches they feed. CSV export neutralises spreadsheet formula prefixes on free-text cells while numeric columns stay typed, the derived batch validates its type token against the measurement enum and carries a per-user rate limit, and a dose synced from the phone app counts as the interactive write it is. Background cache refresh failures log a structured event instead of vanishing, the warm queue runs visit-triggered jobs alongside the nightly walk, the formatter memo evicts one entry instead of flushing wholesale with its option objects frozen, the targets cold build bounds its fan-out, and time in bed unions every writer's envelope while stages stay with the richest recorder. --- docs/api/openapi.yaml | 4 +- src/app/api/auth/me/source-priority/route.ts | 12 +- .../derived/batch/__tests__/route.test.ts | 68 ++++++- src/app/api/insights/derived/batch/route.ts | 43 +++-- src/app/api/insights/generate/route.ts | 16 +- src/app/api/insights/targets/route.ts | 176 ++++++++++-------- .../intake/bulk/__tests__/route.test.ts | 12 +- src/app/api/medications/intake/bulk/route.ts | 14 +- src/app/api/user/thresholds/route.ts | 10 + src/lib/__tests__/export.test.ts | 34 ++++ .../analytics/__tests__/sleep-night.test.ts | 157 +++++++++++++--- src/lib/analytics/sleep-night.ts | 133 ++++++++++--- src/lib/auth/profile-update.ts | 14 ++ src/lib/cache/__tests__/invalidate.test.ts | 70 +++++-- src/lib/cache/__tests__/server-cache.test.ts | 53 +++++- src/lib/cache/invalidate.ts | 37 +++- src/lib/cache/server-cache.ts | 51 +++-- src/lib/export.ts | 38 +++- .../__tests__/blood-pressure-status.test.ts | 2 + src/lib/insights/__tests__/bmi-status.test.ts | 2 + .../comprehensive-generate-force.test.ts | 8 +- .../insights/__tests__/general-status.test.ts | 2 + .../medication-compliance-status.test.ts | 13 +- .../insights/__tests__/metric-status.test.ts | 89 +++++++-- .../insights/__tests__/mood-status.test.ts | 23 ++- .../insights/__tests__/payload-budget.test.ts | 2 + .../__tests__/pulse-status-timeout.test.ts | 2 + .../insights/__tests__/pulse-status.test.ts | 2 + .../insights/__tests__/snapshot-hash.test.ts | 37 +++- .../insights/__tests__/status-cache.test.ts | 33 +++- .../insights/__tests__/weight-status.test.ts | 19 +- src/lib/insights/comprehensive-generate.ts | 49 +++-- src/lib/insights/status-cache.ts | 21 ++- src/lib/insights/status-provider.ts | 23 +++ src/lib/jobs/reminder-worker.ts | 17 +- src/lib/openapi/routes/insights.ts | 2 +- src/lib/tz/__tests__/intl-cache.test.ts | 59 +++++- src/lib/tz/format.ts | 38 ++-- src/lib/tz/intl-cache.ts | 10 +- src/lib/tz/wall-clock.ts | 33 ++-- 40 files changed, 1141 insertions(+), 287 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 4cc34596c..4ea02f394 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -4224,11 +4224,11 @@ paths: maxLength: 1024 description: Comma-separated derived-metric tokens. Each is a `` or `:` (the colon sub-targets a VITALS_BASELINE vital). An unknown id - 422s; at most 24 tokens; duplicates collapse. + 422s; a `type` outside the MeasurementType enum 422s; at most 24 tokens; duplicates collapse. required: true description: Comma-separated derived-metric tokens. Each is a `` or `:` (the colon sub-targets a VITALS_BASELINE vital). An unknown id 422s; - at most 24 tokens; duplicates collapse. + a `type` outside the MeasurementType enum 422s; at most 24 tokens; duplicates collapse. responses: "200": description: The map of derived-metric values, keyed by token. diff --git a/src/app/api/auth/me/source-priority/route.ts b/src/app/api/auth/me/source-priority/route.ts index 5d9055cec..2deba909d 100644 --- a/src/app/api/auth/me/source-priority/route.ts +++ b/src/app/api/auth/me/source-priority/route.ts @@ -13,13 +13,17 @@ * * Bearer-auth + cookie-auth both work via the shared `requireAuth()` * helper. The analytics aggregator reads this row on every call and - * the read path is cheap (one column) — no caching layer to flush. + * the read path is cheap (one column). The PUT flushes the per-user + * profile-derived caches (`invalidateUserProfile`) because the ladder + * decides which source's rows the cached targets / derived / analytics + * payloads were built from. */ import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; import { apiSuccess, getClientIp } from "@/lib/api-response"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { prisma } from "@/lib/db"; +import { invalidateUserProfile } from "@/lib/cache/invalidate"; import { parseSourcePriority, sourcePrioritySchema, @@ -77,6 +81,12 @@ export const PUT = apiHandler(async (req: Request) => { data: { sourcePriorityJson: parsed.data }, }); + // The priority ladder drives every aggregator's source pick (the + // analytics rank SQL, the rollup read-swap, the targets grid's sleep + // dedup) — drop the cached payloads so the reorder reflects on the + // next read instead of after the TTL. + invalidateUserProfile(user.id); + await auditLog("user.source-priority.update", { userId: user.id, ipAddress: getClientIp(req), diff --git a/src/app/api/insights/derived/batch/__tests__/route.test.ts b/src/app/api/insights/derived/batch/__tests__/route.test.ts index a63189400..9695e352b 100644 --- a/src/app/api/insights/derived/batch/__tests__/route.test.ts +++ b/src/app/api/insights/derived/batch/__tests__/route.test.ts @@ -18,6 +18,10 @@ vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), })); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), + rateLimitHeaders: () => ({}), +})); vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), @@ -40,15 +44,26 @@ vi.mock("@/lib/insights/derived", async (importOriginal) => { computeDerivedMetric: vi.fn(async (args: { metric: string }) => ({ status: "ok", value: { metric: args.metric, n: 1 }, - coverage: { requiredInputs: 1, presentInputs: 1, historyDays: 30, missing: [] }, + coverage: { + requiredInputs: 1, + presentInputs: 1, + historyDays: 30, + missing: [], + }, confidence: { score: 100, band: "high" }, - provenance: { inputs: [], source: "DAY", windowDays: 30, computedAt: "2026-06-02T07:00:00+02:00" }, + provenance: { + inputs: [], + source: "DAY", + windowDays: 30, + computedAt: "2026-06-02T07:00:00+02:00", + }, })), }; }); import { GET } from "../route"; import { getSession } from "@/lib/auth/session"; +import { checkRateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; import { computeDerivedMetric } from "@/lib/insights/derived"; import { __resetAllCachesForTests } from "@/lib/cache/server-cache"; @@ -59,7 +74,12 @@ import { const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, - user: { id: "user-1", username: "testuser", role: "USER" as const, locale: "en" }, + user: { + id: "user-1", + username: "testuser", + role: "USER" as const, + locale: "en", + }, }; const callGet = GET as unknown as (req: NextRequest) => Promise; @@ -74,6 +94,7 @@ beforeEach(() => { // v1.16.8 — the route reads through the module-scope `insightsDerived` // server cache; reset it so every test starts on a cold cell. __resetAllCachesForTests(); + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); vi.mocked(prisma.appSettings.findUnique).mockResolvedValue(null as never); vi.mocked(prisma.user.findUnique).mockResolvedValue({ dateOfBirth: new Date("1986-01-01"), @@ -85,9 +106,7 @@ beforeEach(() => { describe("GET /api/insights/derived/batch", () => { it("resolves several metrics into one keyed map", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - const res = await callGet( - makeReq("READINESS,BMI,VITALS_BASELINE:WEIGHT"), - ); + const res = await callGet(makeReq("READINESS,BMI,VITALS_BASELINE:WEIGHT")); expect(res.status).toBe(200); const body = (await res.json()) as { data: { metrics: Record }; @@ -132,6 +151,38 @@ describe("GET /api/insights/derived/batch", () => { expect(computeDerivedMetric).not.toHaveBeenCalled(); }); + it("422s when a sub-target type is outside the MeasurementType enum", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // The colon sub-target must be a closed-enum member BEFORE it joins + // the compute dispatch and the cache key — an arbitrary token would + // mint unbounded cache cells and echo through coverage / provenance. + const res = await callGet(makeReq("VITALS_BASELINE:NOT_A_TYPE")); + expect(res.status).toBe(422); + expect(computeDerivedMetric).not.toHaveBeenCalled(); + }); + + it("accepts a valid MeasurementType sub-target", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq("VITALS_BASELINE:RESTING_HEART_RATE")); + expect(res.status).toBe(200); + expect(computeDerivedMetric).toHaveBeenCalledWith( + expect.objectContaining({ type: "RESTING_HEART_RATE" }), + ); + }); + + it("429s when the per-user rate limit is exhausted", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: false } as never); + const res = await callGet(makeReq("BMI")); + expect(res.status).toBe(429); + expect(computeDerivedMetric).not.toHaveBeenCalled(); + expect(checkRateLimit).toHaveBeenCalledWith( + "insights-derived-batch:user-1", + 30, + 60_000, + ); + }); + it("422s on a missing metrics param", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); const res = await callGet(makeReq()); @@ -169,10 +220,7 @@ describe("GET /api/insights/derived/batch — server cache (v1.16.8)", () => { const body = (await second.json()) as { data: { metrics: Record }; }; - expect(Object.keys(body.data.metrics).sort()).toEqual([ - "BMI", - "READINESS", - ]); + expect(Object.keys(body.data.metrics).sort()).toEqual(["BMI", "READINESS"]); }); it("hits the same cell regardless of token order", async () => { diff --git a/src/app/api/insights/derived/batch/route.ts b/src/app/api/insights/derived/batch/route.ts index 1569f4713..271d88783 100644 --- a/src/app/api/insights/derived/batch/route.ts +++ b/src/app/api/insights/derived/batch/route.ts @@ -25,14 +25,12 @@ import { z } from "zod/v4"; import pLimit from "p-limit"; import { apiSuccess, apiError, returnAllZodIssues } from "@/lib/api-response"; import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; -import { - cachedSwr, - caches, - type ServerCache, -} from "@/lib/cache/server-cache"; +import { cachedSwr, caches, type ServerCache } from "@/lib/cache/server-cache"; import { requireAssistantSurface } from "@/lib/feature-flags"; import { prisma } from "@/lib/db"; +import { MeasurementType } from "@/generated/prisma/client"; import { computeDerivedMetric, loadBaselineProfile, @@ -77,6 +75,10 @@ const batchQuerySchema = z.object({ * sub-targets a VITALS_BASELINE vital; a bare `metric` carries no type. * Unknown ids are returned as `invalid` so the route 422s the whole * request rather than silently dropping a metric (the closed-enum rule). + * The `type` part is held to the SAME closed-enum rule: it must be a + * member of the `MeasurementType` enum BEFORE it joins the compute + * dispatch and the cache key — an arbitrary token would otherwise mint + * unbounded cache cells and be echoed through coverage / provenance. * Duplicate keys collapse to one item (last write wins) so a repeated * token can't inflate the fan-out. */ @@ -96,6 +98,13 @@ function parseBatchItems(csv: string): { invalid.push(metric); continue; } + if ( + type !== null && + !Object.prototype.hasOwnProperty.call(MeasurementType, type) + ) { + invalid.push(token); + continue; + } const key = type ? `${metric}:${type}` : metric; seen.set(key, { metric, type, key }); } @@ -106,6 +115,22 @@ export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireAuth(); await requireAssistantSurface("insightStatus"); + // Per-user limiter, same posture as the compliance routes: the cold + // build fans out up to 24 rollup walks, so an unthrottled caller could + // hammer the pool past the cache. 30/min covers the dashboard's one + // canonical read plus retries with a wide margin. + const rl = await checkRateLimit( + `insights-derived-batch:${user.id}`, + 30, + 60_000, + ); + if (!rl.allowed) { + return apiError( + "Too many derived-metric requests. Please retry later.", + 429, + ); + } + const parsed = batchQuerySchema.safeParse({ metrics: request.nextUrl.searchParams.get("metrics"), }); @@ -126,10 +151,7 @@ export const GET = apiHandler(async (request: NextRequest) => { action: { name: "insights.derived.batch.invalid-metric" }, meta: { invalid_count: invalid.length }, }); - return apiError( - `Unknown derived metric id(s): ${invalid.join(", ")}`, - 422, - ); + return apiError(`Unknown derived metric id(s): ${invalid.join(", ")}`, 422); } if (items.length === 0 || items.length > MAX_METRICS) { annotate({ @@ -255,8 +277,7 @@ async function buildDerivedBatch(input: { coverage: derived.coverage, confidence: derived.status === "ok" ? derived.confidence : null, provenance: derived.provenance, - reason: - derived.status === "insufficient" ? derived.reason : null, + reason: derived.status === "insufficient" ? derived.reason : null, // Deterministic assessment only (cheap, pure). The AI-warm prose // stays a single-route concern; here every assessable `ok` score // carries its template "why" text, everything else stays null. diff --git a/src/app/api/insights/generate/route.ts b/src/app/api/insights/generate/route.ts index 22dfd4645..48451e071 100644 --- a/src/app/api/insights/generate/route.ts +++ b/src/app/api/insights/generate/route.ts @@ -723,14 +723,22 @@ export const POST = apiHandler(async (request: NextRequest) => { // provider call. The user-initiated POST itself stays un-gated: an // explicit regenerate request is honoured even on unchanged data // (it is already bounded by the hourly rate limit above). The - // about-me text joins the fingerprint — it feeds the prompt and can - // change with no data change (Coach remember, Settings → AI edit) — - // and the shape MUST match the gate in `comprehensive-generate.ts`, + // composite shape MUST match the gate in `comprehensive-generate.ts`, // or every off-request warm after a manual regenerate re-pays a - // full generation on unchanged data. + // full generation on unchanged data. Beyond the features, three + // prompt inputs that can change with NO data change join in: + // - aboutMe (Coach remember / Settings → AI edit), + // - comparisonBaseline (the comparison toggle adds/removes the + // prior-period context block; `null` snapshot means "none"), + // - generationLocale (the language the cached text renders in — + // hashing it makes a locale switch regenerate exactly once, so + // the briefing follows the reader; named `generationLocale` + // because the canonicaliser strips `locale` keys as volatile). insightsSnapshotHash: hashInsightSnapshot({ features: compactFeatures, aboutMe: aboutMe ?? null, + comparisonBaseline: comparisonSnapshot?.baseline ?? "none", + generationLocale: locale, }), }, }); diff --git a/src/app/api/insights/targets/route.ts b/src/app/api/insights/targets/route.ts index d54899ecf..feaf66162 100644 --- a/src/app/api/insights/targets/route.ts +++ b/src/app/api/insights/targets/route.ts @@ -29,6 +29,7 @@ import { getPersonalizedPulseTarget, } from "@/lib/analytics/pulse-targets"; import { resolveRestingPulseSeries } from "@/lib/analytics/resting-pulse"; +import pLimit from "p-limit"; import type { MeasurementType, GlucoseContext, @@ -205,7 +206,11 @@ async function buildTargetsResponse(user: AuthedUser) { // wall-clock the production trace showed. All of them are independent // (the intake read depends only on the medication ids, so the pair // chains inside its own slot); fan them out together and the cold - // build pays roughly the slowest query instead of the sum. + // build pays roughly the slowest query instead of the sum. The + // fan-out is bounded by the project's `p-limit(4)` discipline (parity + // with the derived batch route) so one cold build never claims eight + // connections from the shared Prisma pool at once. + const limit = pLimit(4); const [ dbUser, recentMeasurements, @@ -217,33 +222,37 @@ async function buildTargetsResponse(user: AuthedUser) { glucoseRows, ] = await Promise.all([ // User profile (height / age / gender / thresholds / source priority). - prisma.user.findUnique({ - where: { id: userId }, - select: { - heightCm: true, - dateOfBirth: true, - gender: true, - glucoseUnit: true, - thresholdsJson: true, - // v1.11.5 — needed to collapse a dual-source sleep night to one - // canonical source before reconstructing per-night asleep totals. - sourcePriorityJson: true, - }, - }), + limit(() => + prisma.user.findUnique({ + where: { id: userId }, + select: { + heightCm: true, + dateOfBirth: true, + gender: true, + glucoseUnit: true, + thresholdsJson: true, + // v1.11.5 — needed to collapse a dual-source sleep night to one + // canonical source before reconstructing per-night asleep totals. + sourcePriorityJson: true, + }, + }), + ), // All measurements in the last 30 days for the generic type set. // Soft-delete filter mirrors the W-DELETED pattern: tombstoned rows // must not contribute to tile averages / latest values once iOS sync // starts emitting deletions. - prisma.measurement.findMany({ - where: { - userId, - type: { in: types }, - measuredAt: { gte: thirtyDaysAgo }, - deletedAt: null, - }, - orderBy: { measuredAt: "desc" }, - select: { type: true, value: true, measuredAt: true }, - }), + limit(() => + prisma.measurement.findMany({ + where: { + userId, + type: { in: types }, + measuredAt: { gte: thirtyDaysAgo }, + deletedAt: null, + }, + orderBy: { measuredAt: "desc" }, + select: { type: true, value: true, measuredAt: true }, + }), + ), // Absolute latest measurement per type within a one-year floor. // // Prisma's `distinct` does not compile to Postgres `DISTINCT ON` — the @@ -257,40 +266,44 @@ async function buildTargetsResponse(user: AuthedUser) { // forward — which is the correct behaviour for the consistency strip // (the strip already gates on "fewer than 3 readings in 30 days" → // insufficientData). - prisma.measurement.findMany({ - where: { - userId, - type: { in: types }, - measuredAt: { gte: oneYearAgo }, - deletedAt: null, - }, - orderBy: { measuredAt: "desc" }, - distinct: ["type"], - select: { type: true, value: true }, - }), + limit(() => + prisma.measurement.findMany({ + where: { + userId, + type: { in: types }, + measuredAt: { gte: oneYearAgo }, + deletedAt: null, + }, + orderBy: { measuredAt: "desc" }, + distinct: ["type"], + select: { type: true, value: true }, + }), + ), // Sleep stage rows for the per-night reconstruction (section 4). - prisma.measurement.findMany({ - where: { - userId, - type: "SLEEP_DURATION", - measuredAt: { gte: thirtyDaysAgo }, - deletedAt: null, - }, - orderBy: { measuredAt: "asc" }, - // Writer-level collapse: two HealthKit apps behind one source (watch - // stages vs phone in-bed) must not blend into one night. - select: { - value: true, - measuredAt: true, - sleepStage: true, - source: true, - deviceType: true, - }, - }), + limit(() => + prisma.measurement.findMany({ + where: { + userId, + type: "SLEEP_DURATION", + measuredAt: { gte: thirtyDaysAgo }, + deletedAt: null, + }, + orderBy: { measuredAt: "asc" }, + // Writer-level collapse: two HealthKit apps behind one source (watch + // stages vs phone in-bed) must not blend into one night. + select: { + value: true, + measuredAt: true, + sleepStage: true, + source: true, + deviceType: true, + }, + }), + ), // Active medications + their 30-day intake events (section 8). The // intake read depends on the medication ids, so the pair chains // inside this slot while riding the outer fan-out. - (async () => { + limit(async () => { const activeMedications = await prisma.medication.findMany({ where: { userId, active: true }, // v1.15.20 — schedules through the shared compliance select so the @@ -324,31 +337,35 @@ async function buildTargetsResponse(user: AuthedUser) { }, }); return { activeMedications, intakeEvents }; - })(), - // Mood day rollups (section 9). - readMoodDayRollups(userId, moodSince), - // Latest raw mood entry — bounded `take: 1` via the index. - prisma.moodEntry.findFirst({ - // v1.7.0 sync — exclude tombstoned rows. - where: { userId, deletedAt: null }, - orderBy: { moodLoggedAt: "desc" }, - select: { score: true, moodLoggedAt: true }, }), + // Mood day rollups (section 9). + limit(() => readMoodDayRollups(userId, moodSince)), + // Latest raw mood entry — bounded `take: 1` via the index. + limit(() => + prisma.moodEntry.findFirst({ + // v1.7.0 sync — exclude tombstoned rows. + where: { userId, deletedAt: null }, + orderBy: { moodLoggedAt: "desc" }, + select: { score: true, moodLoggedAt: true }, + }), + ), // Glucose rows (section 10). Same one-year floor as the // latest-per-type read above — the previous unbounded scan pulled a // heavy logger's full history back to Node on every cold build; a // context whose newest reading is over a year old now reports no // card, matching the per-type tile posture. - prisma.measurement.findMany({ - where: { - userId, - type: "BLOOD_GLUCOSE", - measuredAt: { gte: oneYearAgo }, - deletedAt: null, - }, - orderBy: { measuredAt: "desc" }, - select: { value: true, measuredAt: true, glucoseContext: true }, - }), + limit(() => + prisma.measurement.findMany({ + where: { + userId, + type: "BLOOD_GLUCOSE", + measuredAt: { gte: oneYearAgo }, + deletedAt: null, + }, + orderBy: { measuredAt: "desc" }, + select: { value: true, measuredAt: true, glucoseContext: true }, + }), + ), ]); const age = dbUser?.dateOfBirth ? getAge(new Date(dbUser.dateOfBirth)) : null; @@ -893,7 +910,8 @@ async function buildTargetsResponse(user: AuthedUser) { (sorted.length - mid); const diff = avgSecond - avgFirst; const threshold = avgFirst * 0.02; - restingTrend = diff > threshold ? "up" : diff < -threshold ? "down" : "stable"; + restingTrend = + diff > threshold ? "up" : diff < -threshold ? "down" : "stable"; } targets.push({ @@ -1291,7 +1309,11 @@ async function buildTargetsResponse(user: AuthedUser) { // the bound the legacy code was missing. const recentRawMood = await prisma.moodEntry.findMany({ // v1.7.0 sync — exclude tombstoned rows. - where: { userId, deletedAt: null, moodLoggedAt: { gte: thirtyDaysAgoMood } }, + where: { + userId, + deletedAt: null, + moodLoggedAt: { gte: thirtyDaysAgoMood }, + }, orderBy: { moodLoggedAt: "asc" }, select: { score: true, moodLoggedAt: true }, }); @@ -1316,9 +1338,7 @@ async function buildTargetsResponse(user: AuthedUser) { const moodAvg30 = moodDailyEvents.length > 0 - ? Math.round( - (moodSumScores / moodDailyEvents.length) * 10, - ) / 10 + ? Math.round((moodSumScores / moodDailyEvents.length) * 10) / 10 : null; // Mood trend: compare first half to second half of last 30 days. diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts index d33694a57..92d669497 100644 --- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts +++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts @@ -63,6 +63,7 @@ import { POST } from "../route"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; +import { invalidateUserMedications } from "@/lib/cache/invalidate"; const SESSION_OK = { session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, @@ -296,6 +297,12 @@ describe("POST /api/medications/intake/bulk — v1.8.2 reconcile", () => { }; expect(body.data.updated).toBe(1); expect(body.data.entries[0].status).toBe("updated"); + // The bulk endpoint is the iOS user's interactive intake path — the + // write must HARD-EVICT the SWR buckets so the next read shows the + // dose, never the pre-write stale payload. + expect(invalidateUserMedications).toHaveBeenCalledWith("user-1", { + evict: true, + }); }); it("C1 — a same-slot P2002 on create converges via re-find+update, not dropped as duplicate", async () => { @@ -686,7 +693,10 @@ describe("POST /api/medications/intake/bulk — v1.15.20 band attribution", () = ); expect(res.status).toBe(200); const body = (await res.json()) as { - data: { updated: number; entries: Array<{ status: string; id?: string }> }; + data: { + updated: number; + entries: Array<{ status: string; id?: string }>; + }; }; expect(body.data.updated).toBe(1); expect(body.data.entries[0].status).toBe("updated"); diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 6b7b42759..8bc18e95b 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -569,13 +569,15 @@ async function postBulk(request: NextRequest): Promise { // v1.4.34 IW-G — bust per-user medications + compliance + achievement // caches when at least one row landed so the next read reflects the - // ingested batch. v1.16.8 — this is the background sync path (iOS bulk - // ingest), so the SWR buckets are marked stale rather than hard-evicted: - // the readers keep serving the prior list / compliance payloads while - // one coalesced recompute warms each cell, instead of a high-frequency - // sync busting every card into an inline cold rebuild. + // ingested batch. v1.16.8 — hard-evict: this bulk endpoint is the iOS + // user's INTERACTIVE intake path (a dose taken / skipped on the phone + // syncs through here), so the very next read must show the user their + // own action — a marked-stale SWR cell would hand back the pre-dose + // payload. The genuinely background writers (the auto-miss cron in + // `intake-auto-skip.ts`, slot dedup in `intake-slot-dedup.ts`) keep + // the default mark-stale at their own call sites. if (inserted > 0 || updated > 0) { - invalidateUserMedications(user.id); + invalidateUserMedications(user.id, { evict: true }); } // v1.4.39 W-MED — refresh one rollup row per distinct diff --git a/src/app/api/user/thresholds/route.ts b/src/app/api/user/thresholds/route.ts index c13eb52b0..c11ad5410 100644 --- a/src/app/api/user/thresholds/route.ts +++ b/src/app/api/user/thresholds/route.ts @@ -19,6 +19,7 @@ import { annotate } from "@/lib/logging/context"; import { prisma } from "@/lib/db"; import { auditLog } from "@/lib/auth/audit"; import { checkRateLimit } from "@/lib/rate-limit"; +import { invalidateUserProfile } from "@/lib/cache/invalidate"; import { getAllEffectiveRanges, type ThresholdOverridesJson, @@ -99,6 +100,11 @@ export const PUT = apiHandler(async (request: NextRequest) => { data: { thresholdsJson: merged }, }); + // Threshold overrides feed the targets grid's effective ranges and the + // analytics snapshot — drop the cached payloads so the next read + // reflects the new bands instead of serving the pre-edit grid. + invalidateUserProfile(user.id); + await auditLog("thresholds.update", { userId: user.id, ipAddress: getClientIp(request), @@ -142,6 +148,10 @@ export const DELETE = apiHandler(async (request: NextRequest) => { }, }); + // A reset changes the effective ranges back to the defaults — same + // eviction as the PUT above. + invalidateUserProfile(user.id); + await auditLog("thresholds.reset", { userId: user.id, ipAddress: getClientIp(request), diff --git a/src/lib/__tests__/export.test.ts b/src/lib/__tests__/export.test.ts index 944a38e69..5178b8d3c 100644 --- a/src/lib/__tests__/export.test.ts +++ b/src/lib/__tests__/export.test.ts @@ -47,6 +47,40 @@ describe("toCSV", () => { const csv = toCSV(records); expect(csv).toContain("2025-01-15T12:00:00.000Z"); }); + + // Spreadsheet formula injection (OWASP CSV injection): text cells + // starting with a formula trigger are neutralised with a leading `'`. + // Third-party text reaches these cells (mood notes via the moodLog + // webhook), so a crafted payload must open as literal text. + it("neutralises formula prefixes in text cells", () => { + const records = [ + { note: '=HYPERLINK("http://evil","x")' }, + { note: "+SUM(A1:A9)" }, + { note: "-2+3+cmd|' /C calc'!A0" }, + { note: "@SUM(A1)" }, + { note: "\tleading tab" }, + ]; + const lines = toCSV(records).split("\n"); + // RFC 4180 quoting layers on top of the neutralised prefix (the + // payload carries commas + quotes). + expect(lines[1]).toBe('"\'=HYPERLINK(""http://evil"",""x"")"'); + expect(lines[2]).toBe("'+SUM(A1:A9)"); + expect(lines[3]).toBe("'-2+3+cmd|' /C calc'!A0"); + expect(lines[4]).toBe("'@SUM(A1)"); + expect(lines[5]).toBe("'\tleading tab"); + }); + + it("does not mangle numeric or boolean cells (negative numbers stay bare)", () => { + const records = [{ delta: -5.2, count: 3, flagged: false }]; + const lines = toCSV(records).split("\n"); + // The number -5.2 is typed `number`, not text — no quote prefix. + expect(lines[1]).toBe("-5.2,3,false"); + }); + + it("leaves benign text cells untouched", () => { + const records = [{ note: "felt fine after the walk" }]; + expect(toCSV(records).split("\n")[1]).toBe("felt fine after the walk"); + }); }); describe("formatMeasurementsForExport", () => { diff --git a/src/lib/analytics/__tests__/sleep-night.test.ts b/src/lib/analytics/__tests__/sleep-night.test.ts index 203128283..93fc874ec 100644 --- a/src/lib/analytics/__tests__/sleep-night.test.ts +++ b/src/lib/analytics/__tests__/sleep-night.test.ts @@ -1,8 +1,5 @@ import { describe, it, expect } from "vitest"; -import type { - MeasurementSource, - SleepStage, -} from "@/generated/prisma/client"; +import type { MeasurementSource, SleepStage } from "@/generated/prisma/client"; import { reconstructSleepNights, reconstructSleepSessions, @@ -33,7 +30,12 @@ function srcRow( minutes: number, source: MeasurementSource, ): SleepStageRow { - return { measuredAt: new Date(iso), sleepStage: stage, value: minutes, source }; + return { + measuredAt: new Date(iso), + sleepStage: stage, + value: minutes, + source, + }; } describe("reconstructSleepNights", () => { @@ -81,9 +83,7 @@ describe("reconstructSleepNights", () => { }); it("treats a bare SLEEP_DURATION row (no stage) as the night total", () => { - const rows: SleepStageRow[] = [ - row("2026-06-04T06:00:00.000Z", null, 423), - ]; + const rows: SleepStageRow[] = [row("2026-06-04T06:00:00.000Z", null, 423)]; const nights = reconstructSleepNights(rows, "UTC"); expect(nights).toHaveLength(1); expect(nights[0].asleepMinutes).toBe(423); @@ -574,7 +574,10 @@ describe("summarizeSleepNights", () => { row("2026-06-03T23:30:00.000Z", "DEEP", 150), // 23:00 → 01:30 Jun 4 row("2026-06-04T04:00:00.000Z", "REM", 270), // 23:30 → 06:00 Jun 4 ]; - const { summary, latestNight } = summarizeSleepNights(rows, "Europe/Berlin"); + const { summary, latestNight } = summarizeSleepNights( + rows, + "Europe/Berlin", + ); expect(summary.count).toBe(2); expect(latestNight?.night).toBe("2026-06-04"); // Full last night = 150 + 150 + 270 = 570 (not just a post-midnight slice). @@ -663,9 +666,7 @@ describe("reconstructSleepSessions total-safety (A5)", () => { it("never throws on a stage-less-only session", () => { // Legacy / manual stage-less rows only — no granular partition, so they are // the fallback signal and survive the filter; still must not throw. - const rows: SleepStageRow[] = [ - row("2026-06-04T06:00:00.000Z", null, 480), - ]; + const rows: SleepStageRow[] = [row("2026-06-04T06:00:00.000Z", null, 480)]; expect(() => reconstructSleepSessions(rows, "UTC")).not.toThrow(); }); }); @@ -712,11 +713,15 @@ describe("per-night writer richness pick", () => { // The winner's own awake minutes survive; the coarse 400 must not. expect(nights[0].awakeMinutes).toBe(35); expect(nights[0].asleepMinutes).toBe(410); + // The losing writer's IN_BED envelope survives — "Zeit im Bett" is a + // union across writers, never erased by the stage-richness pick. + expect(nights[0].inBedMinutes).toBe(250); const sessions = reconstructSleepSessions(rows, "Europe/Berlin"); expect(sessions).toHaveLength(1); expect(sessions[0].source).toBe("WHOOP"); expect(sessions[0].stages.AWAKE).toBe(35); + expect(sessions[0].inBedMinutes).toBe(250); }); it("separates writer apps behind ONE source: phone in-bed must not blend into watch stages", () => { @@ -726,13 +731,49 @@ describe("per-night writer richness pick", () => { // both writers and inflate the night's awake total — the device-type // refinement must keep only the stage-bearing writer. const rows: SleepStageRow[] = [ - writerRow("2026-06-04T00:30:00.000Z", "CORE", 120, "APPLE_HEALTH", "watch"), - writerRow("2026-06-04T02:30:00.000Z", "DEEP", 60, "APPLE_HEALTH", "watch"), + writerRow( + "2026-06-04T00:30:00.000Z", + "CORE", + 120, + "APPLE_HEALTH", + "watch", + ), + writerRow( + "2026-06-04T02:30:00.000Z", + "DEEP", + 60, + "APPLE_HEALTH", + "watch", + ), writerRow("2026-06-04T04:00:00.000Z", "REM", 80, "APPLE_HEALTH", "watch"), - writerRow("2026-06-04T04:30:00.000Z", "AWAKE", 15, "APPLE_HEALTH", "watch"), - writerRow("2026-06-04T04:30:00.000Z", "IN_BED", 460, "APPLE_HEALTH", "phone"), - writerRow("2026-06-04T00:35:00.000Z", "ASLEEP", 200, "APPLE_HEALTH", "phone"), - writerRow("2026-06-04T03:00:00.000Z", "AWAKE", 385, "APPLE_HEALTH", "phone"), + writerRow( + "2026-06-04T04:30:00.000Z", + "AWAKE", + 15, + "APPLE_HEALTH", + "watch", + ), + writerRow( + "2026-06-04T04:30:00.000Z", + "IN_BED", + 460, + "APPLE_HEALTH", + "phone", + ), + writerRow( + "2026-06-04T00:35:00.000Z", + "ASLEEP", + 200, + "APPLE_HEALTH", + "phone", + ), + writerRow( + "2026-06-04T03:00:00.000Z", + "AWAKE", + 385, + "APPLE_HEALTH", + "phone", + ), ]; const nights = reconstructSleepNights(rows, "Europe/Berlin"); expect(nights).toHaveLength(1); @@ -743,6 +784,54 @@ describe("per-night writer richness pick", () => { expect(nights[0].awakeMinutes).toBe(15); // The phone's bare ASLEEP must not blend into the asleep total either. expect(nights[0].asleepMinutes).toBe(260); + // The night keeps the stages AND a sane in-bed figure: the watch wins + // the stage views, but "Zeit im Bett" is the union envelope across + // writers — the phone's 460-minute IN_BED window must not shrink to + // null just because the watch carried no IN_BED row. + expect(nights[0].inBedMinutes).toBe(460); + + const sessions = reconstructSleepSessions(rows, "Europe/Berlin"); + expect(sessions).toHaveLength(1); + // The session's hypnogram segments stay winner-only (no phone lanes)… + expect(sessions[0].segments.some((seg) => seg.stage === "IN_BED")).toBe( + false, + ); + // …while the session-level in-bed figure keeps the phone's envelope. + expect(sessions[0].inBedMinutes).toBe(460); + }); + + it("merges overlapping IN_BED spans from two writers without double-counting", () => { + // Watch and phone both export an IN_BED window for the same night; + // the spans overlap by four hours. The union envelope is 22:00 → + // 06:00 (480 min), never the 360 + 360 = 720 sum. + const rows: SleepStageRow[] = [ + writerRow( + "2026-06-04T02:00:00.000Z", + "CORE", + 200, + "APPLE_HEALTH", + "watch", + ), + // Watch IN_BED 22:00 → 04:00 UTC. + writerRow( + "2026-06-04T04:00:00.000Z", + "IN_BED", + 360, + "APPLE_HEALTH", + "watch", + ), + // Phone IN_BED 00:00 → 06:00 UTC — overlaps the watch span. + writerRow( + "2026-06-04T06:00:00.000Z", + "IN_BED", + 360, + "APPLE_HEALTH", + "phone", + ), + ]; + const nights = reconstructSleepNights(rows, "UTC"); + expect(nights).toHaveLength(1); + expect(nights[0].inBedMinutes).toBe(480); }); it("rows without a device-type collapse per source exactly as before", () => { @@ -767,8 +856,20 @@ describe("per-night writer richness pick", () => { // richness must pick the watch. const rows: SleepStageRow[] = [ srcRow("2026-06-04T04:30:00.000Z", "CORE", 300, "WHOOP"), - writerRow("2026-06-04T00:30:00.000Z", "CORE", 120, "APPLE_HEALTH", "watch"), - writerRow("2026-06-04T02:30:00.000Z", "DEEP", 60, "APPLE_HEALTH", "watch"), + writerRow( + "2026-06-04T00:30:00.000Z", + "CORE", + 120, + "APPLE_HEALTH", + "watch", + ), + writerRow( + "2026-06-04T02:30:00.000Z", + "DEEP", + 60, + "APPLE_HEALTH", + "watch", + ), writerRow("2026-06-04T04:00:00.000Z", "REM", 80, "APPLE_HEALTH", "watch"), ]; const nights = reconstructSleepNights(rows, "Europe/Berlin"); @@ -784,8 +885,20 @@ describe("per-night writer richness pick", () => { srcRow("2026-06-04T01:00:00.000Z", "CORE", 240, "WHOOP"), srcRow("2026-06-04T03:00:00.000Z", "DEEP", 80, "WHOOP"), srcRow("2026-06-04T04:30:00.000Z", "REM", 90, "WHOOP"), - writerRow("2026-06-04T00:30:00.000Z", "CORE", 120, "APPLE_HEALTH", "watch"), - writerRow("2026-06-04T02:30:00.000Z", "DEEP", 60, "APPLE_HEALTH", "watch"), + writerRow( + "2026-06-04T00:30:00.000Z", + "CORE", + 120, + "APPLE_HEALTH", + "watch", + ), + writerRow( + "2026-06-04T02:30:00.000Z", + "DEEP", + 60, + "APPLE_HEALTH", + "watch", + ), writerRow("2026-06-04T04:00:00.000Z", "REM", 80, "APPLE_HEALTH", "watch"), ]; const sessions = reconstructSleepSessions(rows, "Europe/Berlin"); diff --git a/src/lib/analytics/sleep-night.ts b/src/lib/analytics/sleep-night.ts index 08c652648..3b33034fd 100644 --- a/src/lib/analytics/sleep-night.ts +++ b/src/lib/analytics/sleep-night.ts @@ -86,6 +86,18 @@ * session, mirroring the per-(type, day) collapse the rest of the v1.11.x * numeric surfaces apply. * + * One exception: `inBedMinutes` ("Zeit im Bett") derives from the UNION + * of the IN_BED spans across ALL writers in the session, not from the + * winner alone. The common pairing is a watch that knows the stages but + * writes no IN_BED row and a phone whose bedtime detection writes only + * the long IN_BED window — the watch wins the night on richness, and a + * winner-only in-bed figure would shrink to nothing exactly when the + * stage data is best. Overlapping spans from two writers are merged + * interval-union style so a doubled export never double-counts. The + * asleep total, the `stages` map, and `awakeMinutes` stay winner-only — + * blending the phone's coarse AWAKE blocks into the watch's night was + * the original inflated-awake bug this collapse exists to prevent. + * * Unit * ---- * Totals are returned in MINUTES — the canonical `SLEEP_DURATION` unit @@ -175,6 +187,44 @@ function asleepMinutesOf(rows: readonly SleepStageRow[]): number { return sawGranular ? granular : fallback; } +/** + * Total minutes covered by the UNION of the IN_BED spans across EVERY + * writer in a session ("Zeit im Bett"). Each IN_BED row resolves to the + * absolute span `[measuredAt − value·60_000, measuredAt]`; overlapping + * spans (the same window exported by two writers) merge before summing + * so a doubled export never double-counts the night. Returns `null` + * when no IN_BED row exists anywhere in the session — the caller keeps + * the "no in-bed signal" semantics. Deliberately NOT winner-scoped: the + * stage-rich watch usually writes no IN_BED row while the phone writes + * only the envelope, and the headline must keep both facets. + */ +function inBedEnvelopeMinutes(rows: readonly SleepStageRow[]): number | null { + const intervals: Array<{ start: number; end: number }> = []; + for (const r of rows) { + if (r.sleepStage !== "IN_BED") continue; + const minutes = Number.isFinite(r.value) ? r.value : 0; + const end = r.measuredAt.getTime(); + intervals.push({ start: end - minutes * 60_000, end }); + } + if (intervals.length === 0) return null; + intervals.sort((a, b) => a.start - b.start); + let total = 0; + let curStart = intervals[0].start; + let curEnd = intervals[0].end; + for (let i = 1; i < intervals.length; i++) { + const { start, end } = intervals[i]; + if (start <= curEnd) { + if (end > curEnd) curEnd = end; + } else { + total += curEnd - curStart; + curStart = start; + curEnd = end; + } + } + total += curEnd - curStart; + return Math.round(total / 60_000); +} + /** * Gap (ms) between consecutive stage segments that starts a new sleep * session. Stage segments within one night are contiguous (minutes to a @@ -214,11 +264,17 @@ export interface SleepNight { measuredAt: Date; /** Time asleep in minutes = CORE + DEEP + REM, or bare ASLEEP when granular stages are absent. */ asleepMinutes: number; - /** In-bed minutes when an IN_BED row exists, else null. */ + /** + * In-bed minutes when an IN_BED row exists ANYWHERE in the night's + * sessions, else null. Union envelope across ALL writers — not + * winner-scoped like `asleepMinutes` / `stages` / `awakeMinutes` — + * so a stage-rich watch winning the night never erases the phone's + * in-bed window. See the module doc's writer-de-dup exception. + */ inBedMinutes: number | null; /** Awake-in-bed minutes when an AWAKE row exists, else null. */ awakeMinutes: number | null; - /** Per-stage minutes for the night (only stages the device reported). */ + /** Per-stage minutes for the night (only stages the winning writer reported). */ stages: Partial>; } @@ -353,7 +409,10 @@ export function reconstructSleepNights( priorityJson: unknown = null, ): SleepNight[] { if (rows.length === 0) return []; - const sleepLadder = getSourceLadder(parseSourcePriority(priorityJson), "sleep"); + const sleepLadder = getSourceLadder( + parseSourcePriority(priorityJson), + "sleep", + ); // Cluster into sessions by the gap between a segment's START and the latest // END seen so far in the current session. `measuredAt` is the segment END; @@ -364,8 +423,7 @@ export function reconstructSleepNights( // (their gap is ≤ 0). Gaps are absolute-time, so the clustering is DST-immune. // Sort by START so contiguous segments compare end-to-start in order. const startOf = (r: SleepStageRow): number => - r.measuredAt.getTime() - - (Number.isFinite(r.value) ? r.value : 0) * 60_000; + r.measuredAt.getTime() - (Number.isFinite(r.value) ? r.value : 0) * 60_000; const sorted = [...rows].sort((a, b) => startOf(a) - startOf(b)); const sessions: SleepStageRow[][] = []; let current: SleepStageRow[] = []; @@ -388,8 +446,14 @@ export function reconstructSleepNights( // wake-day key. The rows are grouped PER SESSION (not flattened) so the // granular-over-bare gate and the asleep total are decided per session — a // bare-only nap keeps its minutes even when the overnight session is - // granular (the v1.11.5 merged-night nap under-count fix). - const byNight = new Map(); + // granular (the v1.11.5 merged-night nap under-count fix). The FULL + // session row set rides along because the in-bed envelope spans every + // writer, not just the winner (see the module doc's writer-de-dup + // exception). + const byNight = new Map< + string, + Array<{ pool: SleepStageRow[]; all: SleepStageRow[] }> + >(); for (const session of sessions) { const canonical = pickSessionWriter(session, sleepLadder); const kept = session.filter((r) => writerKeyOf(r) === canonical); @@ -402,7 +466,7 @@ export function reconstructSleepNights( ); const key = userDayKey(wakeInstant, tz); const list = byNight.get(key) ?? []; - list.push(pool); + list.push({ pool, all: session }); byNight.set(key, list); } @@ -416,12 +480,21 @@ export function reconstructSleepNights( // `nightSessions` and each session pool are non-empty by construction: a // key is only added with a `pool` that has ≥ 1 row (the source-filter falls // back to the full session when the canonical filter empties it), so - // `nightSessions[0][0]` is safe. Unlike `reconstructSleepSessions`, this - // function never applies the granular-over-bare filter to the indexed pool, - // so it cannot empty the array here. Keep that invariant on future edits. - let latest = nightSessions[0][0].measuredAt; + // `nightSessions[0].pool[0]` is safe. Unlike `reconstructSleepSessions`, + // this function never applies the granular-over-bare filter to the indexed + // pool, so it cannot empty the array here. Keep that invariant on future + // edits. + let latest = nightSessions[0].pool[0].measuredAt; const stages: Partial> = {}; - for (const sessionRows of nightSessions) { + for (const { pool: sessionRows, all } of nightSessions) { + // In-bed: union envelope across ALL writers in the session (sessions + // are > 3 h apart, so per-session envelopes never overlap and summing + // them per wake day is exact). + const envelope = inBedEnvelopeMinutes(all); + if (envelope !== null) { + inBed += envelope; + sawInBed = true; + } // v1.11.5 — decide the granular-over-bare gate PER SESSION. A granular // overnight does not strip a bare-only nap's minutes, and the per-stage // `stages` map drops the redundant bare ASLEEP aggregate only for the @@ -441,10 +514,7 @@ export function reconstructSleepNights( if (stage) { stages[stage] = (stages[stage] ?? 0) + minutes; } - if (stage === "IN_BED") { - inBed += minutes; - sawInBed = true; - } else if (stage === "AWAKE") { + if (stage === "AWAKE") { awake += minutes; sawAwake = true; } @@ -497,7 +567,11 @@ export interface SleepSession { end: Date; /** Time asleep in minutes (granular-over-bare rule). */ asleepMinutes: number; - /** In-bed minutes when an IN_BED segment exists, else null. */ + /** + * In-bed minutes when an IN_BED row exists anywhere in the session, + * else null. Union envelope across ALL writers (not just the + * canonical one) — see the module doc's writer-de-dup exception. + */ inBedMinutes: number | null; /** Awake-in-bed minutes when an AWAKE segment exists, else null. */ awakeMinutes: number | null; @@ -570,11 +644,13 @@ export function reconstructSleepSessions( priorityJson: unknown = null, ): SleepSession[] { if (rows.length === 0) return []; - const sleepLadder = getSourceLadder(parseSourcePriority(priorityJson), "sleep"); + const sleepLadder = getSourceLadder( + parseSourcePriority(priorityJson), + "sleep", + ); const startOf = (r: SleepStageRow): number => - r.measuredAt.getTime() - - (Number.isFinite(r.value) ? r.value : 0) * 60_000; + r.measuredAt.getTime() - (Number.isFinite(r.value) ? r.value : 0) * 60_000; const sorted = [...rows].sort((a, b) => startOf(a) - startOf(b)); const rawSessions: SleepStageRow[][] = []; let current: SleepStageRow[] = []; @@ -618,9 +694,7 @@ export function reconstructSleepSessions( // other consumer) returns a valid empty night, never a 500. if (segments.length === 0) continue; - let inBed = 0; let awake = 0; - let sawInBed = false; let sawAwake = false; const stages: Partial> = {}; let earliest = segments[0].start; @@ -630,14 +704,17 @@ export function reconstructSleepSessions( if (seg.end.getTime() > latest.getTime()) latest = seg.end; const stage = seg.stage; if (stage) stages[stage] = (stages[stage] ?? 0) + seg.minutes; - if (stage === "IN_BED") { - inBed += seg.minutes; - sawInBed = true; - } else if (stage === "AWAKE") { + if (stage === "AWAKE") { awake += seg.minutes; sawAwake = true; } } + // In-bed: union envelope across ALL writers in the RAW session — the + // canonical (winning) writer often carries no IN_BED row at all (a + // watch's stage export) while the losing writer holds the night's + // only in-bed window (the phone's bedtime detection). The hypnogram + // `segments` + `stages` stay winner-only. + const inBedEnvelope = inBedEnvelopeMinutes(session); const asleep = asleepMinutesOf(pool); const canonicalSource = sourceOfWriterKey(canonical); sessions.push({ @@ -649,7 +726,7 @@ export function reconstructSleepSessions( start: earliest, end: latest, asleepMinutes: asleep, - inBedMinutes: sawInBed ? inBed : null, + inBedMinutes: inBedEnvelope, awakeMinutes: sawAwake ? awake : null, stages, awakenings: countAwakenings(segments), diff --git a/src/lib/auth/profile-update.ts b/src/lib/auth/profile-update.ts index 9c2eea443..7b531e2ce 100644 --- a/src/lib/auth/profile-update.ts +++ b/src/lib/auth/profile-update.ts @@ -8,6 +8,7 @@ import { auditLog } from "@/lib/auth/audit"; import { profileSchema } from "@/lib/validations/auth"; import { isValidTimezone } from "@/lib/tz/format"; import { encrypt } from "@/lib/crypto"; +import { invalidateUserProfile } from "@/lib/cache/invalidate"; import { z } from "zod/v4"; const extendedProfileSchema = profileSchema.extend({ @@ -154,6 +155,19 @@ export async function applyProfileUpdate( data: updates, }); + // Anthropometrics feed the cached targets grid (BMI band, BP-by-age + // range, body-fat band), the derived-score baseline profile (age / + // sex / height) and the analytics snapshot. Only those three fields + // matter here — a display-name or email edit must not cold-rebuild + // the analytics caches. + if ( + data.heightCm !== undefined || + data.dateOfBirth !== undefined || + data.gender !== undefined + ) { + invalidateUserProfile(userId); + } + await auditLog("profile.update", { userId: updatedUser.id, ipAddress: ipAddress ?? null, diff --git a/src/lib/cache/__tests__/invalidate.test.ts b/src/lib/cache/__tests__/invalidate.test.ts index 15bf74a86..ec1885c4f 100644 --- a/src/lib/cache/__tests__/invalidate.test.ts +++ b/src/lib/cache/__tests__/invalidate.test.ts @@ -6,11 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - __resetAllCachesForTests, - cached, - caches, -} from "../server-cache"; +import { __resetAllCachesForTests, cached, caches } from "../server-cache"; import { invalidateAppSettings, invalidateUserDashboardWidgets, @@ -19,6 +15,7 @@ import { invalidateUserMeasurements, invalidateUserMedications, invalidateUserMood, + invalidateUserProfile, dashboardSnapshotCacheKey, } from "../invalidate"; @@ -43,12 +40,20 @@ async function primeAllCaches(): Promise { await cached(caches.medications, USER_A, async () => ({ m: 1 })); await cached(caches.medications, USER_B, async () => ({ m: 2 })); - await cached(caches.medicationsIntake, `${USER_A}|compliance|30`, async () => ({ - c: 1, - })); - await cached(caches.medicationsIntake, `${USER_B}|compliance|30`, async () => ({ - c: 2, - })); + await cached( + caches.medicationsIntake, + `${USER_A}|compliance|30`, + async () => ({ + c: 1, + }), + ); + await cached( + caches.medicationsIntake, + `${USER_B}|compliance|30`, + async () => ({ + c: 2, + }), + ); // v1.15.20 — per-medication compliance payload cache. await cached( @@ -149,9 +154,7 @@ describe("invalidateUserMeasurements", () => { ).toBeNull(); // User B stays warm; the non-analytics buckets evict as before. - expect( - caches.analytics.getAllowStale(`${USER_B}|default`), - ).not.toBeNull(); + expect(caches.analytics.getAllowStale(`${USER_B}|default`)).not.toBeNull(); expect(caches.achievements.get(USER_A)).toBeNull(); expect(caches.workouts.get(`${USER_A}|3|0||`)).toBeNull(); }); @@ -197,7 +200,9 @@ describe("invalidateUserMedications", () => { // covers the snapshot key. expect(caches.analytics.get(dashboardSnapshotCacheKey(USER_A))).toBeNull(); expect(caches.medications.get(USER_B)).not.toBeNull(); - expect(caches.medicationsIntake.get(`${USER_B}|compliance|30`)).not.toBeNull(); + expect( + caches.medicationsIntake.get(`${USER_B}|compliance|30`), + ).not.toBeNull(); }); it("evicts the per-medication compliance payload for the target user only", async () => { @@ -308,6 +313,41 @@ describe("invalidateUserInsights", () => { }); }); +describe("invalidateUserProfile", () => { + it("hard-evicts targets + derived + analytics for the target user only", async () => { + await primeAllCaches(); + await cached(caches.insightsTargets, USER_A, async () => ({ t: 1 })); + await cached(caches.insightsTargets, USER_B, async () => ({ t: 2 })); + await cached(caches.insightsDerived, `${USER_A}|batch|x|en`, async () => ({ + d: 1, + })); + await cached(caches.insightsDerived, `${USER_B}|batch|x|en`, async () => ({ + d: 2, + })); + + invalidateUserProfile(USER_A); + + // Profile-derived payloads drop — including the SWR stale window + // (a Settings save is interactive; the pre-edit grid must not serve). + expect(caches.insightsTargets.getAllowStale(USER_A)).toBeNull(); + expect( + caches.insightsDerived.getAllowStale(`${USER_A}|batch|x|en`), + ).toBeNull(); + expect( + caches.analytics.getAllowStale(dashboardSnapshotCacheKey(USER_A)), + ).toBeNull(); + expect(caches.analytics.getAllowStale(`${USER_A}|default`)).toBeNull(); + + // The other user's cells stay warm. + expect(caches.insightsTargets.get(USER_B)).not.toBeNull(); + expect(caches.insightsDerived.get(`${USER_B}|batch|x|en`)).not.toBeNull(); + expect(caches.analytics.get(`${USER_B}|default`)).not.toBeNull(); + + // Buckets a profile edit does not feed stay untouched. + expect(caches.medications.get(USER_A)).not.toBeNull(); + }); +}); + describe("invalidateAppSettings", () => { it("evicts the entire bug-report status cache (singleton row)", async () => { await primeAllCaches(); diff --git a/src/lib/cache/__tests__/server-cache.test.ts b/src/lib/cache/__tests__/server-cache.test.ts index 6088a2e25..12664fbb2 100644 --- a/src/lib/cache/__tests__/server-cache.test.ts +++ b/src/lib/cache/__tests__/server-cache.test.ts @@ -62,7 +62,13 @@ describe("ServerCache per-key TTL override", () => { it("wrap()/cached() thread the TTL override and stay evictable by prefix", async () => { vi.useFakeTimers(); const cache = new ServerCache({ maxEntries: 4, ttlMs: 60_000 }); - await cached(cache, "u1|dashboard-snapshot", async () => "snap", undefined, 180_000); + await cached( + cache, + "u1|dashboard-snapshot", + async () => "snap", + undefined, + 180_000, + ); // Default bucket TTL elapsed; the override keeps the entry warm so // the 120 s client refetch interval lands on a hit, not a miss. vi.advanceTimersByTime(120_000); @@ -308,6 +314,51 @@ describe("ServerCache stale-while-revalidate (wrapSwr / cachedSwr)", () => { expect(builder).toHaveBeenCalledTimes(2); }); + it("logs a refresh_failed signal when the detached background rebuild rejects", async () => { + vi.useFakeTimers(); + const consoleError = vi + .spyOn(console, "error") + .mockImplementation(() => {}); + const cache = new ServerCache({ + name: "analytics", + maxEntries: 4, + ttlMs: 1000, + staleTtlMs: 600_000, + }); + let calls = 0; + const builder = vi.fn(async () => { + calls += 1; + if (calls === 1) return "v1"; + throw new Error("db gone"); + }); + await cache.wrapSwr("k", builder); + vi.advanceTimersByTime(1500); + + // Stale serve succeeds for the caller… + const stale = await cache.wrapSwr("k", builder); + expect(stale.outcome).toBe("stale"); + expect(stale.value).toBe("v1"); + await vi.runAllTimersAsync(); + + // …but the failed background rebuild is surfaced, not swallowed: + // bucket-scoped event name + hashed key + the error message. + expect(consoleError).toHaveBeenCalledTimes(1); + const [event, payload] = consoleError.mock.calls[0] as [string, string]; + expect(event).toBe("cache.analytics.refresh_failed"); + const parsed = JSON.parse(payload) as { + key_hash: number; + error: string; + }; + expect(parsed.key_hash).toBe(hashCacheKey("k")); + expect(parsed.error).toBe("db gone"); + + // The pending slot is clear — the next stale read retries the build. + const retry = await cache.wrapSwr("k", builder); + expect(retry.outcome).toBe("stale"); + expect(builder).toHaveBeenCalledTimes(3); + consoleError.mockRestore(); + }); + it("treats an entry past the stale window as a hard miss", async () => { vi.useFakeTimers(); const cache = new ServerCache({ diff --git a/src/lib/cache/invalidate.ts b/src/lib/cache/invalidate.ts index 2e3e64640..c099b2e09 100644 --- a/src/lib/cache/invalidate.ts +++ b/src/lib/cache/invalidate.ts @@ -138,13 +138,14 @@ export function invalidateUserMood(userId: string): void { * v1.16.8 — `evict` splits the SWR buckets (medications, the * per-medication compliance payload, analytics) by write origin, the * `invalidateUserMeasurements` pattern. Interactive writes (a card take / - * skip, a CRUD edit, an intake edit / delete / purge / import) pass + * skip, a CRUD edit, an intake edit / delete / purge / import, AND the + * iOS bulk-intake endpoint — it carries the phone user's own doses) pass * `{ evict: true }`: the user must see their own action on the very next * read, and a stale-serveable entry would hand back the pre-write body. - * Background paths (the iOS bulk-intake sync, the auto-miss cron, slot - * dedup) keep the default mark-stale so a high-frequency sync never busts - * the buckets into inline cold-rebuild storms — the `cachedSwr` readers - * serve the prior payload while one coalesced recompute warms each cell. + * Background paths (the auto-miss cron, slot dedup) keep the default + * mark-stale so a high-frequency pass never busts the buckets into + * inline cold-rebuild storms — the `cachedSwr` readers serve the prior + * payload while one coalesced recompute warms each cell. */ export function invalidateUserMedications( userId: string, @@ -179,6 +180,32 @@ export function invalidateUserMedications( caches.insightsTargets.deleteByPrefix(userId); } +/** + * v1.16.8 — invalidate every cache whose payload derives from the user's + * PROFILE-level settings rather than from measurement / mood / medication + * rows. The measurement-write invalidators never fire on a settings save, + * so before this helper a height / birth-date / gender edit, a threshold + * override, or a source-priority reorder kept serving the pre-edit + * targets grid (BMI band, BP-by-age range, glucose effective ranges), + * derived scores (baseline profile: age / sex / height), and analytics + * snapshot until the TTL lapsed. + * + * Hard-evict on every bucket: these are interactive Settings writes, and + * a marked-stale SWR cell would hand the user back the pre-edit payload + * on the very next read. + * + * Callers: `/api/user/thresholds` PUT + DELETE, `applyProfileUpdate` + * (only when heightCm / dateOfBirth / gender is in the update set), and + * `/api/auth/me/source-priority` PUT. The unit-preference toggle is NOT + * wired in: it only drives the client-side display transform, no + * server-cached payload reads it. + */ +export function invalidateUserProfile(userId: string): void { + caches.insightsTargets.deleteByPrefix(userId); + caches.insightsDerived.deleteByPrefix(`${userId}|`); + caches.analytics.deleteByPrefix(`${userId}|`); +} + /** * Invalidate the dashboard-widget layout cache for a single user. * diff --git a/src/lib/cache/server-cache.ts b/src/lib/cache/server-cache.ts index bc65cf133..3afdd06fa 100644 --- a/src/lib/cache/server-cache.ts +++ b/src/lib/cache/server-cache.ts @@ -162,12 +162,20 @@ export class ServerCache { /** * v1.12.1 — mark every entry under `prefix` stale without dropping it. * Collapses the fresh TTL to "now" so the next `wrapSwr` read serves - * the prior value immediately (within the stale window) while a single - * coalesced recompute warms a fresh value. This replaces the - * hard-evict on a mood write so an active logger no longer re-pays the - * cold compute on every entry. No-op for buckets without `staleTtlMs` - * (the entry is already at/over its hard TTL after this, so the next - * read is a clean miss — identical to the old evict behaviour). + * the prior value immediately while a single coalesced recompute warms + * a fresh value. This replaces the hard-evict on a mood write so an + * active logger no longer re-pays the cold compute on every entry. + * + * Stale-serve bound: marking does NOT touch `staleUntil`, which was + * fixed at insert time as `insert + ttlMs + staleTtlMs`. A marked + * entry can therefore keep serving stale until up to `ttlMs + + * staleTtlMs` after it was INSERTED — not `staleTtlMs` after the + * mark. (Marking near the end of the fresh window leaves close to + * `staleTtlMs` of serveability; marking right after insert leaves + * nearly the full `ttlMs + staleTtlMs`.) No-op for buckets without + * `staleTtlMs` (the entry is already at/over its hard TTL after this, + * so the next read is a clean miss — identical to the old evict + * behaviour). */ markStaleByPrefix(prefix: string): number { let marked = 0; @@ -295,9 +303,24 @@ export class ServerCache { }); this.pending.set(key, refresh); // Detach: the foreground caller does not await the rebuild. - // Swallow the rejection on the detached handle so an - // unhandled-rejection warning never fires. - refresh.catch(() => {}); + // Handle the rejection on the detached handle so an + // unhandled-rejection warning never fires — but never silently: + // a refresh that keeps failing means the bucket serves an + // ever-older stale value until the SWR window finally lapses, + // so the failure must reach the logs. There is no request + // context here (the caller already returned), so a structured + // console.error stands in for the wide-event annotation; the + // key is logged as its djb2 hash, matching the cache-outcome + // meta convention, so the userId segment never reaches stdout. + refresh.catch((err) => { + console.error( + `cache.${this.opts.name ?? "unnamed"}.refresh_failed`, + JSON.stringify({ + key_hash: hashCacheKey(key), + error: err instanceof Error ? err.message : String(err), + }), + ); + }); } return { value: cachedEntry.value, outcome: "stale" }; } @@ -422,10 +445,12 @@ export const caches = { * detail read and the batched card read) consume via `cachedSwr`: * inside the 10-minute stale window an expired cell serves the prior * payload immediately while one coalesced rebuild warms it. Interactive - * intake / medication writes hard-evict (the user must see their own - * dose on the next read); background sync paths (iOS bulk intake, the - * auto-miss cron, slot dedup) mark stale so a high-frequency sync never - * busts every card into an inline cold rebuild. + * intake / medication writes — including the iOS bulk-intake endpoint, + * which carries the phone user's own taken/skipped doses — hard-evict + * (the user must see their own dose on the next read); the genuinely + * background writers (the auto-miss cron, slot dedup) mark stale so a + * high-frequency pass never busts every card into an inline cold + * rebuild. */ medicationCompliance: new ServerCache({ name: "medicationCompliance", diff --git a/src/lib/export.ts b/src/lib/export.ts index 26e7d85aa..171018915 100644 --- a/src/lib/export.ts +++ b/src/lib/export.ts @@ -38,6 +38,18 @@ export interface ExportableRecord { * Escapes RFC 4180 special characters: commas, double quotes, line * breaks (`\n` and `\r`). Values containing any of these are wrapped * in double quotes with embedded quotes doubled (`"` → `""`). + * + * Spreadsheet formula injection (CSV injection): free-TEXT cells whose + * value starts with `=`, `+`, `-`, `@`, TAB, or CR get a leading `'` + * (the OWASP single-quote neutralisation — Excel / LibreOffice / + * Google Sheets treat the cell as text and hide the quote). The guard + * is scoped to STRING-typed record values only: numeric columns + * (measurement values, mood scores) stay typed `number` all the way to + * this function, so a negative reading exports as `-5.2`, never + * `'-5.2`. The text columns are where third-party content can land — + * mood `note` originates from the moodLog webhook, measurement `notes` + * from arbitrary clients — and a crafted `=HYPERLINK(...)` / + * `=cmd|...` there must not execute when an operator opens the export. */ export function toCSV( records: ExportableRecord[], @@ -47,7 +59,11 @@ export function toCSV( const headers = Object.keys(records[0]); const headerLine = headerLabels - ? headers.map((h) => escapeCsvCell(headerLabels[h] ?? h)).join(",") + ? headers + .map((h) => + escapeCsvCell(neutraliseFormulaPrefix(headerLabels[h] ?? h)), + ) + .join(",") : headers.map(escapeCsvCell).join(","); const lines = [headerLine]; @@ -55,8 +71,13 @@ export function toCSV( const values = headers.map((h) => { const val = record[h]; if (val === null || val === undefined) return ""; - const str = val instanceof Date ? val.toISOString() : String(val); - return escapeCsvCell(str); + if (val instanceof Date) return escapeCsvCell(val.toISOString()); + // Only genuine TEXT cells get the formula-prefix guard; numbers / + // booleans stringify verbatim so numeric columns are never mangled. + const str = String(val); + return escapeCsvCell( + typeof val === "string" ? neutraliseFormulaPrefix(str) : str, + ); }); lines.push(values.join(",")); } @@ -64,6 +85,17 @@ export function toCSV( return lines.join("\n"); } +/** + * OWASP CSV-injection neutralisation: prefix a text cell that starts + * with a formula trigger (`=`, `+`, `-`, `@`) or a control character + * Excel interprets as a field continuation (TAB, CR) with a single + * quote so the spreadsheet renders it as literal text. Applied to + * string cells only — see `toCSV`. + */ +function neutraliseFormulaPrefix(str: string): string { + return /^[=+\-@\t\r]/.test(str) ? `'${str}` : str; +} + /** * RFC 4180 cell escaping. `\r` is treated the same as `\n` so a * Windows-newline value (`\r\n`) inside a single field doesn't break diff --git a/src/lib/insights/__tests__/blood-pressure-status.test.ts b/src/lib/insights/__tests__/blood-pressure-status.test.ts index 48c831044..1c010abbb 100644 --- a/src/lib/insights/__tests__/blood-pressure-status.test.ts +++ b/src/lib/insights/__tests__/blood-pressure-status.test.ts @@ -14,6 +14,8 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), + // Consent never blocks in these fixtures — the gate has its own tests. + statusConsentBlocksGeneration: vi.fn(async () => false), })); vi.mock("@/lib/insights/memory", () => ({ diff --git a/src/lib/insights/__tests__/bmi-status.test.ts b/src/lib/insights/__tests__/bmi-status.test.ts index 902d18b4c..251f7b56f 100644 --- a/src/lib/insights/__tests__/bmi-status.test.ts +++ b/src/lib/insights/__tests__/bmi-status.test.ts @@ -11,6 +11,8 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), + // Consent never blocks in these fixtures — the gate has its own tests. + statusConsentBlocksGeneration: vi.fn(async () => false), })); vi.mock("@/lib/insights/memory", () => ({ diff --git a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts index 21277ec13..114a50316 100644 --- a/src/lib/insights/__tests__/comprehensive-generate-force.test.ts +++ b/src/lib/insights/__tests__/comprehensive-generate-force.test.ts @@ -70,12 +70,16 @@ import { compactSections } from "@/lib/ai/prompts/compact-sections"; const FEATURES = { weight: { count: 12, latest: 81.4, mean30: 82.1 }, }; -// The fingerprint covers the compacted features AND the about-me text -// (null here — the about-me module is mocked to no text), matching the +// The fingerprint covers the compacted features, the about-me text +// (null here — the about-me module is mocked to no text), the +// comparison-baseline setting ("none" — the mocked user row carries no +// dashboardWidgetsJson) and the generation locale, matching the // composite shape both the gate and the POST route hash. const FEATURES_HASH = hashInsightSnapshot({ features: compactSections(FEATURES as unknown as Record), aboutMe: null, + comparisonBaseline: "none", + generationLocale: "de", }); beforeEach(() => { diff --git a/src/lib/insights/__tests__/general-status.test.ts b/src/lib/insights/__tests__/general-status.test.ts index 26500875c..6d004dfbe 100644 --- a/src/lib/insights/__tests__/general-status.test.ts +++ b/src/lib/insights/__tests__/general-status.test.ts @@ -12,6 +12,8 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), + // Consent never blocks in these fixtures — the gate has its own tests. + statusConsentBlocksGeneration: vi.fn(async () => false), })); vi.mock("@/lib/insights/memory", () => ({ diff --git a/src/lib/insights/__tests__/medication-compliance-status.test.ts b/src/lib/insights/__tests__/medication-compliance-status.test.ts index 9e73cc253..61aea6cd8 100644 --- a/src/lib/insights/__tests__/medication-compliance-status.test.ts +++ b/src/lib/insights/__tests__/medication-compliance-status.test.ts @@ -10,6 +10,8 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), + // Consent never blocks in these fixtures — the gate has its own tests. + statusConsentBlocksGeneration: vi.fn(async () => false), })); vi.mock("@/lib/insights/memory", () => ({ @@ -74,7 +76,12 @@ describe("generateMedicationComplianceStatusForUser — graded payload", () => { active: true, createdAt: new Date(now.getTime() - 1100 * dayMs), schedules: [ - { id: "s1", windowStart: "08:00", windowEnd: "09:00", daysOfWeek: null }, + { + id: "s1", + windowStart: "08:00", + windowEnd: "09:00", + daysOfWeek: null, + }, ], }; @@ -197,7 +204,9 @@ describe("generateMedicationComplianceStatusForUser — cache-read skips a stub" createdAt: now, } as never); - stubCompletion('{"summary":"Fresh compliance assessment.","medications":[]}'); + stubCompletion( + '{"summary":"Fresh compliance assessment.","medications":[]}', + ); const result = await generateMedicationComplianceStatusForUser("user-1", { locale: "en", diff --git a/src/lib/insights/__tests__/metric-status.test.ts b/src/lib/insights/__tests__/metric-status.test.ts index 1f418837e..122f7478a 100644 --- a/src/lib/insights/__tests__/metric-status.test.ts +++ b/src/lib/insights/__tests__/metric-status.test.ts @@ -11,6 +11,8 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), + // Consent never blocks in these fixtures — the gate has its own tests. + statusConsentBlocksGeneration: vi.fn(async () => false), })); vi.mock("@/lib/insights/memory", () => ({ @@ -23,9 +25,10 @@ vi.mock("@/lib/insights/metric-correlation-context", () => ({ })); vi.mock("@/lib/tz/resolver", async () => { - const actual = await vi.importActual( - "@/lib/tz/resolver", - ); + const actual = + await vi.importActual( + "@/lib/tz/resolver", + ); return { ...actual, resolveUserTimezone: vi.fn() }; }); @@ -103,9 +106,24 @@ describe("generateMetricStatus — SLEEP_DURATION night reconstruction (iOS E2)" const wake = new Date("2026-06-04T06:30:00.000Z"); const m = 60 * 1000; const stageRows = [ - { value: 300, measuredAt: new Date(wake.getTime() - 90 * m), sleepStage: "CORE", source: "APPLE_HEALTH" }, - { value: 90, measuredAt: new Date(wake.getTime() - 30 * m), sleepStage: "DEEP", source: "APPLE_HEALTH" }, - { value: 75, measuredAt: wake, sleepStage: "REM", source: "APPLE_HEALTH" }, + { + value: 300, + measuredAt: new Date(wake.getTime() - 90 * m), + sleepStage: "CORE", + source: "APPLE_HEALTH", + }, + { + value: 90, + measuredAt: new Date(wake.getTime() - 30 * m), + sleepStage: "DEEP", + source: "APPLE_HEALTH", + }, + { + value: 75, + measuredAt: wake, + sleepStage: "REM", + source: "APPLE_HEALTH", + }, ]; vi.mocked(prisma.measurement.count).mockResolvedValue( stageRows.length as never, @@ -156,12 +174,42 @@ describe("generateMetricStatus — SLEEP_DURATION night reconstruction (iOS E2)" const wake = new Date(Date.now() - 6 * 60 * 60 * 1000); const m = 60 * 1000; const stageRows = [ - { value: 480, measuredAt: wake, sleepStage: "ASLEEP", source: "APPLE_HEALTH" }, // bare aggregate - { value: 240, measuredAt: new Date(wake.getTime() - 240 * m), sleepStage: "CORE", source: "APPLE_HEALTH" }, - { value: 120, measuredAt: new Date(wake.getTime() - 120 * m), sleepStage: "DEEP", source: "APPLE_HEALTH" }, - { value: 120, measuredAt: wake, sleepStage: "REM", source: "APPLE_HEALTH" }, - { value: 470, measuredAt: wake, sleepStage: "IN_BED", source: "APPLE_HEALTH" }, - { value: 20, measuredAt: new Date(wake.getTime() - 200 * m), sleepStage: "AWAKE", source: "APPLE_HEALTH" }, + { + value: 480, + measuredAt: wake, + sleepStage: "ASLEEP", + source: "APPLE_HEALTH", + }, // bare aggregate + { + value: 240, + measuredAt: new Date(wake.getTime() - 240 * m), + sleepStage: "CORE", + source: "APPLE_HEALTH", + }, + { + value: 120, + measuredAt: new Date(wake.getTime() - 120 * m), + sleepStage: "DEEP", + source: "APPLE_HEALTH", + }, + { + value: 120, + measuredAt: wake, + sleepStage: "REM", + source: "APPLE_HEALTH", + }, + { + value: 470, + measuredAt: wake, + sleepStage: "IN_BED", + source: "APPLE_HEALTH", + }, + { + value: 20, + measuredAt: new Date(wake.getTime() - 200 * m), + sleepStage: "AWAKE", + source: "APPLE_HEALTH", + }, ]; vi.mocked(prisma.measurement.count).mockResolvedValue( stageRows.length as never, @@ -254,9 +302,9 @@ describe("metric-status registry", () => { }); it("anchors a six-minute-walk normal range from the population reference", () => { - expect(getMetricStatusMeta("SIX_MINUTE_WALK_DISTANCE")?.normalRange).toEqual( - { low: 400, high: 700 }, - ); + expect( + getMetricStatusMeta("SIX_MINUTE_WALK_DISTANCE")?.normalRange, + ).toEqual({ low: 400, high: 700 }); }); }); @@ -273,9 +321,9 @@ describe("archetype prompt templates", () => { const meta = getMetricStatusMeta("SLEEP_DURATION")!; const sys = getMetricArchetypeSystemPrompt(meta, "en"); expect(sys).toContain("SLEEP"); - expect(getMetricArchetypeUserPrompt(meta, "{}", "2026-06-02", "en")).toContain( - "sleep duration", - ); + expect( + getMetricArchetypeUserPrompt(meta, "{}", "2026-06-02", "en"), + ).toContain("sleep duration"); }); }); @@ -325,7 +373,10 @@ describe("generateMetricStatus — generation path", () => { systemPrompt: string | null; userPrompt: string | null; }; - stubCompletion('{"summary":"Your resting heart rate is steady."}', captured); + stubCompletion( + '{"summary":"Your resting heart rate is steady."}', + captured, + ); const result = await generateMetricStatus({ metric: "RESTING_HEART_RATE", diff --git a/src/lib/insights/__tests__/mood-status.test.ts b/src/lib/insights/__tests__/mood-status.test.ts index 164ed9cde..91dc4e8b0 100644 --- a/src/lib/insights/__tests__/mood-status.test.ts +++ b/src/lib/insights/__tests__/mood-status.test.ts @@ -11,6 +11,8 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), + // Consent never blocks in these fixtures — the gate has its own tests. + statusConsentBlocksGeneration: vi.fn(async () => false), })); vi.mock("@/lib/insights/memory", () => ({ @@ -108,7 +110,12 @@ describe("generateMoodStatusForUser — timeout/error never persists", () => { const t = new Date(); vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ - { date: t.toISOString().slice(0, 10), score: 4, tags: [], moodLoggedAt: t }, + { + date: t.toISOString().slice(0, 10), + score: 4, + tags: [], + moodLoggedAt: t, + }, ] as never); vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({ @@ -157,7 +164,12 @@ describe("generateMoodStatusForUser — cache-read skips a stub", () => { }), } as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ - { date: now.toISOString().slice(0, 10), score: 4, tags: [], moodLoggedAt: now }, + { + date: now.toISOString().slice(0, 10), + score: 4, + tags: [], + moodLoggedAt: now, + }, ] as never); vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({ @@ -179,7 +191,12 @@ describe("generateMoodStatusForUser — token-leak hardening (v1.4.27 F16)", () const t = new Date(); vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([ - { date: t.toISOString().slice(0, 10), score: 4, tags: [], moodLoggedAt: t }, + { + date: t.toISOString().slice(0, 10), + score: 4, + tags: [], + moodLoggedAt: t, + }, ] as never); vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); vi.mocked(prisma.auditLog.create).mockResolvedValue({ diff --git a/src/lib/insights/__tests__/payload-budget.test.ts b/src/lib/insights/__tests__/payload-budget.test.ts index 7557eb04c..4a10c5acd 100644 --- a/src/lib/insights/__tests__/payload-budget.test.ts +++ b/src/lib/insights/__tests__/payload-budget.test.ts @@ -25,6 +25,8 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), + // Consent never blocks in these fixtures — the gate has its own tests. + statusConsentBlocksGeneration: vi.fn(async () => false), })); vi.mock("@/lib/insights/memory", () => ({ diff --git a/src/lib/insights/__tests__/pulse-status-timeout.test.ts b/src/lib/insights/__tests__/pulse-status-timeout.test.ts index b7336afda..18ce10a10 100644 --- a/src/lib/insights/__tests__/pulse-status-timeout.test.ts +++ b/src/lib/insights/__tests__/pulse-status-timeout.test.ts @@ -27,6 +27,8 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), + // Consent never blocks in these fixtures — the gate has its own tests. + statusConsentBlocksGeneration: vi.fn(async () => false), })); vi.mock("@/lib/insights/memory", () => ({ diff --git a/src/lib/insights/__tests__/pulse-status.test.ts b/src/lib/insights/__tests__/pulse-status.test.ts index a3d77a9e9..64c64b01c 100644 --- a/src/lib/insights/__tests__/pulse-status.test.ts +++ b/src/lib/insights/__tests__/pulse-status.test.ts @@ -12,6 +12,8 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), + // Consent never blocks in these fixtures — the gate has its own tests. + statusConsentBlocksGeneration: vi.fn(async () => false), })); vi.mock("@/lib/insights/memory", () => ({ diff --git a/src/lib/insights/__tests__/snapshot-hash.test.ts b/src/lib/insights/__tests__/snapshot-hash.test.ts index c335855a4..81f983253 100644 --- a/src/lib/insights/__tests__/snapshot-hash.test.ts +++ b/src/lib/insights/__tests__/snapshot-hash.test.ts @@ -52,6 +52,35 @@ describe("hashInsightSnapshot", () => { expect(de).toBe(en); }); + it("does NOT ignore generationLocale or comparisonBaseline (the comprehensive composite)", () => { + // The comprehensive fingerprint carries the generation language and + // the comparison toggle under non-volatile names so a reader-facing + // change regenerates the briefing exactly once. `locale` itself + // stays volatile (per-locale-keyed status caches rely on that). + const base = hashInsightSnapshot({ + features: { weight: { mean: 80 } }, + aboutMe: null, + comparisonBaseline: "none", + generationLocale: "de", + }); + expect( + hashInsightSnapshot({ + features: { weight: { mean: 80 } }, + aboutMe: null, + comparisonBaseline: "none", + generationLocale: "en", + }), + ).not.toBe(base); + expect( + hashInsightSnapshot({ + features: { weight: { mean: 80 } }, + aboutMe: null, + comparisonBaseline: "lastMonth", + generationLocale: "de", + }), + ).not.toBe(base); + }); + it("ignores positional offsets (dayOffset) but not the values", () => { const yesterday = hashInsightSnapshot({ dataCoverage: { totalMeasurements: 12, newestMeasurementDaysAgo: 0 }, @@ -149,8 +178,12 @@ describe("hashInsightSnapshot", () => { }); it("keeps absolute day keys in the fingerprint", () => { - const a = hashInsightSnapshot({ recent: [{ date: "2026-06-09", mean: 80 }] }); - const b = hashInsightSnapshot({ recent: [{ date: "2026-06-10", mean: 80 }] }); + const a = hashInsightSnapshot({ + recent: [{ date: "2026-06-09", mean: 80 }], + }); + const b = hashInsightSnapshot({ + recent: [{ date: "2026-06-10", mean: 80 }], + }); expect(a).not.toBe(b); }); diff --git a/src/lib/insights/__tests__/status-cache.test.ts b/src/lib/insights/__tests__/status-cache.test.ts index 744937aad..b87c734c0 100644 --- a/src/lib/insights/__tests__/status-cache.test.ts +++ b/src/lib/insights/__tests__/status-cache.test.ts @@ -7,8 +7,11 @@ vi.mock("@/lib/db", () => ({ })); const hasUsableStatusProvider = vi.fn(); +const statusConsentBlocksGeneration = vi.fn(); vi.mock("@/lib/insights/status-provider", () => ({ hasUsableStatusProvider: (...a: unknown[]) => hasUsableStatusProvider(...a), + statusConsentBlocksGeneration: (...a: unknown[]) => + statusConsentBlocksGeneration(...a), })); const enqueueStatusGeneration = vi.fn(); @@ -184,7 +187,11 @@ describe("resolveReadOnlyStatusMiss", () => { // A prior (e.g. yesterday's) real assessment is on record. vi.mocked(prisma.auditLog.findMany).mockResolvedValue([ cacheRow( - { dateKey: "2026-05-30", text: "Steady upward trend.", model: "gpt-4o-mini" }, + { + dateKey: "2026-05-30", + text: "Steady upward trend.", + model: "gpt-4o-mini", + }, new Date("2026-05-30T04:30:00.000Z"), ), ] as never); @@ -248,6 +255,30 @@ describe("resolveReadOnlyStatusMiss", () => { describe("refreshUnchangedStatusInsight (v1.16.8)", () => { const HASH = "a".repeat(64); + beforeEach(() => { + // Default: consent does not block (BYOK / consented chains). + statusConsentBlocksGeneration.mockResolvedValue(false); + }); + + it("misses (and writes nothing) when the server-managed consent is revoked, even on a hash match", async () => { + statusConsentBlocksGeneration.mockResolvedValue(true); + const hit = await refreshUnchangedStatusInsight({ + userId: "u1", + cacheAction: "insights.weight-status.en", + todayKey: TODAY, + snapshotHash: HASH, + }); + expect(hit).toBeNull(); + // The gate must not even read the cache row — a revoked consent can + // never re-stamp old AI text as today's assessment. + expect(prisma.auditLog.findFirst).not.toHaveBeenCalled(); + expect(prisma.auditLog.create).not.toHaveBeenCalled(); + expect(statusConsentBlocksGeneration).toHaveBeenCalledWith( + "u1", + "insights", + ); + }); + it("re-persists the row under today's dateKey and returns the text on a hash match", async () => { vi.mocked(prisma.auditLog.findFirst).mockResolvedValue( cacheRow({ diff --git a/src/lib/insights/__tests__/weight-status.test.ts b/src/lib/insights/__tests__/weight-status.test.ts index dfa1a2c5c..1fea27ab2 100644 --- a/src/lib/insights/__tests__/weight-status.test.ts +++ b/src/lib/insights/__tests__/weight-status.test.ts @@ -15,6 +15,8 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/insights/status-provider", () => ({ runStatusCompletion: vi.fn(), + // Consent never blocks in these fixtures — the gate has its own tests. + statusConsentBlocksGeneration: vi.fn(async () => false), })); vi.mock("@/lib/insights/memory", () => ({ @@ -192,14 +194,21 @@ describe("generateWeightStatusForUser — content-hash gate (v1.16.8)", () => { try { const now = new Date(); const records = [ - { type: "WEIGHT", value: 82, measuredAt: new Date(now.getTime() - dayMs) }, - { type: "WEIGHT", value: 81.6, measuredAt: new Date(now.getTime() - 2 * dayMs) }, + { + type: "WEIGHT", + value: 82, + measuredAt: new Date(now.getTime() - dayMs), + }, + { + type: "WEIGHT", + value: 81.6, + measuredAt: new Date(now.getTime() - 2 * dayMs), + }, ]; // Fresh copy per call — the generator reverses the result array in // place, and a shared fixture would flip order between the two runs. - vi.mocked(prisma.measurement.findMany).mockImplementation( - (async () => records.map((r) => ({ ...r }))) as never, - ); + vi.mocked(prisma.measurement.findMany).mockImplementation((async () => + records.map((r) => ({ ...r }))) as never); vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never); vi.mocked(prisma.auditLog.findFirst).mockResolvedValue(null); vi.mocked(prisma.auditLog.create).mockResolvedValue({ diff --git a/src/lib/insights/comprehensive-generate.ts b/src/lib/insights/comprehensive-generate.ts index 0bb38a4bc..139319d65 100644 --- a/src/lib/insights/comprehensive-generate.ts +++ b/src/lib/insights/comprehensive-generate.ts @@ -57,20 +57,17 @@ import { AllProvidersFailedError, runRawCompletionWithFallback, } from "@/lib/ai/provider-runner"; -import { chainRequiresServerManagedConsent, hasActiveConsentForSurface } from "@/lib/ai/consent-guard"; +import { + chainRequiresServerManagedConsent, + hasActiveConsentForSurface, +} from "@/lib/ai/consent-guard"; import { invalidateUserInsights } from "@/lib/cache/invalidate"; import { enqueueStatusGeneration, type InsightStatusScope, } from "@/lib/jobs/insight-status-generate-shared"; -import { - normalizeLocale, - stripJsonFences, -} from "@/lib/insights/status-shared"; -import { - isTimeoutStub, - statusCacheAction, -} from "@/lib/insights/status-cache"; +import { normalizeLocale, stripJsonFences } from "@/lib/insights/status-shared"; +import { isTimeoutStub, statusCacheAction } from "@/lib/insights/status-cache"; import { hashInsightSnapshot } from "@/lib/insights/snapshot-hash"; import { metricIdForMeasurementType, @@ -273,7 +270,9 @@ export type PerStatusScope = (typeof PER_STATUS_SCOPES)[number]; * type touches it. BMI rides on WEIGHT (it is weight ÷ height²), so a * new weight reading invalidates both the weight and the BMI card. */ -function statusScopesForMeasurementType(type: MeasurementType): PerStatusScope[] { +function statusScopesForMeasurementType( + type: MeasurementType, +): PerStatusScope[] { switch (type) { case "WEIGHT": return ["weight", "bmi", "general"]; @@ -532,6 +531,10 @@ export async function generateComprehensiveInsight( insightsCachedText: true, insightsExcludeMetrics: true, insightsSnapshotHash: true, + // The comparison-baseline setting joins the snapshot fingerprint + // (see the hash construction below), so it must be readable BEFORE + // the gate — the comparison snapshot itself is only built after. + dashboardWidgetsJson: true, }, }); @@ -611,13 +614,33 @@ export async function generateComprehensiveInsight( // it is the one prompt input that can change with NO data change (the // Coach remember action, a Settings → AI edit), and excluding it would // pin the briefing to the pre-edit self-context until a measurement - // happens to move. The comparison + plateau blocks stay out — both are - // derived from the same measurement rows the feature snapshot already - // fingerprints. Fetched once here and reused for the prompt below. + // happens to move. The plateau block stays out — it derives from the + // same measurement rows the feature snapshot already fingerprints. + // Fetched once here and reused for the prompt below. + // + // Two more prompt inputs that can flip with no data change join the + // composite (the shape MUST stay identical to the POST route's hash + // write in `generate/route.ts`, or every nightly force-regenerates): + // - `comparisonBaseline`: the user's comparison toggle. Switching it + // adds/removes the prior-period context block, so the cached text + // no longer matches what the prompt would produce. + // - `generationLocale`: the locale the briefing renders in. Hashing + // it means a locale switch regenerates ONCE — intended, the + // briefing language must follow the reader; afterwards the new + // locale's hash is the stored baseline and the gate holds again. + // The key is named `generationLocale` (not `locale`) on purpose: + // the canonicaliser in `snapshot-hash.ts` strips `locale` keys as + // volatile, which is right for the per-locale-keyed status caches + // but would silently drop the field from THIS composite. const aboutMe = await getSelfContextTextForUser(userId, locale); + const comparisonBaseline: ComparisonBaseline = + resolveDashboardLayout(dbUser?.dashboardWidgetsJson).comparisonBaseline ?? + "none"; const snapshotHash = hashInsightSnapshot({ features: compactFeatures, aboutMe: aboutMe ?? null, + comparisonBaseline, + generationLocale: locale, }); if ( dbUser?.insightsCachedText && diff --git a/src/lib/insights/status-cache.ts b/src/lib/insights/status-cache.ts index 960bd8a4d..fddb4c8fe 100644 --- a/src/lib/insights/status-cache.ts +++ b/src/lib/insights/status-cache.ts @@ -1,5 +1,8 @@ import { prisma } from "@/lib/db"; -import { hasUsableStatusProvider } from "@/lib/insights/status-provider"; +import { + hasUsableStatusProvider, + statusConsentBlocksGeneration, +} from "@/lib/insights/status-provider"; import { enqueueStatusGeneration, type InsightStatusScope, @@ -142,6 +145,14 @@ export async function readFreshStatusText(args: { * the ingest-driven regeneration into no-ops on unchanged data: each of * those paths forces past the same-day cache read, gathers the * snapshot, and lands here. + * + * Consent comes BEFORE the hash compare (mirroring the comprehensive + * pipeline's consent-before-gate order): re-stamping a cached text + * under today's `dateKey` presents it as a current AI assessment, so a + * user who revoked the server-managed AI consent must not have old + * text re-dated by an unchanged-data refresh. On a blocked consent the + * gate misses, the generator proceeds to `runStatusCompletion`, and + * that gate returns the no-key fallback without persisting anything. */ export async function refreshUnchangedStatusInsight(args: { userId: string; @@ -149,6 +160,14 @@ export async function refreshUnchangedStatusInsight(args: { todayKey: string; snapshotHash: string; }): Promise { + if (await statusConsentBlocksGeneration(args.userId, "insights")) { + annotate({ + action: { name: "insights.status.consent_required" }, + meta: { cache_action: args.cacheAction, gate: "unchanged-refresh" }, + }); + return null; + } + const latest = await prisma.auditLog.findFirst({ where: { userId: args.userId, action: args.cacheAction }, orderBy: { createdAt: "desc" }, diff --git a/src/lib/insights/status-provider.ts b/src/lib/insights/status-provider.ts index 1c2d6ae1e..12a66484a 100644 --- a/src/lib/insights/status-provider.ts +++ b/src/lib/insights/status-provider.ts @@ -95,6 +95,29 @@ export async function hasUsableStatusProvider( return (await resolveStatusChain(userId)) !== null; } +/** + * v1.16.8 — true when a status generation for `userId` would be blocked + * by the server-managed consent gate (the same check `runStatusCompletion` + * applies before egress). The content-hash gate + * (`refreshUnchangedStatusInsight`) consults this BEFORE re-stamping a + * cached assessment as current: after a consent revocation the unchanged- + * data refresh must fall through to the generator, whose own gate then + * serves the no-key fallback instead of presenting old AI text as fresh. + * `false` when no provider is configured at all — that path already + * resolves to the fallback via `{ kind: "none" }`. + */ +export async function statusConsentBlocksGeneration( + userId: string, + surface: ConsentSurface, +): Promise { + const chain = await resolveStatusChain(userId); + if (chain === null) return false; + return ( + chainRequiresServerManagedConsent(chain) && + !(await hasActiveConsentForSurface(userId, surface)) + ); +} + /** * Run a status generation across the user's provider chain, bounded by * `STATUS_PROVIDER_TIMEOUT_MS`. The result discriminates between diff --git a/src/lib/jobs/reminder-worker.ts b/src/lib/jobs/reminder-worker.ts index ae562d293..0a9b34ed1 100644 --- a/src/lib/jobs/reminder-worker.ts +++ b/src/lib/jobs/reminder-worker.ts @@ -1146,13 +1146,20 @@ export async function startReminderWorker() { { localConcurrency: 1 }, handleMedicationInventoryExpire, ); - // v1.7.0 — nightly comprehensive-insight pre-generation. Single-flight - // so two ticks can't double-generate the same user; the per-user - // budget gate inside the handler also covers the race, but - // serialising here avoids wasted chain-resolves. + // v1.7.0 — nightly comprehensive-insight pre-generation. + // + // v1.16.8 — localConcurrency 2 (was 1). The queue carries BOTH the + // scheduled 04:30 cohort walk AND the visit-triggered per-user force + // warms; with a single slot a force warm enqueued during the nightly + // batch sat behind the entire cohort and the visiting user stared at + // a cold dashboard for the duration. Two slots let one force warm run + // alongside the batch while still bounding provider-level concurrency + // (the cohort walk is itself sequential per user, and the content-hash + // gate + per-user budget gate inside the handler cover the rare + // double-tick overlap the old single slot serialised away). await boss.work( INSIGHT_PREGENERATE_QUEUE, - { localConcurrency: 1 }, + { localConcurrency: 2 }, handleInsightPregenerateJob, ); // v1.10.0 — computed scores (WX-C). Nightly Recovery-score compute + diff --git a/src/lib/openapi/routes/insights.ts b/src/lib/openapi/routes/insights.ts index 85925c0af..83d68356d 100644 --- a/src/lib/openapi/routes/insights.ts +++ b/src/lib/openapi/routes/insights.ts @@ -252,7 +252,7 @@ const derivedBatchQuery = z .min(1) .max(1024) .describe( - "Comma-separated derived-metric tokens. Each is a `` or `:` (the colon sub-targets a VITALS_BASELINE vital). An unknown id 422s; at most 24 tokens; duplicates collapse.", + "Comma-separated derived-metric tokens. Each is a `` or `:` (the colon sub-targets a VITALS_BASELINE vital). An unknown id 422s; a `type` outside the MeasurementType enum 422s; at most 24 tokens; duplicates collapse.", ), }) .meta({ id: "DerivedBatchQuery" }); diff --git a/src/lib/tz/__tests__/intl-cache.test.ts b/src/lib/tz/__tests__/intl-cache.test.ts index feb8768b2..e36ea5405 100644 --- a/src/lib/tz/__tests__/intl-cache.test.ts +++ b/src/lib/tz/__tests__/intl-cache.test.ts @@ -128,7 +128,10 @@ describe("getDateTimeFormat", () => { describe("memoised consumers stay output-identical to the pre-memo helpers", () => { it("wallClockInTz decomposes DST-transition instants correctly", () => { // 2026-03-29 01:30 UTC = 03:30 CEST (the EU spring-forward morning). - const parts = wallClockInTz(new Date("2026-03-29T01:30:00Z"), "Europe/Berlin"); + const parts = wallClockInTz( + new Date("2026-03-29T01:30:00Z"), + "Europe/Berlin", + ); expect(parts).toEqual({ year: 2026, month: 3, @@ -183,3 +186,57 @@ describe("memoised consumers stay output-identical to the pre-memo helpers", () expect(isValidTimezone("")).toBe(false); }); }); + +describe("overflow eviction", () => { + it("evicts only the single oldest entry on overflow, never the whole map", () => { + // The memo caps at 1000 entries. Fill it to the cap with real + // signatures (IANA zones × option shapes), then overflow by one and + // assert the population stays at the cap with only the OLDEST + // signature gone — a full reset would re-pay construction for every + // hot signature after one burst of unusual zones. + const zones = ( + Intl as unknown as { supportedValuesOf: (k: string) => string[] } + ).supportedValuesOf("timeZone"); + const shapes: Array> = [ + { hour: "2-digit" }, + { minute: "2-digit" }, + { second: "2-digit" }, + ]; + + const CAP = 1000; + const inserted: Array<{ + zone: string; + opts: Omit; + formatter: Intl.DateTimeFormat; + }> = []; + outer: for (const opts of shapes) { + for (const zone of zones) { + inserted.push({ + zone, + opts, + formatter: getDateTimeFormat("en-US", zone, opts), + }); + if (inserted.length === CAP) break outer; + } + } + expect(__intlCacheSizeForTests()).toBe(CAP); + + // One more unique signature → exactly one eviction. + const overflowOpts: Omit = { + year: "numeric", + }; + getDateTimeFormat("en-US", "UTC", overflowOpts); + expect(__intlCacheSizeForTests()).toBe(CAP); + + // The SECOND-oldest entry survived (same memoised instance)… + const second = inserted[1]; + expect(getDateTimeFormat("en-US", second.zone, second.opts)).toBe( + second.formatter, + ); + // …while the oldest was evicted and re-constructs to a NEW instance. + const first = inserted[0]; + expect(getDateTimeFormat("en-US", first.zone, first.opts)).not.toBe( + first.formatter, + ); + }); +}); diff --git a/src/lib/tz/format.ts b/src/lib/tz/format.ts index f54d91e0a..b6a94cf54 100644 --- a/src/lib/tz/format.ts +++ b/src/lib/tz/format.ts @@ -21,25 +21,31 @@ export const DEFAULT_TIMEZONE = "Europe/Berlin"; * lookup hits the same object identity on every call — see * `./intl-cache`. `WALL_CLOCK_PARTS_OPTIONS` is shared by * `wallClockParts` AND `tzOffsetMinutes`, so both resolve to the SAME - * memoised formatter per timezone. + * memoised formatter per timezone. Frozen: the memo computes each + * constant's serialised signature ONCE per object identity, so a later + * mutation would leave the cached signature stale and silently hand + * callers a formatter built from the pre-mutation options. */ -const VALIDATION_OPTIONS: Omit = {}; +const VALIDATION_OPTIONS: Omit = + Object.freeze({}); -const HOUR_MINUTE_OPTIONS: Omit = { - hour: "2-digit", - minute: "2-digit", - hour12: false, -}; +const HOUR_MINUTE_OPTIONS: Omit = + Object.freeze({ + hour: "2-digit", + minute: "2-digit", + hour12: false, + }); -const WALL_CLOCK_PARTS_OPTIONS: Omit = { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, -}; +const WALL_CLOCK_PARTS_OPTIONS: Omit = + Object.freeze({ + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }); /** * Validate a timezone string by asking `Intl.DateTimeFormat` to use it. diff --git a/src/lib/tz/intl-cache.ts b/src/lib/tz/intl-cache.ts index d029a126b..420ffe7a1 100644 --- a/src/lib/tz/intl-cache.ts +++ b/src/lib/tz/intl-cache.ts @@ -21,7 +21,10 @@ const formatterCache = new Map(); * Hard cap. The realistic population is bounded (valid IANA zones × a * handful of option shapes), but the timezone segment is user-supplied * upstream of validation in `isValidTimezone`, so the memo carries a - * safety valve: on overflow the whole map resets and re-warms. + * safety valve: on overflow the single OLDEST entry is evicted (Map + * iteration order is insertion order, so the first key is the oldest). + * A full-map reset on every insert past the cap would let one burst of + * unusual zones re-pay formatter construction for every hot signature. */ const MAX_FORMATTERS = 1000; @@ -61,7 +64,10 @@ export function getDateTimeFormat( locale, timeZone === undefined ? options : { ...options, timeZone }, ); - if (formatterCache.size >= MAX_FORMATTERS) formatterCache.clear(); + if (formatterCache.size >= MAX_FORMATTERS) { + const oldest = formatterCache.keys().next().value; + if (oldest !== undefined) formatterCache.delete(oldest); + } formatterCache.set(key, formatter); return formatter; } diff --git a/src/lib/tz/wall-clock.ts b/src/lib/tz/wall-clock.ts index 11de1fd20..4dc5efcf5 100644 --- a/src/lib/tz/wall-clock.ts +++ b/src/lib/tz/wall-clock.ts @@ -48,18 +48,21 @@ const WEEKDAY_MAP: Record = { * Hoisted so the formatter memo's WeakMap signature lookup hits the same * object identity on every call — a warm `wallClockInTz` no longer pays * `Intl.DateTimeFormat` construction (the dominant cost of the band - * expansion + rollup paths that call this in a tight loop). + * expansion + rollup paths that call this in a tight loop). Frozen: the + * memo serialises the signature ONCE per object identity, so a later + * mutation would leave the cached signature stale. */ -const WALL_CLOCK_OPTIONS: Omit = { - year: "numeric", - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - second: "2-digit", - hour12: false, - weekday: "short", -}; +const WALL_CLOCK_OPTIONS: Omit = + Object.freeze({ + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + weekday: "short", + }); export function wallClockInTz( date: Date, @@ -76,9 +79,11 @@ export function wallClockInTz( weekday: date.getDay(), }; } - const parts = getDateTimeFormat("en-US", tz, WALL_CLOCK_OPTIONS).formatToParts( - date, - ); + const parts = getDateTimeFormat( + "en-US", + tz, + WALL_CLOCK_OPTIONS, + ).formatToParts(date); const get = (type: Intl.DateTimeFormatPartTypes): string => parts.find((p) => p.type === type)?.value ?? "0"; let hour = Number(get("hour"));