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.
This commit is contained in:
Marc-André Bombeck committed 2026-06-11 23:20:38 +02:00
1 parent 95f21bfa4d
commit f790e14edd
40 files changed
+1141 -287

No files matched your search

+2 -2
View File
@@ -4224,11 +4224,11 @@ paths:
maxLength: 1024
description: Comma-separated derived-metric tokens. Each is a `<DERIVED_METRIC_ID>` or
`<DERIVED_METRIC_ID>:<MeasurementType>` (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 `<DERIVED_METRIC_ID>` or
`<DERIVED_METRIC_ID>:<MeasurementType>` (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.
+11 -1
View File
@@ -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),
@@ -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<Response>;
@@ -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<string, { metric: string; status: string }> };
@@ -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<string, { status: string }> };
};
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 () => {
+32 -11
View File
@@ -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.
+12 -4
View File
@@ -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,
}),
},
});
+98 -78
View File
@@ -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.
@@ -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");
+8 -6
View File
@@ -569,13 +569,15 @@ async function postBulk(request: NextRequest): Promise<Response> {
// 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
+10
View File
@@ -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),
+34
View File
@@ -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", () => {
+135 -22
View File
@@ -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");
+105 -28
View File
@@ -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<Record<SleepStage, number>>;
}
@@ -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<string, SleepStageRow[][]>();
// 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<Record<SleepStage, number>> = {};
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<Record<SleepStage, number>> = {};
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),
+14
View File
@@ -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,
+55 -15
View File
@@ -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<void> {
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();
+52 -1
View File
@@ -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<string>({ 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<string>({
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<string>({
+32 -5
View File
@@ -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.
*
+38 -13
View File
@@ -162,12 +162,20 @@ export class ServerCache<T> {
/**
* 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<T> {
});
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<unknown>({
name: "medicationCompliance",
+35 -3
View File
@@ -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
@@ -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", () => ({
@@ -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", () => ({
@@ -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<string, unknown>),
aboutMe: null,
comparisonBaseline: "none",
generationLocale: "de",
});
beforeEach(() => {
@@ -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", () => ({
@@ -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",
@@ -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<typeof import("@/lib/tz/resolver")>(
"@/lib/tz/resolver",
);
const actual =
await vi.importActual<typeof import("@/lib/tz/resolver")>(
"@/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",
+20 -3
View File
@@ -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({
@@ -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", () => ({
@@ -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", () => ({
@@ -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", () => ({
@@ -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);
});
@@ -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({
@@ -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({
+36 -13
View File
@@ -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 &&
+20 -1
View File
@@ -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<FreshStatusCacheHit | null> {
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" },
+23
View File
@@ -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<boolean> {
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
+12 -5
View File
@@ -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<InsightPregeneratePayload>(
INSIGHT_PREGENERATE_QUEUE,
{ localConcurrency: 1 },
{ localConcurrency: 2 },
handleInsightPregenerateJob,
);
// v1.10.0 — computed scores (WX-C). Nightly Recovery-score compute +
+1 -1
View File
@@ -252,7 +252,7 @@ const derivedBatchQuery = z
.min(1)
.max(1024)
.describe(
"Comma-separated derived-metric tokens. Each is a `<DERIVED_METRIC_ID>` or `<DERIVED_METRIC_ID>:<MeasurementType>` (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 `<DERIVED_METRIC_ID>` or `<DERIVED_METRIC_ID>:<MeasurementType>` (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" });
+58 -1
View File
@@ -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<Omit<Intl.DateTimeFormatOptions, "timeZone">> = [
{ hour: "2-digit" },
{ minute: "2-digit" },
{ second: "2-digit" },
];
const CAP = 1000;
const inserted: Array<{
zone: string;
opts: Omit<Intl.DateTimeFormatOptions, "timeZone">;
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<Intl.DateTimeFormatOptions, "timeZone"> = {
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,
);
});
});
+22 -16
View File
@@ -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<Intl.DateTimeFormatOptions, "timeZone"> = {};
const VALIDATION_OPTIONS: Omit<Intl.DateTimeFormatOptions, "timeZone"> =
Object.freeze({});
const HOUR_MINUTE_OPTIONS: Omit<Intl.DateTimeFormatOptions, "timeZone"> = {
hour: "2-digit",
minute: "2-digit",
hour12: false,
};
const HOUR_MINUTE_OPTIONS: Omit<Intl.DateTimeFormatOptions, "timeZone"> =
Object.freeze({
hour: "2-digit",
minute: "2-digit",
hour12: false,
});
const WALL_CLOCK_PARTS_OPTIONS: Omit<Intl.DateTimeFormatOptions, "timeZone"> = {
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<Intl.DateTimeFormatOptions, "timeZone"> =
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.
+8 -2
View File
@@ -21,7 +21,10 @@ const formatterCache = new Map<string, Intl.DateTimeFormat>();
* 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;
}
+19 -14
View File
@@ -48,18 +48,21 @@ const WEEKDAY_MAP: Record<string, number> = {
* 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<Intl.DateTimeFormatOptions, "timeZone"> = {
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<Intl.DateTimeFormatOptions, "timeZone"> =
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"));