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

+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),