chore(release): v1.4.25

Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites).

See CHANGELOG.md for the full per-section delta.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-14 23:49:32 +02:00
1 parent de0689e419
commit 17b8d8d2c2
528 files changed
+104033 -6524

No files matched your search

@@ -434,4 +434,75 @@ describe("PUT /api/admin/settings", () => {
const res = await PUT(r);
expect(res.status).toBe(415);
});
// v1.4.25 W7 — server-default timezone.
it("persists a valid defaultUserTimezone", async () => {
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
id: "singleton",
registrationEnabled: true,
defaultLocale: "de",
telegramGlobal: true,
ntfyGlobal: true,
webPushGlobal: true,
webPushVapidPublicKey: null,
webPushVapidSubject: null,
webPushVapidPrivateKeyEncrypted: null,
apiGlobal: true,
umamiEnabled: false,
umamiScriptUrl: null,
umamiWebsiteId: null,
glitchtipEnabled: false,
glitchtipDsn: null,
glitchtipEnvironment: null,
githubIssueRepo: null,
githubIssueTokenEncrypted: null,
bugReportEnabled: true,
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
moodLogGlobal: true,
defaultUserTimezone: "Pacific/Auckland",
} as never);
const res = await PUT(jsonReq({ defaultUserTimezone: "Pacific/Auckland" }));
expect(res.status).toBe(200);
const args = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
expect(args?.update).toEqual({ defaultUserTimezone: "Pacific/Auckland" });
});
it("clears the server-default timezone when given empty string", async () => {
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
id: "singleton",
registrationEnabled: true,
defaultLocale: "de",
telegramGlobal: true,
ntfyGlobal: true,
webPushGlobal: true,
webPushVapidPublicKey: null,
webPushVapidSubject: null,
webPushVapidPrivateKeyEncrypted: null,
apiGlobal: true,
umamiEnabled: false,
umamiScriptUrl: null,
umamiWebsiteId: null,
glitchtipEnabled: false,
glitchtipDsn: null,
glitchtipEnvironment: null,
githubIssueRepo: null,
githubIssueTokenEncrypted: null,
bugReportEnabled: true,
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
moodLogGlobal: true,
defaultUserTimezone: null,
} as never);
const res = await PUT(jsonReq({ defaultUserTimezone: "" }));
expect(res.status).toBe(200);
const args = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
expect(args?.update).toEqual({ defaultUserTimezone: null });
});
it("returns 422 for an invalid IANA zone in defaultUserTimezone", async () => {
const res = await PUT(jsonReq({ defaultUserTimezone: "Mars/Olympus" }));
expect(res.status).toBe(422);
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
});
});
+33
View File
@@ -11,6 +11,10 @@ import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { adminSettingsSchema } from "@/lib/validations/admin";
import { NextRequest } from "next/server";
import {
invalidateServerDefaultTimezone,
isValidTimezone,
} from "@/lib/tz/resolver";
export const dynamic = "force-dynamic";
@@ -50,6 +54,10 @@ export const GET = apiHandler(async () => {
reminderLateMinutes: settings?.reminderLateMinutes ?? 120,
reminderMissedMinutes: settings?.reminderMissedMinutes ?? 240,
moodLogGlobal: settings?.moodLogGlobal ?? true,
// v1.4.25 W7 — null means "fall back to Europe/Berlin in the
// resolver"; surfacing the raw value lets the admin UI render
// an empty picker placeholder until they opt in.
defaultUserTimezone: settings?.defaultUserTimezone ?? null,
});
});
@@ -186,6 +194,26 @@ export const PUT = apiHandler(async (request: NextRequest) => {
auditDetails.reminderMissedMinutes = data.reminderMissedMinutes;
}
// v1.4.25 W7 — server-default timezone for new signups.
// Empty string clears the override (resolver falls back to
// Europe/Berlin); a non-empty string must pass Intl validation
// upstream of the column write.
let didTouchTimezone = false;
if (data.defaultUserTimezone !== undefined) {
const trimmed = data.defaultUserTimezone.trim();
if (trimmed === "") {
updates.defaultUserTimezone = null;
auditDetails.defaultUserTimezone = null;
didTouchTimezone = true;
} else if (isValidTimezone(trimmed)) {
updates.defaultUserTimezone = trimmed;
auditDetails.defaultUserTimezone = trimmed;
didTouchTimezone = true;
} else {
return apiError("Not a valid IANA timezone.", 422);
}
}
if (Object.keys(updates).length === 0) {
return apiError("No valid fields", 422);
}
@@ -196,6 +224,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
create: { id: "singleton", ...updates },
});
if (didTouchTimezone) {
invalidateServerDefaultTimezone();
}
await auditLog("admin.settings.update", {
userId: user.id,
ipAddress: getClientIp(request),
@@ -230,5 +262,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
reminderLateMinutes: settings.reminderLateMinutes,
reminderMissedMinutes: settings.reminderMissedMinutes,
moodLogGlobal: settings.moodLogGlobal,
defaultUserTimezone: settings.defaultUserTimezone,
});
});
@@ -44,9 +44,17 @@ vi.mock("@/lib/jobs/worker-status", () => ({
})),
}));
// v1.4.25 W21 Fix-K — `/api/admin/status` now reads the legacy-form
// counter from the Withings webhook module. Mock it at module boundary
// so the test fixture stays deterministic.
vi.mock("@/app/api/withings/webhook/route", () => ({
getLegacyFormTotal: vi.fn(() => 0),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { requireAdmin, HttpError } from "@/lib/api-handler";
import { getLegacyFormTotal } from "@/app/api/withings/webhook/route";
const ADMIN_CTX = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -169,4 +177,13 @@ describe("GET /api/admin/status (integration health summary)", () => {
const tokenCallArgs = vi.mocked(prisma.apiToken.count).mock.calls[0]?.[0];
expect(tokenCallArgs).toEqual({ where: { revoked: false } });
});
it("surfaces the legacy Withings webhook counter on the counters block (Fix-K sec-M2)", async () => {
vi.mocked(getLegacyFormTotal).mockReturnValue(7);
const res = await GET();
const body = (await res.json()) as {
data: { counters: { withingsWebhookLegacyFormTotal: number } };
};
expect(body.data.counters.withingsWebhookLegacyFormTotal).toBe(7);
});
});
+7
View File
@@ -3,6 +3,7 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { getWorkerStatus } from "@/lib/jobs/worker-status";
import { getLegacyFormTotal } from "@/app/api/withings/webhook/route";
import { readFileSync } from "node:fs";
// Build timestamp (set at build time via next.config)
@@ -89,5 +90,11 @@ export const GET = apiHandler(async () => {
webPush: webPushConfigured ? { configured: true } : null,
bugReport: bugReportConfigured ? { configured: true } : null,
},
// v1.4.25 W21 Fix-K — in-memory counters that the release-gate can
// read to confirm "legacy form usage trending toward zero" before
// the v1.4.27 cut of the legacy Withings webhook route.
counters: {
withingsWebhookLegacyFormTotal: getLegacyFormTotal(),
},
});
});
+282 -60
View File
@@ -5,11 +5,12 @@ import { apiSuccess } from "@/lib/api-response";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { computeBpInTargetWindows } from "@/lib/analytics/bp-in-target";
import { berlinDayKey } from "@/lib/analytics/berlin-day";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { calculateCompliance } from "@/lib/analytics/compliance";
import {
computeHealthScore,
defaultWeightTargetFromHeight,
type ContributingSource,
type HealthScoreInput,
type HealthScoreResult,
} from "@/lib/analytics/health-score";
@@ -19,8 +20,12 @@ import {
correlateWeightWeekday,
type CorrelationResult,
} from "@/lib/insights/correlations";
import type { MeasurementType } from "@/generated/prisma/client";
import type {
MeasurementSource,
MeasurementType,
} from "@/generated/prisma/client";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
export const dynamic = "force-dynamic";
@@ -28,6 +33,14 @@ export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "analytics.get" } });
// v1.4.25 W7b — every day-bucket call inside this route now honours
// the user's display timezone. The legacy `berlinDayKey()` import
// remains for sleep-stage and correlation paths that share their
// helper signature with non-tz-aware code (`computeSleepStageBreakdown`
// is called with a userId only); the per-call sites below all pass
// `userTz` through `userDayKey()`.
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
// Derived from canonical enum so a new measurement type is auto-summarised
// by /api/analytics (V3 audit: enum drift cousins).
const types = [...measurementTypeEnum.options] as MeasurementType[];
@@ -38,6 +51,12 @@ export const GET = apiHandler(async () => {
// sync history. `summarize()` requires the full series (slope7/30/90,
// anomalies) so groupBy cannot replace this read; the chunked path is
// the smallest pagination contract that still satisfies the helper.
// v1.4.25 W5e — per-metric-class source priority pulled once. The
// aggregator passes the persisted JSON straight through so the
// helper's `parseSourcePriority` runs and falls back to defaults
// when the column is null or malformed.
const sourcePriorityJson = user.sourcePriorityJson;
let totalRowsReadForAggregate = 0;
const measurementsByType = await Promise.all(
types.map((type) =>
@@ -53,9 +72,21 @@ export const GET = apiHandler(async () => {
// = total minutes asleep).
let datapoints: DataPoint[];
if (type === "SLEEP_DURATION") {
// v1.4.25 W5e — pick ONE source per day before summing the
// night's stages. With only WITHINGS + MANUAL today, the
// picker passes everything through; once iOS passthrough
// lands (v1.5) the picker prevents double-counted nights
// (HealthKit forwards Withings' Sleep summary to iOS in
// addition to ScanWatch's own stream).
const sleepRows = pickCanonicalSourceRows(
measurements,
"sleep",
sourcePriorityJson,
(d) => userDayKey(d, userTz),
).canonicalRows;
const byDay = new Map<string, { total: number; date: Date }>();
for (const m of measurements) {
const key = berlinDayKey(m.measuredAt);
for (const m of sleepRows) {
const key = userDayKey(m.measuredAt, userTz);
const slot = byDay.get(key) ?? {
total: 0,
date: m.measuredAt,
@@ -104,7 +135,7 @@ export const GET = apiHandler(async () => {
// included when the user has stage-tagged rows in window; null
// otherwise so the UI can render a plain total without the
// breakdown card painting empty.
const sleepStages = await computeSleepStageBreakdown(user.id);
const sleepStages = await computeSleepStageBreakdown(user.id, userTz);
// BMI calculation
let bmi: number | null = null;
@@ -202,7 +233,7 @@ export const GET = apiHandler(async () => {
// floor from 14 → 20). Each runner gates on n >= 20 + p < 0.05;
// below the bar the result.status === "insufficient" and the UI
// paints an EmptyState.
const correlations = await computeCorrelationHypotheses(user.id);
const correlations = await computeCorrelationHypotheses(user.id, userTz);
// v1.4.20 phase B5 — Personal Health Score. Server-deterministic
// composite of BP-in-target % + weight-trend alignment + mood
@@ -248,12 +279,21 @@ export const GET = apiHandler(async () => {
* `summaries.SLEEP_DURATION` totals without a stage card in that
* case. Returns the sum-per-stage AND the day count covered so the
* UI can render an "averaged across N nights" caption truthfully.
*
* v1.4.25 W3f — also returns a `perNight` array: one entry per
* Berlin-tz day inside the window, with minutes-per-stage for the
* stacked-bar chart. Days with zero stage-tagged rows are omitted
* from `perNight` (the chart treats them as gaps).
*/
async function computeSleepStageBreakdown(userId: string): Promise<{
async function computeSleepStageBreakdown(
userId: string,
userTz: string,
): Promise<{
windowDays: number;
nights: number;
totalMinutes: number;
stages: Record<string, number>;
perNight: Array<{ dayKey: string; stages: Record<string, number> }>;
} | null> {
const DAY_MS = 24 * 60 * 60 * 1000;
const since = new Date(Date.now() - 30 * DAY_MS);
@@ -272,18 +312,33 @@ async function computeSleepStageBreakdown(userId: string): Promise<{
const stages: Record<string, number> = {};
const dayKeys = new Set<string>();
let totalMinutes = 0;
// v1.4.25 W3f — per-day accumulator. Keyed by Berlin-tz day so the
// chart's 7/14/30-day slicer can build a left-aligned series.
const perNightMap = new Map<string, Record<string, number>>();
for (const row of rows) {
if (!row.sleepStage) continue;
stages[row.sleepStage] = (stages[row.sleepStage] ?? 0) + row.value;
totalMinutes += row.value;
dayKeys.add(berlinDayKey(row.measuredAt));
const dayKey = userDayKey(row.measuredAt, userTz);
dayKeys.add(dayKey);
const nightStages = perNightMap.get(dayKey) ?? {};
nightStages[row.sleepStage] =
(nightStages[row.sleepStage] ?? 0) + row.value;
perNightMap.set(dayKey, nightStages);
}
// Sort per-night ascending so the chart consumer can slice the
// trailing N entries for the 7d / 14d / 30d toggle.
const perNight = Array.from(perNightMap.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([dayKey, stages]) => ({ dayKey, stages }));
return {
windowDays: 30,
nights: dayKeys.size,
totalMinutes,
stages,
perNight,
};
}
@@ -295,7 +350,10 @@ async function computeSleepStageBreakdown(userId: string): Promise<{
* because the user-facing "based on N paired readings · last 30 days"
* source-chip has to remain truthful.
*/
async function computeCorrelationHypotheses(userId: string): Promise<{
async function computeCorrelationHypotheses(
userId: string,
userTz: string,
): Promise<{
bpCompliance: CorrelationResult;
moodPulse: CorrelationResult;
weightWeekday: CorrelationResult;
@@ -324,10 +382,10 @@ async function computeCorrelationHypotheses(userId: string): Promise<{
]);
// ── Hypothesis 1: BP × medication compliance ────────────────
// Aggregate by Berlin-day key so DST + UTC boundary issues don't split
// a day's readings. The day's "compliance %" is taken / expected for
// that calendar day across all medications.
const dayKey = (d: Date): string => berlinDayKey(d);
// Aggregate by the user's display-tz day key so DST + UTC boundary
// issues don't split a day's readings. The day's "compliance %" is
// taken / expected for that calendar day across all medications.
const dayKey = (d: Date): string => userDayKey(d, userTz);
const dailySys = new Map<string, number[]>();
for (const row of sysRows) {
@@ -360,7 +418,7 @@ async function computeCorrelationHypotheses(userId: string): Promise<{
const compliancePct = (slot.taken / slot.expected) * 100;
const meanSys = sysValues.reduce((s, v) => s + v, 0) / sysValues.length;
bpCompliancePairs.push({
date: dateFromBerlinKey(key),
date: dateFromDayKey(key),
systolic: meanSys,
compliancePct,
});
@@ -373,14 +431,14 @@ async function computeCorrelationHypotheses(userId: string): Promise<{
// resting-pulse field, so we accept the noise rather than skip.
const dailyMood = new Map<string, number[]>();
for (const row of moodRows) {
const key = berlinDayKey(row.moodLoggedAt);
const key = userDayKey(row.moodLoggedAt, userTz);
const list = dailyMood.get(key) ?? [];
list.push(row.score);
dailyMood.set(key, list);
}
const dailyPulse = new Map<string, number[]>();
for (const row of pulseRows) {
const key = berlinDayKey(row.measuredAt);
const key = userDayKey(row.measuredAt, userTz);
const list = dailyPulse.get(key) ?? [];
list.push(row.value);
dailyPulse.set(key, list);
@@ -397,7 +455,7 @@ async function computeCorrelationHypotheses(userId: string): Promise<{
const meanPulse =
pulseValues.reduce((s, v) => s + v, 0) / pulseValues.length;
moodPulsePairs.push({
date: dateFromBerlinKey(key),
date: dateFromDayKey(key),
mood: meanMood,
restingPulse: meanPulse,
});
@@ -406,9 +464,17 @@ async function computeCorrelationHypotheses(userId: string): Promise<{
// ── Hypothesis 3: Weight × weekday ──────────────────────────
// 0 = Monday … 6 = Sunday. ISO weekday minus 1.
//
// v1.4.25 W10 reconcile (Code-H1) — buckets the weekday by the
// user's display tz, matching W7's per-user-tz threading that the
// BP, mood, sleep, and pulse aggregators above already honour. The
// pre-W10 helper was pinned to `Europe/Berlin` and would land a
// user-local 23:30 weight reading from `Pacific/Auckland` under
// Sunday's bucket instead of Monday's — Pearson on the wrong
// weekday column.
const weightWeekdayPairs: Array<{ weekday: number; weight: number }> = [];
for (const row of weightRows) {
const isoWeekday = berlinIsoWeekday(row.measuredAt); // 1..7, 1=Mon
const isoWeekday = isoWeekdayInTz(row.measuredAt, userTz); // 1..7, 1=Mon
weightWeekdayPairs.push({
weekday: isoWeekday - 1,
weight: row.value,
@@ -464,6 +530,22 @@ interface ChunkedRow {
measuredAt: Date;
value: number;
sleepStage: string | null;
/** v1.4.25 W5e — needed by `pickCanonicalSourceRows` so the SLEEP /
* cumulative aggregators can pick ONE source per day when more than
* one ingest path contributes to the same metric. */
source: MeasurementSource;
/** v1.4.25 W8c — second axis of the canonical picker. Nullable until
* the iOS app starts shipping HKDevice.model with each sample;
* legacy / Withings rows stay NULL and the picker treats them as
* `unknown`. Carried on every type's read so the cumulative-metric
* path can break Apple-Watch-vs-iPhone ties.
*/
deviceType: string | null;
/** v1.4.25 W8c — feeds the per-MeasurementType device-type override
* inside the picker. The picker keys
* `deviceTypePriority[type]` off this so the user's "phone wins for
* steps but watch wins for HR" config is honoured. */
type: MeasurementType;
}
async function fetchMeasurementSeriesChunked(
@@ -485,6 +567,11 @@ async function fetchMeasurementSeriesChunked(
id: true,
measuredAt: true,
value: true,
source: true,
// v1.4.25 W8c — read deviceType so the canonical picker can
// honour the per-metric / per-device override. Nullable until
// iOS sends it.
deviceType: true,
...(options.includeSleepStage ? { sleepStage: true } : {}),
},
take: MEASUREMENT_CHUNK_SIZE,
@@ -495,6 +582,9 @@ async function fetchMeasurementSeriesChunked(
out.push({
measuredAt: row.measuredAt,
value: row.value,
source: row.source,
deviceType: row.deviceType ?? null,
type,
sleepStage:
"sleepStage" in row
? ((row.sleepStage as string | null) ?? null)
@@ -511,18 +601,34 @@ async function fetchMeasurementSeriesChunked(
// `src/lib/analytics/berlin-day.ts` so the targets route's sparkline
// bucketing shares the same Europe/Berlin contract. The
// `weekday: "short"` formatter still lives here because it's only
// used by `berlinIsoWeekday()` below.
const BERLIN_DATE_PARTS = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
weekday: "short",
});
// used by `isoWeekdayInTz()` below.
//
// v1.4.25 W10 reconcile (Code-H1) — formatter is now per-tz so the
// weight-weekday correlator honours the same per-user-tz contract the
// BP/mood/pulse aggregators above already use. Memoised by tz so the
// formatter is built once per unique timezone per process (tz strings
// rarely change at runtime; the cache is bounded by the IANA database
// to a few hundred entries even in the worst case).
const WEEKDAY_FORMATTER_CACHE = new Map<string, Intl.DateTimeFormat>();
function getWeekdayFormatter(timeZone: string): Intl.DateTimeFormat {
let formatter = WEEKDAY_FORMATTER_CACHE.get(timeZone);
if (!formatter) {
formatter = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
weekday: "short",
});
WEEKDAY_FORMATTER_CACHE.set(timeZone, formatter);
}
return formatter;
}
function dateFromBerlinKey(key: string): Date {
function dateFromDayKey(key: string): Date {
// Anchor to UTC midnight — the date is a sortable bucket label rather
// than a wall-clock timestamp, so DST drift is irrelevant.
// than a wall-clock timestamp, so DST drift is irrelevant. The tz
// info is already baked into the key (built via `userDayKey` upstream).
return new Date(`${key}T00:00:00.000Z`);
}
@@ -536,12 +642,58 @@ const ISO_WEEKDAY: Record<string, number> = {
Sun: 7,
};
function berlinIsoWeekday(d: Date): number {
const parts = BERLIN_DATE_PARTS.formatToParts(d);
function isoWeekdayInTz(d: Date, timeZone: string): number {
const parts = getWeekdayFormatter(timeZone).formatToParts(d);
const weekday = parts.find((p) => p.type === "weekday")?.value ?? "Mon";
return ISO_WEEKDAY[weekday] ?? 1;
}
/**
* v1.4.25 W8e — collapse the persisted `MeasurementSource` enum onto
* the camelCase token set the health-score analytics layer consumes.
*
* - `MANUAL` and `IMPORT` (CSV import — still user-supplied data) both
* surface as `"manual"`.
* - `WITHINGS` and `APPLE_HEALTH` ride one-to-one.
*
* Returns `null` for source values that don't fall into the three
* exposed buckets — defence-in-depth in case the enum grows before the
* client is taught about it.
*/
function mapMeasurementSourceToLabel(
source: MeasurementSource,
): ContributingSource | null {
switch (source) {
case "MANUAL":
case "IMPORT":
return "manual";
case "WITHINGS":
return "withings";
case "APPLE_HEALTH":
return "appleHealth";
default:
return null;
}
}
/**
* v1.4.25 W8e — deduplicate the contributing-source list for a single
* component. Returns the empty array when nothing in the input maps
* onto a known label so downstream `resolveSourceLabel` falls through
* to `none` (matches the empty-state branch).
*/
function uniqueComponentSources(
rows: ReadonlyArray<MeasurementSource>,
): ReadonlyArray<ContributingSource> {
const seen = new Set<ContributingSource>();
for (const src of rows) {
const label = mapMeasurementSourceToLabel(src);
if (label) seen.add(label);
}
return Array.from(seen);
}
/**
* Build the Health Score input from the user's last-30-day weight,
* mood, and medication-compliance data, plus the already-computed
@@ -564,38 +716,58 @@ async function computeUserHealthScore(
const prevSince30d = new Date(now.getTime() - 37 * DAY_MS);
const prevUntil = new Date(now.getTime() - 7 * DAY_MS);
const [weightRows, moodRows, medications] = await Promise.all([
prisma.measurement.findMany({
where: {
userId,
type: "WEIGHT",
measuredAt: { gte: prevSince30d, lte: now },
},
select: { value: true, measuredAt: true },
orderBy: { measuredAt: "asc" },
}),
prisma.moodEntry.findMany({
where: {
userId,
moodLoggedAt: { gte: prevSince30d, lte: now },
},
select: { score: true, moodLoggedAt: true },
orderBy: { moodLoggedAt: "asc" },
}),
prisma.medication.findMany({
where: { userId, active: true },
select: {
id: true,
createdAt: true,
schedules: {
select: {
windowStart: true,
windowEnd: true,
// v1.4.25 W8e — read the `source` column alongside the value so the
// health-score provenance accordion knows which ingest path drove
// each component. The weight + BP reads already paid the row cost;
// adding the column to the SELECT is free in Postgres terms (no extra
// round-trip, no extra plan node) and the alternative — a second
// aggregate just for the source pill — would burn another findMany.
const [weightRows, bpSysRowsForSource, moodRows, medications] =
await Promise.all([
prisma.measurement.findMany({
where: {
userId,
type: "WEIGHT",
measuredAt: { gte: prevSince30d, lte: now },
},
select: { value: true, measuredAt: true, source: true },
orderBy: { measuredAt: "asc" },
}),
// BP source attribution rides on the systolic-readings row set —
// diastolic rows always carry the same `source` because both
// halves of a pair are persisted in the same write. Pull only the
// trailing 30 days to keep the call bounded for power users.
prisma.measurement.findMany({
where: {
userId,
type: "BLOOD_PRESSURE_SYS",
measuredAt: { gte: since30d, lte: now },
},
select: { measuredAt: true, source: true },
orderBy: { measuredAt: "asc" },
}),
prisma.moodEntry.findMany({
where: {
userId,
moodLoggedAt: { gte: prevSince30d, lte: now },
},
select: { score: true, moodLoggedAt: true },
orderBy: { moodLoggedAt: "asc" },
}),
prisma.medication.findMany({
where: { userId, active: true },
select: {
id: true,
createdAt: true,
schedules: {
select: {
windowStart: true,
windowEnd: true,
},
},
},
},
}),
]);
}),
]);
// Compliance30 per active medication, then again for the prior-week
// snapshot. The compliance helper anchors on `Date.now()` internally;
@@ -688,12 +860,62 @@ async function computeUserHealthScore(
return null;
}
// v1.4.25 W8e — build per-component source attribution from the rows
// we already hold in memory. `mapMeasurementSourceToLabel` collapses
// the persisted `MeasurementSource` enum onto the camelCase token list
// the analytics helper consumes; `IMPORT` rides under `manual`
// because the v1.4.20 CSV importer ingests user-supplied data — it's
// not a wearable stream.
const windowEndAt = now.toISOString();
const weightSourcesIn30d = uniqueComponentSources(
weightRows
.filter((r) => r.measuredAt >= since30d)
.map((r) => r.source),
);
const latestWeightInWindow = weightRows
.filter((r) => r.measuredAt >= since30d)
.at(-1);
const bpSourceTokens = uniqueComponentSources(
bpSysRowsForSource.map((r) => r.source),
);
const latestBpInWindow = bpSysRowsForSource.at(-1);
// Mood doesn't yet have a non-manual ingest (v1.5 will introduce
// Apple Health mood) so the source list is always `["manual"]` when
// there are entries in window. Keep the lookup explicit so the
// v1.5 ingest path slot just drops in.
const moodSourceTokens = moodSeriesLast30d.length > 0
? (["manual"] as const)
: [];
const latestMoodInWindow = moodRows
.filter((r) => r.moodLoggedAt >= since30d)
.at(-1);
// Medication compliance always derives from logged intake events —
// user-driven manual logging today.
const complianceSourceTokens = medicationCompliance30.length > 0
? (["manual"] as const)
: [];
const current: HealthScoreInput = {
bpInTargetRate: input.bpInTargetPct,
weightSeriesLast30d,
weightTargetKg: fallbackTarget,
moodEntriesLast30d: moodSeriesLast30d,
medicationCompliance30,
attribution: {
bpSources: bpSourceTokens,
asOfBp: latestBpInWindow?.measuredAt.toISOString() ?? null,
weightSources: weightSourcesIn30d,
asOfWeight: latestWeightInWindow?.measuredAt.toISOString() ?? null,
moodSources: moodSourceTokens,
asOfMood: latestMoodInWindow?.moodLoggedAt.toISOString() ?? null,
complianceSources: complianceSourceTokens,
asOfCompliance: complianceSourceTokens.length > 0 ? windowEndAt : null,
windowEndAt,
},
};
// The all-time `bpInTargetPct` is a slow-moving aggregate and would
// need a full historical re-pair to "rewind" by a week. We pass the
@@ -0,0 +1,191 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { DEFAULT_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function mkPut(body: unknown): Request {
return new Request("http://localhost/api/auth/me/doctor-report-prefs", {
method: "PUT",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/doctor-report-prefs", () => {
it("returns defaults when the column is null", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
doctorReportPrefsJson: null,
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/doctor-report-prefs"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: typeof DEFAULT_DOCTOR_REPORT_PREFS;
};
expect(env.data).toEqual(DEFAULT_DOCTOR_REPORT_PREFS);
expect(env.data.mood).toBe(false); // privacy default per Marc
});
});
describe("PUT /api/auth/me/doctor-report-prefs", () => {
// ── Case 1 — happy path ─────────────────────────────────────────────
it("persists the full shape and returns the canonical form", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
doctorReportPrefsJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const body = {
bp: true,
weight: true,
pulse: false,
bmi: true,
mood: true,
compliance: true,
sleep: false,
};
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
expect(res.status).toBe(200);
const env = (await res.json()) as { data: typeof body };
expect(env.data).toEqual(body);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { doctorReportPrefsJson: body },
});
});
// ── Case 2 — partial update layers over current persisted row ───────
it("merges a partial update over the persisted row", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const current = {
bp: false,
weight: false,
pulse: false,
bmi: false,
mood: true,
compliance: false,
sleep: false,
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
doctorReportPrefsJson: current,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
// User only flips mood off — every other key keeps its persisted value.
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ mood: false }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: typeof current };
expect(env.data).toEqual({ ...current, mood: false });
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { doctorReportPrefsJson: { ...current, mood: false } },
});
});
// ── Case 3 — invalid shape ─────────────────────────────────────────
it("rejects a non-boolean value with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ bp: "nope" }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
// ── Case 4 — unauthenticated cross-user request ────────────────────
// Marc's spec mentions "cross-user 404" — the route layer returns 401
// when there is no session at all, which is the only "you are not the
// right user" surface available before requireAuth() resolves an
// identity. `prisma.user.update` must NOT fire in this case.
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ mood: false }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
// ── Case 5 — audit trail (v1.4.25 W10 reconcile security M-3) ──────
it("writes an audit-log entry with the previous and new pref shape", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const current = {
bp: true,
weight: true,
pulse: true,
bmi: true,
mood: false,
compliance: true,
sleep: true,
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
doctorReportPrefsJson: current,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ mood: true }),
);
expect(res.status).toBe(200);
expect(auditLog).toHaveBeenCalledWith(
"user.doctor-report-prefs.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
previous: current,
next: expect.objectContaining({ mood: true }),
}),
}),
);
});
});
@@ -0,0 +1,112 @@
/**
* v1.4.25 W6c — per-user Doctor-Report section toggles.
*
* GET /api/auth/me/doctor-report-prefs — returns the resolved prefs
* (defaults when the row is null).
* PUT /api/auth/me/doctor-report-prefs — merges the supplied partial
* shape over the current row
* and persists the canonical
* fully-resolved object so a
* future schema migration
* doesn't need to back-fill
* missing keys.
*
* Bearer-auth + cookie-auth both work via the shared `requireAuth()`
* helper. The doctor-report aggregator + PDF renderer both read this
* row at generation time so mood data is dropped at the aggregator
* layer when `mood = false` — the data never leaves the DB.
*/
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 {
doctorReportPrefsSchema,
parseDoctorReportPrefs,
resolveDoctorReportPrefs,
} from "@/lib/validations/doctor-report-prefs";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.doctor-report-prefs.get" } });
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { doctorReportPrefsJson: true },
});
return apiSuccess(parseDoctorReportPrefs(row?.doctorReportPrefsJson));
});
export const PUT = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
let body: unknown;
try {
body = await req.json();
} catch {
throw new HttpError(422, "doctor-report-prefs.body.invalid_json");
}
const parsed = doctorReportPrefsSchema.safeParse(body ?? {});
if (!parsed.success) {
annotate({
action: { name: "auth.me.doctor-report-prefs.put.invalid" },
meta: { issues: parsed.error.issues.length },
});
throw new HttpError(422, "doctor-report-prefs.body.invalid_shape");
}
// Merge the partial input over the current row (or defaults) so a
// dialog that only toggles `mood` doesn't have to re-state every other
// flag. Persist the canonical fully-resolved object so the column
// shape stays stable across future schema additions.
const current = await prisma.user.findUnique({
where: { id: user.id },
select: { doctorReportPrefsJson: true },
});
const merged = resolveDoctorReportPrefs(
current?.doctorReportPrefsJson,
parsed.data,
);
await prisma.user.update({
where: { id: user.id },
// `merged` is a typed object; Prisma's `InputJsonValue` insists on an
// index signature so cast through `unknown` rather than widen the
// typed return surface. Shape is identical to the validated input.
data: {
doctorReportPrefsJson: merged as unknown as Record<string, boolean>,
},
});
// v1.4.25 W10 reconcile (security M-3): record the previous + new
// pref shape in the audit log. Doctor-Report pref toggles
// (especially `mood: true`) widen the PDF surface; without an
// audit trail a silent compromise (or unintended client write)
// is invisible. Mirrors the timezone-route audit pattern.
await auditLog("user.doctor-report-prefs.update", {
userId: user.id,
ipAddress: getClientIp(req),
details: {
previous: current?.doctorReportPrefsJson ?? null,
next: merged,
},
});
annotate({
action: { name: "auth.me.doctor-report-prefs.put" },
meta: {
bp: merged.bp,
weight: merged.weight,
pulse: merged.pulse,
bmi: merged.bmi,
mood: merged.mood,
compliance: merged.compliance,
sleep: merged.sleep,
},
});
return apiSuccess(merged);
});
@@ -0,0 +1,304 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 4,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: () => ({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { DELETE, GET, POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
import { RESEARCH_MODE_DISCLAIMER_VERSION } from "@/lib/medications/glp1-pk";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function mkPost(body: unknown): Request {
return new Request("http://localhost/api/auth/me/research-mode", {
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
function mkDelete(): Request {
return new Request("http://localhost/api/auth/me/research-mode", {
method: "DELETE",
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/research-mode", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/research-mode"),
);
expect(res.status).toBe(401);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("returns the disabled default for a fresh user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
researchModeEnabled: false,
researchModeAcknowledgedAt: null,
researchModeAcknowledgedVersion: null,
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/research-mode"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: {
enabled: boolean;
acknowledgedAt: string | null;
acknowledgedVersion: string | null;
currentDisclaimerVersion: string;
};
};
expect(env.data.enabled).toBe(false);
expect(env.data.acknowledgedAt).toBeNull();
expect(env.data.acknowledgedVersion).toBeNull();
expect(env.data.currentDisclaimerVersion).toBe(
RESEARCH_MODE_DISCLAIMER_VERSION,
);
});
it("returns the enabled state with the persisted version", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const ackAt = new Date("2026-05-14T10:00:00Z");
vi.mocked(prisma.user.findUnique).mockResolvedValue({
researchModeEnabled: true,
researchModeAcknowledgedAt: ackAt,
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/research-mode"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: {
enabled: boolean;
acknowledgedAt: string;
acknowledgedVersion: string;
currentDisclaimerVersion: string;
};
};
expect(env.data.enabled).toBe(true);
expect(env.data.acknowledgedAt).toBe(ackAt.toISOString());
expect(env.data.acknowledgedVersion).toBe(
RESEARCH_MODE_DISCLAIMER_VERSION,
);
});
});
describe("POST /api/auth/me/research-mode", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (POST as (r: Request) => Promise<Response>)(
mkPost({ acknowledged: true, version: RESEARCH_MODE_DISCLAIMER_VERSION }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("enables Research Mode and writes the audit log on a valid ack", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
researchModeEnabled: false,
researchModeAcknowledgedAt: null,
researchModeAcknowledgedVersion: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (POST as (r: Request) => Promise<Response>)(
mkPost({ acknowledged: true, version: RESEARCH_MODE_DISCLAIMER_VERSION }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { enabled: boolean; acknowledgedVersion: string };
};
expect(env.data.enabled).toBe(true);
expect(env.data.acknowledgedVersion).toBe(
RESEARCH_MODE_DISCLAIMER_VERSION,
);
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "user-1" },
data: expect.objectContaining({
researchModeEnabled: true,
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
}),
}),
);
expect(auditLog).toHaveBeenCalledWith(
"user.research-mode.enable",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
next: expect.objectContaining({
researchModeEnabled: true,
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
}),
}),
}),
);
});
it("rejects a stale disclaimer version with 400", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (POST as (r: Request) => Promise<Response>)(
mkPost({ acknowledged: true, version: "1970-01-01.0" }),
);
expect(res.status).toBe(400);
expect(prisma.user.update).not.toHaveBeenCalled();
expect(auditLog).not.toHaveBeenCalled();
});
it("rejects a body without acknowledged=true with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (POST as (r: Request) => Promise<Response>)(
mkPost({ version: RESEARCH_MODE_DISCLAIMER_VERSION }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("returns 429 when the per-user rate-limit fires", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 30_000,
});
const res = await (POST as (r: Request) => Promise<Response>)(
mkPost({ acknowledged: true, version: RESEARCH_MODE_DISCLAIMER_VERSION }),
);
expect(res.status).toBe(429);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
describe("DELETE /api/auth/me/research-mode", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (DELETE as (r: Request) => Promise<Response>)(mkDelete());
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("disables Research Mode and clears the acknowledgment", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
researchModeEnabled: true,
researchModeAcknowledgedAt: new Date(),
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (DELETE as (r: Request) => Promise<Response>)(mkDelete());
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: {
enabled: boolean;
acknowledgedAt: string | null;
acknowledgedVersion: string | null;
};
};
expect(env.data.enabled).toBe(false);
expect(env.data.acknowledgedAt).toBeNull();
expect(env.data.acknowledgedVersion).toBeNull();
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: {
researchModeEnabled: false,
researchModeAcknowledgedAt: null,
researchModeAcknowledgedVersion: null,
},
});
expect(auditLog).toHaveBeenCalledWith(
"user.research-mode.disable",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
next: {
researchModeEnabled: false,
researchModeAcknowledgedAt: null,
researchModeAcknowledgedVersion: null,
},
}),
}),
);
});
it("is idempotent when Research Mode is already off", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
researchModeEnabled: false,
researchModeAcknowledgedAt: null,
researchModeAcknowledgedVersion: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (DELETE as (r: Request) => Promise<Response>)(mkDelete());
expect(res.status).toBe(200);
// The route writes the canonical off-state even when the
// column is already off — guarantees audit trail symmetry.
expect(prisma.user.update).toHaveBeenCalledTimes(1);
expect(auditLog).toHaveBeenCalledTimes(1);
});
});
+218
View File
@@ -0,0 +1,218 @@
/**
* v1.4.25 W19c — Research Mode acknowledgment endpoint.
*
* GET /api/auth/me/research-mode — returns the current flag,
* the timestamp + version of
* the last acknowledgment, and
* the live disclaimer version
* so the Settings UI can
* compare and re-prompt.
* POST /api/auth/me/research-mode — body { acknowledged: true,
* version }. Flips the flag
* on. Rejects 400 if `version`
* is not the live version.
* DELETE /api/auth/me/research-mode — disables Research Mode and
* clears the acknowledgment.
* Idempotent.
*
* Per-user POST rate-limit is intentionally low (5/min) — this is a
* one-tap acknowledgment dialog, not a hot path. Anything above that
* traffic looks like a malformed client retry-loop or an exploit.
*
* Prompt-injection surface — the API accepts the disclaimer
* `version` string and nothing else from the client. The version
* value never reaches the LLM; it is matched against the
* server-side constant `RESEARCH_MODE_DISCLAIMER_VERSION` and then
* persisted as the stamp on the user row. No free-text body.
*
* Audit-log entries fire on every state-changing call (POST, DELETE)
* with the previous + next state, mirroring the doctor-report-prefs
* route's reconcile pattern (W10 security M-3).
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { RESEARCH_MODE_DISCLAIMER_VERSION } from "@/lib/medications/glp1-pk";
export const dynamic = "force-dynamic";
const POST_RATE_LIMIT = 5;
const POST_WINDOW_MS = 60_000;
type ResearchModeResponse = {
enabled: boolean;
acknowledgedAt: string | null;
acknowledgedVersion: string | null;
currentDisclaimerVersion: string;
};
function shape(row: {
researchModeEnabled: boolean;
researchModeAcknowledgedAt: Date | null;
researchModeAcknowledgedVersion: string | null;
}): ResearchModeResponse {
return {
enabled: row.researchModeEnabled,
acknowledgedAt: row.researchModeAcknowledgedAt
? row.researchModeAcknowledgedAt.toISOString()
: null,
acknowledgedVersion: row.researchModeAcknowledgedVersion,
currentDisclaimerVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
};
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.research-mode.get" } });
const row = await prisma.user.findUnique({
where: { id: user.id },
select: {
researchModeEnabled: true,
researchModeAcknowledgedAt: true,
researchModeAcknowledgedVersion: true,
},
});
// `requireAuth` already proved the user exists; defensive default
// here only matters for the (impossible-in-practice) race where
// the user is deleted mid-request.
return apiSuccess(
shape(
row ?? {
researchModeEnabled: false,
researchModeAcknowledgedAt: null,
researchModeAcknowledgedVersion: null,
},
),
);
});
export const POST = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
// Per-user rate-limit. 5/min is generous for a once-per-update
// acknowledgment dialog; anything more looks like a retry loop.
const rl = await checkRateLimit(
`research-mode:post:${user.id}`,
POST_RATE_LIMIT,
POST_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
let body: unknown;
try {
body = await req.json();
} catch {
throw new HttpError(422, "research-mode.body.invalid_json");
}
// Hand-rolled validation — the body is tiny (two fields), and
// staying off Zod keeps the prompt-injection surface visibly
// simple in a code review.
if (
!body ||
typeof body !== "object" ||
(body as { acknowledged?: unknown }).acknowledged !== true ||
typeof (body as { version?: unknown }).version !== "string"
) {
annotate({ action: { name: "auth.me.research-mode.post.invalid_shape" } });
throw new HttpError(422, "research-mode.body.invalid_shape");
}
const submittedVersion = (body as { version: string }).version;
// Stale-version guard — the dialog must show the *live* copy at
// the moment of acknowledgment. A stale version means the user
// is acknowledging older wording; we refuse and force the
// Settings UI to re-render with the new disclaimer.
if (submittedVersion !== RESEARCH_MODE_DISCLAIMER_VERSION) {
annotate({
action: { name: "auth.me.research-mode.post.stale_version" },
meta: {
submitted: submittedVersion,
current: RESEARCH_MODE_DISCLAIMER_VERSION,
},
});
throw new HttpError(400, "research-mode.version.stale");
}
const previous = await prisma.user.findUnique({
where: { id: user.id },
select: {
researchModeEnabled: true,
researchModeAcknowledgedAt: true,
researchModeAcknowledgedVersion: true,
},
});
const next = {
researchModeEnabled: true,
researchModeAcknowledgedAt: new Date(),
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
};
await prisma.user.update({
where: { id: user.id },
data: next,
});
await auditLog("user.research-mode.enable", {
userId: user.id,
ipAddress: getClientIp(req),
details: {
previous: previous ?? null,
next: {
...next,
researchModeAcknowledgedAt: next.researchModeAcknowledgedAt.toISOString(),
},
},
});
annotate({
action: { name: "auth.me.research-mode.post" },
meta: { version: RESEARCH_MODE_DISCLAIMER_VERSION },
});
return apiSuccess(shape(next));
});
export const DELETE = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
const previous = await prisma.user.findUnique({
where: { id: user.id },
select: {
researchModeEnabled: true,
researchModeAcknowledgedAt: true,
researchModeAcknowledgedVersion: true,
},
});
// Idempotent — if Research Mode is already off, we still write
// the canonical "off" state so the audit row exists.
const next = {
researchModeEnabled: false,
researchModeAcknowledgedAt: null,
researchModeAcknowledgedVersion: null,
};
await prisma.user.update({
where: { id: user.id },
data: next,
});
await auditLog("user.research-mode.disable", {
userId: user.id,
ipAddress: getClientIp(req),
details: { previous: previous ?? null, next },
});
annotate({ action: { name: "auth.me.research-mode.delete" } });
return apiSuccess(shape(next));
});
@@ -0,0 +1,138 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function mkPut(body: unknown): Request {
return new Request("http://localhost/api/auth/me/source-priority", {
method: "PUT",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/source-priority", () => {
it("rejects unauthenticated requests with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as () => Promise<Response>)();
expect(res.status).toBe(401);
});
it("returns the resolved shape with defaults when the column is null", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
} as never);
const res = await (GET as () => Promise<Response>)();
expect(res.status).toBe(200);
const env = (await res.json()) as { data: Record<string, unknown> };
// Resolver always emits the keys the analytics layer needs.
expect(env.data).toHaveProperty("weight");
expect(env.data).toHaveProperty("metricPriority");
expect(env.data).toHaveProperty("deviceTypePriority");
});
});
describe("PUT /api/auth/me/source-priority", () => {
it("rejects unauthenticated requests with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"] }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("persists the partial shape", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const body = { weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"] };
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { sourcePriorityJson: body },
});
});
it("rejects an invalid shape with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ weight: "not-an-array" }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
// ── v1.4.25 W10 reconcile (security M-3) ──
it("writes an audit-log entry with the previous and new shape", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const previousShape = {
weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"],
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: previousShape,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const body = { weight: ["APPLE_HEALTH", "WITHINGS", "MANUAL"] };
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
expect(res.status).toBe(200);
expect(auditLog).toHaveBeenCalledWith(
"user.source-priority.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
previous: previousShape,
next: body,
}),
}),
);
});
});
@@ -0,0 +1,94 @@
/**
* v1.4.25 W5e — per-user, per-metric-class source priority.
*
* GET /api/auth/me/source-priority — returns the fully-defaulted
* shape (missing keys filled
* from `DEFAULT_SOURCE_PRIORITY`).
* PUT /api/auth/me/source-priority — replaces the persisted shape;
* body is validated against
* `sourcePrioritySchema` and
* persisted as the partial form
* (missing keys keep the default
* at read time).
*
* 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.
*/
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 {
parseSourcePriority,
sourcePrioritySchema,
} from "@/lib/validations/source-priority";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.source-priority.get" } });
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { sourcePriorityJson: true },
});
return apiSuccess(parseSourcePriority(row?.sourcePriorityJson));
});
export const PUT = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
let body: unknown;
try {
body = await req.json();
} catch {
throw new HttpError(422, "source-priority.body.invalid_json");
}
const parsed = sourcePrioritySchema.safeParse(body ?? {});
if (!parsed.success) {
annotate({
action: { name: "auth.me.source-priority.put.invalid" },
meta: { issues: parsed.error.issues.length },
});
throw new HttpError(422, "source-priority.body.invalid_shape");
}
// v1.4.25 W10 reconcile (security M-3): capture the previous shape
// before overwriting so the audit-log entry records the actual
// delta. Source-priority drives every aggregator's source pick,
// so a silent compromise (or accidental client write) was
// previously invisible — mirroring the timezone-route audit
// pattern closes the gap.
const before = await prisma.user.findUnique({
where: { id: user.id },
select: { sourcePriorityJson: true },
});
// Persist the partial form — missing keys read as defaults via
// `parseSourcePriority`. Storing only the user-edited subset keeps
// the Json blob narrow and future-proofs the shape: when a new
// metric class is added, every existing row reads the new key's
// default until the user explicitly changes it.
await prisma.user.update({
where: { id: user.id },
data: { sourcePriorityJson: parsed.data },
});
await auditLog("user.source-priority.update", {
userId: user.id,
ipAddress: getClientIp(req),
details: {
previous: before?.sourcePriorityJson ?? null,
next: parsed.data,
},
});
annotate({
action: { name: "auth.me.source-priority.put" },
meta: { keys: Object.keys(parsed.data).length },
});
return apiSuccess(parseSourcePriority(parsed.data));
});
@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
appSettings: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function mkReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/auth/me/timezone", {
method: "PUT",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("PUT /api/auth/me/timezone", () => {
it("rejects unauthenticated requests with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await PUT(mkReq({ timezone: "Europe/Berlin" }));
expect(res.status).toBe(401);
});
it("accepts a valid IANA zone and writes it to the user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({
timezone: "Pacific/Auckland",
} as never);
const res = await PUT(mkReq({ timezone: "Pacific/Auckland" }));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { timezone: "Pacific/Auckland" },
});
});
it("rejects an invalid timezone with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await PUT(mkReq({ timezone: "Mars/Olympus" }));
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects an empty timezone with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await PUT(mkReq({ timezone: "" }));
expect(res.status).toBe(422);
});
it("rejects a missing timezone field with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await PUT(mkReq({}));
expect(res.status).toBe(422);
});
it("trims whitespace before validating", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({
timezone: "Asia/Tokyo",
} as never);
const res = await PUT(mkReq({ timezone: " Asia/Tokyo " }));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { timezone: "Asia/Tokyo" },
});
});
});
+78
View File
@@ -0,0 +1,78 @@
/**
* PUT /api/auth/me/timezone
*
* v1.4.25 W7 — per-user timezone setter. Lifts the timezone write
* out of `PUT /api/auth/profile` so the picker UI can post the
* single field without round-tripping email/heightCm/etc, and so
* the change pipeline (validate → write → invalidate-cache) is
* isolated from the heavier profile patch.
*
* Auth: cookie session OR Bearer token.
* Audit: `user.timezone.update` with the old + new zone.
*
* Validation: the body is `{ "timezone": "Europe/Berlin" }`. We
* accept any string the runtime's `Intl.DateTimeFormat` constructor
* accepts — i.e. every IANA zone the active Node engine knows about.
* Rejecting unknown zones at the API surface stops a malformed
* client from poisoning the column with a value the resolver
* would later have to defend against.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserTimezone, isValidTimezone } from "@/lib/tz/resolver";
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "user.timezone.update" } });
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const tz =
body && typeof (body as { timezone?: unknown }).timezone === "string"
? ((body as { timezone: string }).timezone || "").trim()
: "";
if (!isValidTimezone(tz)) {
return apiError("Not a valid IANA timezone.", 422);
}
const before = await prisma.user.findUnique({
where: { id: user.id },
select: { timezone: true },
});
await prisma.user.update({
where: { id: user.id },
data: { timezone: tz },
});
invalidateUserTimezone(user.id);
await auditLog("user.timezone.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
previous: before?.timezone ?? null,
next: tz,
},
});
annotate({
meta: {
timezone_previous: before?.timezone ?? null,
timezone_next: tz,
},
});
return apiSuccess({ timezone: tz });
});
+21 -2
View File
@@ -15,6 +15,10 @@ import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import {
isValidTimezone,
resolveServerDefaultTimezone,
} from "@/lib/tz/resolver";
export const POST = apiHandler(async (request: NextRequest) => {
const ip = getClientIp(request) ?? "unknown";
@@ -59,7 +63,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError(parsed.error.issues[0].message, 422);
}
const { email, username, password } = parsed.data;
const { email, username, password, timezone: timezoneInput } = parsed.data;
// v1.4.25 W7 — accept a browser-detected timezone from the
// registration form. Validate against the runtime IANA list; on
// any invalid value (or absence), fall back to the admin-
// configured server default. The `User.timezone` column has a
// hard-coded "Europe/Berlin" default at the schema layer, so the
// worst-case chain still produces a usable string.
let timezone: string;
const trimmedTimezone = timezoneInput?.trim() ?? "";
if (trimmedTimezone && isValidTimezone(trimmedTimezone)) {
timezone = trimmedTimezone;
} else {
timezone = await resolveServerDefaultTimezone();
}
// Check if email or username already taken (unified message to prevent enumeration)
const [existingEmail, existingUsername] = await Promise.all([
@@ -91,6 +109,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
username,
passwordHash,
role,
timezone,
},
});
@@ -104,7 +123,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
await auditLog("auth.register", {
userId: user.id,
ipAddress: ip,
details: { method: "password" },
details: { method: "password", timezone },
});
annotate({ action: { name: "auth.register" } });
+203
View File
@@ -0,0 +1,203 @@
/**
* GET /api/dashboard/glp1 — v1.4.25 W6
*
* Powers the dashboard's `<Glp1Tile>`. Returns the user's active GLP-1
* medications (treatmentClass = "GLP1"), each enriched with a tiny
* weight series + the dates the user logged an injection. Returns
* `null` data when the user has no active GLP-1 medication — the tile
* then suppresses itself per Marc's empty-state hide rule.
*
* Bridge layer: the heavy lifting (current dose / last + next injection
* / titration history / inventory math) is delegated to
* `buildGlp1SnapshotBlock` so the Coach's GLP-1-aware reply path and
* this dashboard tile read from the exact same shape. The extras we
* add on top here are purely UI-facing:
* - weightSeries[] — daily aggregates since the first dose change
* (or 90 days back, whichever is later) for
* the mini-chart on the tile
* - weightDeltaKg — convenience caption value (current − start);
* the tile renders "−4.2 kg seit Beginn" off
* this number, with `null` when either side
* is missing
* - injectionDates[] — `YYYY-MM-DD` keys for every recorded intake
* event so the chart can paint the vertical
* injection-day markers Marc wants
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { buildGlp1SnapshotBlock } from "@/lib/ai/coach/glp1-snapshot";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
const WEIGHT_LOOKBACK_DAYS = 90;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
function isoDate(d: Date): string {
return d.toISOString().slice(0, 10);
}
/**
* Bucket raw weight measurements into one daily average so the mini-
* chart doesn't render the noisy raw stream. Same `YYYY-MM-DD` keying
* the dashboard chart layer uses (UTC-noon anchor — DST-safe).
*/
function bucketDaily(
rows: Array<{ measuredAt: Date; value: number }>,
): Array<{ date: string; weight: number }> {
const sums = new Map<string, { sum: number; count: number }>();
for (const row of rows) {
const key = isoDate(row.measuredAt);
const slot = sums.get(key) ?? { sum: 0, count: 0 };
slot.sum += row.value;
slot.count += 1;
sums.set(key, slot);
}
return Array.from(sums.entries())
.map(([date, { sum, count }]) => ({
date,
weight: Math.round((sum / count) * 100) / 100,
}))
.sort((a, b) => (a.date < b.date ? -1 : 1));
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
const now = new Date();
const snapshot = await buildGlp1SnapshotBlock(user.id, now);
// No active GLP-1 medication → the tile suppresses itself. We still
// return 200 + `data: null` so the client doesn't have to special-
// case the 404 path.
if (!snapshot) {
annotate({
action: { name: "dashboard.glp1" },
meta: { active: 0 },
});
return apiSuccess(null);
}
// Anchor the weight series to the earliest meaningful date: the user
// wants to see the trend SINCE THEY STARTED the GLP-1, not just the
// last 90 days. We pick the earlier of:
// - the first dose-change `effectiveFrom` across all GLP-1 meds
// - 90 days back from today (so brand-new accounts still get a
// reasonable window when they only just logged their first dose)
let anchor = new Date(now.getTime() - WEIGHT_LOOKBACK_DAYS * MS_PER_DAY);
for (const med of snapshot.medications) {
if (med.doseHistory.length === 0) continue;
const firstDose = med.doseHistory[0].effectiveFrom;
const firstDoseMs = Date.parse(`${firstDose}T00:00:00Z`);
if (Number.isFinite(firstDoseMs) && firstDoseMs < anchor.getTime()) {
anchor = new Date(firstDoseMs);
}
}
const [weights, intakeRows] = await Promise.all([
prisma.measurement.findMany({
where: {
userId: user.id,
type: "WEIGHT",
measuredAt: { gte: anchor },
},
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
}),
prisma.medicationIntakeEvent.findMany({
where: {
userId: user.id,
takenAt: { not: null, gte: anchor },
medication: { treatmentClass: "GLP1" },
},
orderBy: { takenAt: "asc" },
select: {
medicationId: true,
takenAt: true,
medication: { select: { name: true } },
},
}),
]);
const weightSeries = bucketDaily(weights);
// Per-med injection date set — used by the chart's vertical markers.
// Multiple injection events on the same day collapse to a single
// marker so the chart doesn't paint stacked overlapping lines.
const injectionDatesByMed = new Map<string, Set<string>>();
for (const ev of intakeRows) {
if (!ev.takenAt) continue;
const set = injectionDatesByMed.get(ev.medicationId) ?? new Set<string>();
set.add(isoDate(ev.takenAt));
injectionDatesByMed.set(ev.medicationId, set);
}
// We also need to know the medication id per snapshot entry. The
// snapshot exposes name + genericName but not the id; re-fetch the
// id list ordered by `createdAt: desc` (matches the snapshot
// ordering) and zip the two lists. Cheap re-query — already in cache
// from the snapshot's first hit.
const idsForOrder = await prisma.medication.findMany({
where: { userId: user.id, treatmentClass: "GLP1", active: true },
orderBy: { createdAt: "desc" },
select: { id: true, name: true },
});
// Build the enriched per-med payload. `startWeight` = the first daily
// bucket on/after the med's first dose-change date; `currentWeight`
// = the most recent daily bucket. Both `null` when no weight data
// exists in the window — the tile suppresses the delta caption then.
const medications = snapshot.medications.map((med, idx) => {
const medId = idsForOrder[idx]?.id ?? null;
let startWeight: number | null = null;
let currentWeight: number | null = null;
let medSeries: Array<{ date: string; weight: number }> = [];
if (weightSeries.length > 0) {
const firstDoseDate =
med.doseHistory.length > 0 ? med.doseHistory[0].effectiveFrom : null;
// Filter the series to "since the user started this medication".
// If we don't have a dose-change row (legacy data) we fall back
// to the whole 90-day window so the chart isn't empty.
medSeries = firstDoseDate
? weightSeries.filter((p) => p.date >= firstDoseDate)
: weightSeries.slice();
startWeight = medSeries[0]?.weight ?? null;
currentWeight = medSeries[medSeries.length - 1]?.weight ?? null;
}
const weightDeltaKg =
startWeight !== null && currentWeight !== null
? Math.round((currentWeight - startWeight) * 100) / 100
: null;
const injectionDates = medId
? Array.from(injectionDatesByMed.get(medId) ?? []).sort()
: [];
return {
...med,
medicationId: medId,
startWeight,
currentWeight,
weightDeltaKg,
weightSeries: medSeries,
injectionDates,
};
});
annotate({
action: { name: "dashboard.glp1" },
meta: {
active: medications.length,
tz: userTz,
},
});
return apiSuccess({
active: snapshot.active,
medications,
});
});
+32 -25
View File
@@ -17,6 +17,7 @@ import type { MeasurementType } from "@/generated/prisma/client";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { defaultLocale, type Locale } from "@/lib/i18n/config";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
const SPARK_DAYS = 7;
const STREAK_WINDOW_DAYS = 365;
@@ -81,35 +82,35 @@ function trendOf(values: number[]): MetricCard["trend"] {
return delta > 0 ? "up" : "down";
}
function startOfDayBerlin(date: Date): Date {
// Compute midnight in Europe/Berlin → UTC ms.
const berlin = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
function startOfDayInTz(date: Date, tz: string): Date {
// Compute midnight in the user's tz → UTC ms.
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date);
const y = berlin.find((p) => p.type === "year")?.value ?? "1970";
const m = berlin.find((p) => p.type === "month")?.value ?? "01";
const d = berlin.find((p) => p.type === "day")?.value ?? "01";
const y = parts.find((p) => p.type === "year")?.value ?? "1970";
const m = parts.find((p) => p.type === "month")?.value ?? "01";
const d = parts.find((p) => p.type === "day")?.value ?? "01";
return new Date(`${y}-${m}-${d}T00:00:00.000Z`);
}
function berlinDayKey(date: Date): string {
return new Intl.DateTimeFormat("sv-SE", {
timeZone: "Europe/Berlin",
}).format(date);
}
interface StreakInfo {
currentDays: number;
longest: number;
}
/** Compute the current logging-day streak (days where any measurement or
* intake event was recorded, in Berlin time) plus the longest streak in
* the last `STREAK_WINDOW_DAYS` days. */
function computeStreak(activityDays: Set<string>): StreakInfo {
* intake event was recorded, in the user's display timezone) plus the
* longest streak in the last `STREAK_WINDOW_DAYS` days.
*
* v1.4.25 W7b — `userTz` parameterises the "today" pivot so a Pacific/
* Auckland user gets their Auckland-day streak rather than the Berlin
* one. The activity-day Set entries are already produced in the same
* zone by the caller (via `userDayKey`), so the cursor walk only needs
* the same zone here to align. */
function computeStreak(activityDays: Set<string>, userTz: string): StreakInfo {
if (activityDays.size === 0) return { currentDays: 0, longest: 0 };
const sorted = [...activityDays].sort();
@@ -126,15 +127,15 @@ function computeStreak(activityDays: Set<string>): StreakInfo {
}
}
// Current streak: walk back from today (Berlin).
const todayKey = berlinDayKey(new Date());
// Current streak: walk back from today (user's tz).
const todayKey = userDayKey(new Date(), userTz);
let currentDays = 0;
let cursor = new Date(`${todayKey}T00:00:00.000Z`);
// Allow yesterday's last day to count if today not yet logged.
if (!activityDays.has(berlinDayKey(cursor))) {
if (!activityDays.has(userDayKey(cursor, userTz))) {
cursor = new Date(cursor.getTime() - 86_400_000);
}
while (activityDays.has(berlinDayKey(cursor))) {
while (activityDays.has(userDayKey(cursor, userTz))) {
currentDays += 1;
cursor = new Date(cursor.getTime() - 86_400_000);
}
@@ -146,12 +147,18 @@ export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "dashboard.summary" } });
// v1.4.25 W7b — anchor every day-bucket call to the user's display
// timezone. Falls back to Europe/Berlin when the column is somehow
// missing (defensive — the schema's NOT NULL default normally pins
// it).
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
const now = new Date();
const sevenDaysAgo = new Date(now.getTime() - SPARK_DAYS * 86_400_000);
const streakWindowStart = new Date(
now.getTime() - STREAK_WINDOW_DAYS * 86_400_000,
);
const todayStart = startOfDayBerlin(now);
const todayStart = startOfDayInTz(now, userTz);
const todayEnd = new Date(todayStart.getTime() + 86_400_000);
// Derived from canonical enum so a new measurement type is auto-included
@@ -192,7 +199,7 @@ export const GET = apiHandler(async () => {
const activityDays = new Set<string>();
for (const m of recentMeasurements)
activityDays.add(berlinDayKey(m.measuredAt));
activityDays.add(userDayKey(m.measuredAt, userTz));
// Pull a wider measurement window for the streak so it isn't capped by
// SPARK_DAYS — but only if the user has any data at all.
if (recentMeasurements.length > 0) {
@@ -203,13 +210,13 @@ export const GET = apiHandler(async () => {
},
select: { measuredAt: true },
});
for (const m of wider) activityDays.add(berlinDayKey(m.measuredAt));
for (const m of wider) activityDays.add(userDayKey(m.measuredAt, userTz));
}
for (const e of streakActivity) {
activityDays.add(berlinDayKey(e.takenAt ?? e.scheduledFor));
activityDays.add(userDayKey(e.takenAt ?? e.scheduledFor, userTz));
}
const streak = computeStreak(activityDays);
const streak = computeStreak(activityDays, userTz);
// Per-type latest + sparkline.
const byType = new Map<MeasurementType, { value: number; at: Date }[]>();
+24 -1
View File
@@ -58,13 +58,36 @@ const layoutSchema = z.object({
// v1.4.18 — per-chart overlay prefs (3 toggles per chart card).
// Optional so older clients that don't know the field can still PUT;
// the resolver coerces malformed values away from the layout blob.
//
// v1.4.25 W6 — switched from `z.record(enum, …)` to `z.partialRecord(…)`
// because Zod v4 changed the semantics of `z.record(enum, …)`: it now
// requires every enum key to be present (a breaking change from
// Zod v3 which behaved like a partial record). With the strict variant
// any PUT that carried `chartOverlayPrefs` with fewer than ALL nine
// chart keys (i.e. every real-world Save click once a user had touched
// a per-chart overlay popover) 422'd with
// `expected: object, path: ["chartOverlayPrefs", "<missing-key>"]` and
// surfaced as the toast `Layout konnte nicht gespeichert werden`.
// Partial-record matches the original intent — overlay prefs are
// per-chart opt-in, the resolver fills in defaults for missing keys.
//
// The inner object also documents `comparisonBaseline` so the
// per-chart `<ChartOverlayControls>` popover (which calls
// `/api/dashboard/chart-overlay-prefs`) and a full-layout PUT from
// Settings → Dashboard can both round-trip the field. Without it Zod
// would silently strip the per-chart `comparisonBaseline` on every
// Save click in Settings, wiping any per-chart toggle the user had
// set via the chart-card popover.
chartOverlayPrefs: z
.record(
.partialRecord(
z.enum(CHART_OVERLAY_KEYS),
z.object({
showTrendIndicator: z.boolean(),
showTrendArrow: z.boolean(),
showTargetRange: z.boolean(),
comparisonBaseline: z
.enum(["none", "lastMonth", "lastYear"])
.optional(),
}),
)
.optional(),
@@ -0,0 +1,121 @@
/**
* v1.4.25 W6c — doctor-report section availability probe.
*
* Returns one boolean per toggleable section so the dialog can hide
* checkboxes for data types that have zero rows in the user-selected
* date range (Marc's "hide-when-empty" rule). Pure presence check —
* `count` queries on small indexed tables — so this is cheap enough to
* run on every date-range change in the dialog without driving cost.
*
* Body shape (mirrors `/api/doctor-report`):
* { startDate?: ISO, endDate?: ISO, days?: number }
*/
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, safeJson } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { normaliseDateRange } from "@/lib/doctor-report-data";
export const dynamic = "force-dynamic";
export interface DoctorReportAvailability {
/** Section flags — `true` when at least one row exists in the range. */
bp: boolean;
weight: boolean;
pulse: boolean;
/** BMI requires both a weight reading AND a configured height. */
bmi: boolean;
mood: boolean;
/** Medication compliance — derived from `MedicationIntakeEvent` rows. */
compliance: boolean;
sleep: boolean;
}
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "doctor-report.availability" } });
const { data: body, error } = await safeJson(request);
if (error) return error;
const range = normaliseDateRange(body ?? undefined);
const { start, end } = range;
// `count` queries are cheaper than `findMany` for a presence check —
// we never read the rows themselves. The user has indexes on
// `(userId, measuredAt)`, `(userId, moodLoggedAt)`, and
// `(userId, scheduledFor)` so these stay sub-millisecond even with
// multi-year ranges.
const [
bpCount,
weightCount,
pulseCount,
moodCount,
complianceCount,
sleepCount,
profile,
] = await Promise.all([
prisma.measurement.count({
where: {
userId: user.id,
measuredAt: { gte: start, lte: end },
type: { in: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"] },
},
}),
prisma.measurement.count({
where: {
userId: user.id,
measuredAt: { gte: start, lte: end },
type: "WEIGHT",
},
}),
prisma.measurement.count({
where: {
userId: user.id,
measuredAt: { gte: start, lte: end },
type: "PULSE",
},
}),
prisma.moodEntry.count({
where: { userId: user.id, moodLoggedAt: { gte: start, lte: end } },
}),
prisma.medicationIntakeEvent.count({
where: { userId: user.id, scheduledFor: { gte: start, lte: end } },
}),
prisma.measurement.count({
where: {
userId: user.id,
measuredAt: { gte: start, lte: end },
type: "SLEEP_DURATION",
},
}),
prisma.user.findUnique({
where: { id: user.id },
select: { heightCm: true },
}),
]);
// BMI is derived (latest weight × profile height) so it only shows up
// when BOTH inputs are available — otherwise the user would tick a
// box that produces a silently-empty section.
const bmi = weightCount > 0 && (profile?.heightCm ?? null) !== null;
const availability: DoctorReportAvailability = {
bp: bpCount > 0,
weight: weightCount > 0,
pulse: pulseCount > 0,
bmi,
mood: moodCount > 0,
compliance: complianceCount > 0,
sleep: sleepCount > 0,
};
annotate({
meta: {
report_days: range.days,
sections_available: Object.values(availability).filter(Boolean).length,
},
});
return apiSuccess(availability);
});
+29 -2
View File
@@ -11,9 +11,15 @@ import {
} from "@/lib/doctor-report-data";
import { prisma } from "@/lib/db";
import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
import {
DEFAULT_DOCTOR_REPORT_PREFS,
doctorReportPrefsSchema,
type DoctorReportPrefs,
} from "@/lib/validations/doctor-report-prefs";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
import { locales, type Locale } from "@/lib/i18n/config";
import { resolveUserTimezone } from "@/lib/tz/resolver";
/**
* Server-rendered PDF doctor report.
@@ -47,6 +53,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
const rawPracticeName = body?.practiceName;
const practiceName = sanitisePracticeName(rawPracticeName);
// v1.4.25 W6c — section toggles. Malformed shapes silently fall back
// to documented defaults so a forward-compat client never lands a
// 422 on a benign drift.
const sectionsParsed = doctorReportPrefsSchema.safeParse(
body?.sections ?? {},
);
const sectionsInput = sectionsParsed.success ? sectionsParsed.data : {};
const sections: DoctorReportPrefs = {
...DEFAULT_DOCTOR_REPORT_PREFS,
...sectionsInput,
};
if (typeof rawPracticeName === "string" && practiceName !== null) {
try {
await prisma.user.update({
@@ -58,10 +76,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
const data = await collectDoctorReportData(user.id, range, { practiceName });
const [data, userTz] = await Promise.all([
collectDoctorReportData(user.id, range, { practiceName, sections }),
resolveUserTimezone(user.id),
]);
const { t } = getServerTranslator(locale);
const pdfBytes = renderDoctorReportPdfBytes(data, { t, locale });
const pdfBytes = renderDoctorReportPdfBytes(data, { t, locale, userTz });
await auditLog("doctor-report.pdf.generate", {
userId: user.id,
@@ -72,6 +93,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
endDate: range.end.toISOString(),
locale,
practiceNameProvided: practiceName !== null,
// v1.4.25 W6c — record exactly which sections this report
// rendered, so a future privacy review can confirm mood was
// never rendered when the user opted out.
sections,
},
});
@@ -107,6 +132,8 @@ interface PdfRequestBody {
endDate?: unknown;
locale?: unknown;
practiceName?: unknown;
/** v1.4.25 W6c — per-section visibility toggles. */
sections?: unknown;
}
/**
+16 -1
View File
@@ -15,6 +15,7 @@ import {
sanitisePracticeName,
} from "@/lib/doctor-report-data";
import { prisma } from "@/lib/db";
import { doctorReportPrefsSchema } from "@/lib/validations/doctor-report-prefs";
/**
* Collect data for doctor report PDF generation (client-side).
@@ -51,6 +52,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
?.practiceName;
const practiceName = sanitisePracticeName(rawPracticeName);
// v1.4.25 W6c — section toggles. Malformed shapes silently fall
// through to the documented defaults so a forward-compat client
// never lands a 422 on a benign drift.
const rawSections = (body as Record<string, unknown> | null)?.sections;
const sectionsParsed = doctorReportPrefsSchema.safeParse(rawSections ?? {});
const sections = sectionsParsed.success ? sectionsParsed.data : {};
// Persist the most-recent practice name as a user preference so the
// dialog can pre-fill it next time. We only write when the caller
// actually supplied a non-empty string — passing `null`/empty does NOT
@@ -67,7 +75,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
const data = await collectDoctorReportData(user.id, range, { practiceName });
const data = await collectDoctorReportData(user.id, range, {
practiceName,
sections,
});
await auditLog("doctor-report.generate", {
userId: user.id,
@@ -77,6 +88,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
startDate: range.start.toISOString(),
endDate: range.end.toISOString(),
practiceNameProvided: practiceName !== null,
// v1.4.25 W6c — record which sections were included. The audit
// trail proves mood data was never aggregated when the toggle
// was off (privacy contract).
sections,
},
});
+9 -5
View File
@@ -23,6 +23,7 @@ import { auditLog } from "@/lib/auth/audit";
import { apiError, getClientIp } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { toCSV, formatMeasurementsForExport } from "@/lib/export";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { NextRequest, NextResponse } from "next/server";
export const GET = apiHandler(async (request: NextRequest) => {
@@ -37,12 +38,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { since, until } = parseRange(request.url);
const where = buildWhere(user.id, { since, until });
const measurements = await prisma.measurement.findMany({
where,
orderBy: { measuredAt: "desc" },
});
const [measurements, userTz] = await Promise.all([
prisma.measurement.findMany({
where,
orderBy: { measuredAt: "desc" },
}),
resolveUserTimezone(user.id),
]);
const csv = toCSV(formatMeasurementsForExport(measurements));
const csv = toCSV(formatMeasurementsForExport(measurements, userTz));
await auditLog("user.export.measurements", {
userId: user.id,
+20 -16
View File
@@ -24,6 +24,7 @@ import {
formatMedicationsForExport,
formatIntakeEventsForExport,
} from "@/lib/export";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { NextRequest, NextResponse } from "next/server";
export const GET = apiHandler(async (request: NextRequest) => {
@@ -48,23 +49,26 @@ export const GET = apiHandler(async (request: NextRequest) => {
// without a matching migration); a wildcard `include: { schedules: true }`
// would 500 on the missing column. Pin the columns instead — the export
// shape doesn't need every future field anyway.
const medications = await prisma.medication.findMany({
where: { userId: user.id },
select: {
name: true,
dose: true,
active: true,
schedules: {
select: {
windowStart: true,
windowEnd: true,
label: true,
dose: true,
const [medications, userTz] = await Promise.all([
prisma.medication.findMany({
where: { userId: user.id },
select: {
name: true,
dose: true,
active: true,
schedules: {
select: {
windowStart: true,
windowEnd: true,
label: true,
dose: true,
},
},
},
},
orderBy: { createdAt: "desc" },
});
orderBy: { createdAt: "desc" },
}),
resolveUserTimezone(user.id),
]);
const sections: string[] = [
"# Medications",
@@ -91,7 +95,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
intakeCount = events.length;
sections.push("# Intake history");
sections.push(toCSV(formatIntakeEventsForExport(events)));
sections.push(toCSV(formatIntakeEventsForExport(events, userTz)));
}
const body = sections.join("\n\n");
+9 -5
View File
@@ -19,6 +19,7 @@ import { auditLog } from "@/lib/auth/audit";
import { apiError, getClientIp } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { toCSV, formatMoodEntriesForExport } from "@/lib/export";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { NextRequest, NextResponse } from "next/server";
export const GET = apiHandler(async (request: NextRequest) => {
@@ -50,12 +51,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (until) where.moodLoggedAt.lte = until;
}
const entries = await prisma.moodEntry.findMany({
where,
orderBy: { moodLoggedAt: "desc" },
});
const [entries, userTz] = await Promise.all([
prisma.moodEntry.findMany({
where,
orderBy: { moodLoggedAt: "desc" },
}),
resolveUserTimezone(user.id),
]);
const csv = toCSV(formatMoodEntriesForExport(entries));
const csv = toCSV(formatMoodEntriesForExport(entries, userTz));
await auditLog("user.export.mood", {
userId: user.id,
+8 -6
View File
@@ -11,6 +11,7 @@ import {
formatIntakeEventsForExport,
formatMoodEntriesForExport,
} from "@/lib/export";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { NextRequest, NextResponse } from "next/server";
/**
@@ -48,6 +49,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const userId = user.id;
const userTz = await resolveUserTimezone(userId);
const data: Record<string, unknown> = {};
@@ -58,8 +60,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
data.measurements =
format === "csv"
? toCSV(formatMeasurementsForExport(measurements))
: formatMeasurementsForExport(measurements);
? toCSV(formatMeasurementsForExport(measurements, userTz))
: formatMeasurementsForExport(measurements, userTz);
}
if (type === "medications" || type === "all") {
@@ -82,8 +84,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
data.intakeEvents =
format === "csv"
? toCSV(formatIntakeEventsForExport(events))
: formatIntakeEventsForExport(events);
? toCSV(formatIntakeEventsForExport(events, userTz))
: formatIntakeEventsForExport(events, userTz);
}
if (type === "mood" || type === "all") {
@@ -93,8 +95,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
data.moodEntries =
format === "csv"
? toCSV(formatMoodEntriesForExport(moodEntries))
: formatMoodEntriesForExport(moodEntries);
? toCSV(formatMoodEntriesForExport(moodEntries, userTz))
: formatMoodEntriesForExport(moodEntries, userTz);
}
await auditLog("export.download", {
+24 -2
View File
@@ -206,12 +206,22 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
// the new user message. v1.4.23 H4 — fold per-user prefs into the
// system-prompt prefix; the snapshot builder reads the same prefs
// separately so excluded metrics never even leave the DB.
//
// v1.4.25 W5 — `coachPrefs.defaultWindow` is the user's saved
// analysis-window preference. Merge it into the snapshot scope when
// the client didn't supply a per-conversation override; the override
// (header pill / sources rail) always wins. Keep the merge cheap so
// we don't accidentally widen narrow per-call scopes.
const prefsRow = await prisma.user.findUnique({
where: { id: userId },
select: { coachPrefsJson: true },
});
const coachPrefs = parseCoachPrefs(prefsRow?.coachPrefsJson);
const snapshot = await buildCoachSnapshot(userId, scope);
const effectiveScope =
scope?.window === undefined && coachPrefs.defaultWindow
? { ...(scope ?? {}), window: coachPrefs.defaultWindow }
: scope;
const snapshot = await buildCoachSnapshot(userId, effectiveScope);
const systemPrompt = getCoachSystemPrompt(locale, coachPrefs);
const window = buildHistoryWindow([
...priorTurns,
@@ -265,7 +275,19 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
firstStatus: err.attempts[0]?.httpStatus ?? null,
},
});
return streamProviderError({ code: "coach.provider.unavailable" });
// v1.4.25 W5 — distinguish provider rate-limit (every attempt
// landed on 429) from generic unavailability. The drawer's
// error-decoder surfaces the rate-limit copy with a warning
// toast instead of the generic provider-down message, so the
// user understands the limit is transient.
const allRateLimited =
err.attempts.length > 0 &&
err.attempts.every((a) => a.httpStatus === 429);
return streamProviderError({
code: allRateLimited
? "coach.provider.rate_limited"
: "coach.provider.unavailable",
});
}
throw err;
}
@@ -1,32 +0,0 @@
import { NextRequest } from "next/server";
import { apiSuccess } from "@/lib/api-response";
import {
generateGeneralStatusForUser,
resolveGeneralStatusLocale,
} from "@/lib/insights/general-status";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const localeParam = request.nextUrl.searchParams.get("locale");
const resolved = await resolveServerLocale({
request,
userLocale: user.locale ?? null,
override: localeParam,
});
const locale = resolveGeneralStatusLocale(resolved);
const result = await generateGeneralStatusForUser(user.id, {
locale,
force: false,
});
annotate({ action: { name: "insights.general-status" } });
return apiSuccess(result);
});
+16 -1
View File
@@ -2,6 +2,10 @@ import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { extractFeatures } from "@/lib/insights/features";
import {
detectGlp1Plateau,
buildGlp1PlateauPrompt,
} from "@/lib/insights/glp1-plateau";
import {
buildUserPrompt,
type ComparisonSnapshot,
@@ -266,12 +270,23 @@ export const POST = apiHandler(async (request: NextRequest) => {
// narrative is default-on whenever the toggle is on per research §7
// Q4 — the pulldown is the single affordance.
const comparisonSnapshot = await buildComparisonSnapshotForUser(userId);
const userPrompt = buildUserPrompt(
// v1.4.25 W4d — GLP-1 plateau detector. Appends a SYSTEM CONTEXT
// block to the user prompt when the active GLP-1 medication has been
// on a stable dose for ≥21 days with weight delta within ±0.5 kg.
// The block instructs the model to emit a glp1_plateau keyFinding
// framed observationally (no dose recommendation per GROUND RULE 14).
// Returns null when no GLP-1 is active OR no plateau condition;
// 99% of users land in the null branch and pay zero token cost.
const plateauContext = await detectGlp1Plateau(userId);
let userPrompt = buildUserPrompt(
featuresJson,
dbUser?.insightsPrivacyMode ?? "aggregated",
locale,
comparisonSnapshot ?? undefined,
);
if (plateauContext) {
userPrompt += buildGlp1PlateauPrompt(plateauContext, locale);
}
let result;
let workingProviderType: string;
+169
View File
@@ -0,0 +1,169 @@
/**
* v1.4.25 W4d — GLP-1 therapy-timeline aggregator.
*
* Returns a chronological merge of every GLP-1-relevant event the user
* has logged: dose changes, injections (with site if recorded),
* inventory events, and side-effect-tagged mood entries from the
* trailing 90 days. Used by /insights/medikamente's TherapyTimeline
* component. Web-only users without a GLP-1 medication get
* `hasGlp1: false` so the component hides cleanly.
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { NextRequest } from "next/server";
const DEFAULT_LIMIT = 60;
const SIDE_EFFECT_TAGS = new Set([
"nausea",
"constipation",
"diarrhea",
"fatigue",
"appetite-loss",
"heartburn",
"headache",
"übelkeit",
"verstopfung",
"durchfall",
"müdigkeit",
"appetitlosigkeit",
"sodbrennen",
"kopfschmerzen",
]);
interface TimelineEntry {
date: string;
kind: "dose-change" | "injection" | "inventory" | "side-effect";
medicationName?: string;
doseValue?: number;
doseUnit?: string;
doseDelta?: "up" | "down" | null;
note?: string | null;
injectionSite?: string | null;
inventoryDelta?: number;
reason?: string;
tags?: string[];
}
function parseTagList(raw: string | null): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed
.map((v) => (typeof v === "string" ? v.trim() : ""))
.filter(Boolean);
}
} catch {
/* fall through */
}
return raw
.split(",")
.map((p) => p.trim())
.filter(Boolean);
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const url = new URL(request.url);
const rawLimit = Number(url.searchParams.get("limit"));
const limit =
Number.isInteger(rawLimit) && rawLimit > 0 && rawLimit <= 200
? rawLimit
: DEFAULT_LIMIT;
const meds = await prisma.medication.findMany({
where: { userId: user.id, treatmentClass: "GLP1" },
include: {
doseChanges: { orderBy: { effectiveFrom: "asc" } },
intakeEvents: {
where: { takenAt: { not: null } },
orderBy: { takenAt: "desc" },
take: 50,
},
inventoryEvents: { orderBy: { occurredAt: "desc" }, take: 20 },
},
});
if (meds.length === 0) {
return apiSuccess({ hasGlp1: false, entries: [] });
}
const ninetyDaysAgo = new Date();
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);
const moods = await prisma.moodEntry.findMany({
where: { userId: user.id, moodLoggedAt: { gte: ninetyDaysAgo } },
select: { moodLoggedAt: true, tags: true },
orderBy: { moodLoggedAt: "desc" },
});
const entries: TimelineEntry[] = [];
for (const med of meds) {
const sortedChanges = [...med.doseChanges];
for (let i = 0; i < sortedChanges.length; i += 1) {
const dc = sortedChanges[i];
const prev = i > 0 ? sortedChanges[i - 1] : null;
let delta: "up" | "down" | null = null;
if (prev) {
if (dc.doseValue > prev.doseValue) delta = "up";
else if (dc.doseValue < prev.doseValue) delta = "down";
}
entries.push({
date: dc.effectiveFrom.toISOString(),
kind: "dose-change",
medicationName: med.name,
doseValue: dc.doseValue,
doseUnit: dc.doseUnit,
doseDelta: delta,
note: dc.note,
});
}
for (const intake of med.intakeEvents) {
if (!intake.takenAt) continue;
entries.push({
date: intake.takenAt.toISOString(),
kind: "injection",
medicationName: med.name,
injectionSite: intake.injectionSite,
});
}
for (const inv of med.inventoryEvents) {
entries.push({
date: inv.occurredAt.toISOString(),
kind: "inventory",
medicationName: med.name,
inventoryDelta: inv.delta,
reason: inv.reason,
});
}
}
// Side-effect days — collapse to one entry per day (most recent
// first) listing every recognised tag picked up that day.
const sideEffectByDay = new Map<string, string[]>();
for (const mood of moods) {
const tags = parseTagList(mood.tags).filter((t) =>
SIDE_EFFECT_TAGS.has(t.toLowerCase()),
);
if (tags.length === 0) continue;
const dayKey = mood.moodLoggedAt.toISOString().slice(0, 10);
const existing = sideEffectByDay.get(dayKey) ?? [];
for (const t of tags) {
if (!existing.includes(t)) existing.push(t);
}
sideEffectByDay.set(dayKey, existing);
}
for (const [day, tags] of sideEffectByDay) {
entries.push({
date: `${day}T12:00:00Z`,
kind: "side-effect",
tags,
});
}
entries.sort((a, b) => b.date.localeCompare(a.date));
return apiSuccess({ hasGlp1: true, entries: entries.slice(0, limit) });
});
+580 -162
View File
@@ -14,7 +14,7 @@ import {
import { calculateCompliance } from "@/lib/analytics/compliance";
import { pairByTimestamp } from "@/lib/analytics/correlations";
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
import { berlinDayKey } from "@/lib/analytics/berlin-day";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import {
classifyPulseByTarget,
getPersonalizedPulseTarget,
@@ -58,18 +58,38 @@ interface TargetItem {
classification: { category: string; color: string } | null;
source: string;
/**
* v1.4.22 C1 — sparkline support on the Zielwerte page. `points30d`
* is the last 30 days of values (chronological, oldest → newest) for
* the target's primary metric, rounded to one decimal. Derived metrics
* (BMI, BP-in-target, mood stability, glucose-by-context) skip the
* field when the page already renders a richer status surface.
* `deltaVsLastMonth` is the signed difference between the current
* 30-day average and the prior 30-day average (current − prior),
* null when either window has fewer than 3 points to keep the
* comparison honest.
* v1.4.25 W3e — consistency strip support. Berlin-tz day buckets over
* the last 7 days. `daysInRange7d` is the count of days whose mean
* reading landed inside the target's green band; `daysLogged7d` is
* how many of those days had at least one reading (so the strip can
* render filled / hollow / dimmed dots without ambiguity).
*
* `lastMetGoalAt` is the most recent Berlin-tz day whose mean reading
* was in the green band (ISO date YYYY-MM-DD), or null if the user
* has not hit goal in the last 30 days.
*
* `streakDays` is the count of consecutive Berlin days ending today
* where the day's mean was in the green band. Capped at 365.
*
* `insufficientData` is true when the target has fewer than 3
* readings over the last 30 days OR fewer than 1 day of data in the
* last 7 days. The page hides the strip + percentage when this is
* true.
*/
points30d?: number[] | null;
deltaVsLastMonth?: number | null;
daysInRange7d: number;
daysLogged7d: number;
daysInRange30d: number;
daysLogged30d: number;
lastMetGoalAt: string | null;
streakDays: number;
insufficientData: boolean;
/**
* v1.4.25 W3e — per-day classification of the last 7 Berlin-tz days
* for the consistency strip. Index 0 is six days ago, index 6 is
* today. `null` slots represent days with no readings; non-null
* slots represent days whose mean reading landed in the named band.
*/
consistency7d: ReadonlyArray<"in" | "near" | "out" | null>;
details?: {
medications?: Array<{
name: string;
@@ -79,11 +99,26 @@ interface TargetItem {
};
}
interface TargetPageSummary {
targetsMetThisWeek: number;
totalTargets: number;
streakHighlight: { metric: string; days: number } | null;
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const userId = user.id;
// v1.4.25 W7b — every per-day bucket key in this route resolves
// against the user's display timezone so a Pacific/Auckland user gets
// their Auckland-day streaks, their Auckland-day "last met goal" date,
// and their Auckland-day consistency strip. Falls back to
// Europe/Berlin when the column is somehow missing (defensive — the
// schema's NOT NULL default normally pins it).
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
const dayKey = (d: Date): string => userDayKey(d, userTz);
// Fetch user profile
const dbUser = await prisma.user.findUnique({
where: { id: userId },
@@ -112,28 +147,17 @@ export const GET = apiHandler(async () => {
];
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
// v1.4.22 C1 — second 30-day window (days 31–60) so each target card
// can render a "Δ vs. last month" caption alongside its sparkline.
// We fetch 60 days in one query to keep the route round-trip count
// identical to the previous version.
const sixtyDaysAgo = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000);
// Fetch all measurements in the last 60 days + the latest for each type
const windowedMeasurements = await prisma.measurement.findMany({
// Fetch all measurements in the last 30 days + the latest for each type
const recentMeasurements = await prisma.measurement.findMany({
where: {
userId,
type: { in: types },
measuredAt: { gte: sixtyDaysAgo },
measuredAt: { gte: thirtyDaysAgo },
},
orderBy: { measuredAt: "desc" },
select: { type: true, value: true, measuredAt: true },
});
const recentMeasurements = windowedMeasurements.filter(
(m) => m.measuredAt >= thirtyDaysAgo,
);
const priorMeasurements = windowedMeasurements.filter(
(m) => m.measuredAt < thirtyDaysAgo,
);
// Also get the absolute latest measurement per type (even if older than 30 days).
// Single grouped query replaces the previous N×findFirst loop — DISTINCT ON
@@ -188,50 +212,321 @@ export const GET = apiHandler(async () => {
return "stable";
}
// --------------------------------------------------------------------
// v1.4.25 W3e — consistency helper. Buckets readings by Berlin-tz day
// and classifies each day's mean against the target's green band.
// Single helper shared by every target type so the rule stays stable
// (mean reading per day → in-range / near-range / out-of-range).
// --------------------------------------------------------------------
const last7DayKeys: string[] = [];
for (let i = 6; i >= 0; i--) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
last7DayKeys.push(dayKey(d));
}
type DayBand = "in" | "near" | "out";
interface ConsistencyOutput {
daysInRange7d: number;
daysLogged7d: number;
daysInRange30d: number;
daysLogged30d: number;
lastMetGoalAt: string | null;
streakDays: number;
insufficientData: boolean;
consistency7d: ReadonlyArray<DayBand | null>;
}
const EMPTY_CONSISTENCY: ConsistencyOutput = {
daysInRange7d: 0,
daysLogged7d: 0,
daysInRange30d: 0,
daysLogged30d: 0,
lastMetGoalAt: null,
streakDays: 0,
insufficientData: true,
consistency7d: [null, null, null, null, null, null, null] as const,
};
/**
* v1.4.22 C1 — sparkline + delta helpers. The sparkline collapses
* the last 30 days of values for a measurement type into a daily
* mean series (chronological, oldest → newest). The delta is the
* signed difference between the current 30-day window's average
* and the prior 30-day window's average. Both helpers skip the
* computation when there are fewer than 3 points in a window so
* the comparison stays honest on cold-start accounts.
* Bucket a series of `{ measuredAt, value }` events by Berlin-tz day,
* classify each day's mean against the target band, and roll up the
* 7-day strip + 30-day stats + recency + streak.
*
* `classify(meanValue)` returns the band the day's mean falls into.
* Pass `null` to mean "no band — skip this day for classification".
*/
function sparklinePoints(type: MeasurementType): number[] | null {
const data = recentMeasurements
.filter((m) => m.type === type)
.sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime());
if (data.length < 3) return null;
function rollupConsistency(
events: ReadonlyArray<{ measuredAt: Date; value: number }>,
classify: (mean: number) => DayBand | null,
totalReadingsThreshold = 3,
): ConsistencyOutput {
if (events.length < totalReadingsThreshold) {
// Cold-start path. Still emit the per-day strip if we have any
// recent readings, but mark insufficient so the UI hides the %.
const partial = computeStrip(events, classify);
return {
...EMPTY_CONSISTENCY,
consistency7d: partial.consistency7d,
daysLogged7d: partial.daysLogged7d,
daysInRange7d: partial.daysInRange7d,
insufficientData: true,
};
}
// v1.4.22 W5 reconcile (Code-MED-3) — bucket by Berlin calendar
// day, not UTC. A 23:30-Berlin reading on Tuesday otherwise
// lands in Wednesday's UTC bucket and the sparkline drifts a
// day on cross-DST boundaries. Aligns with `berlinDayKey()`
// already used by the dashboard analytics route.
const byDay = new Map<string, { sum: number; count: number }>();
for (const m of data) {
const day = berlinDayKey(m.measuredAt);
for (const ev of events) {
const day = dayKey(ev.measuredAt);
const bucket = byDay.get(day) ?? { sum: 0, count: 0 };
bucket.sum += m.value;
bucket.sum += ev.value;
bucket.count += 1;
byDay.set(day, bucket);
}
return Array.from(byDay.values()).map(
(b) => Math.round((b.sum / b.count) * 10) / 10,
);
const stripPart = computeStrip(events, classify);
// 30-day stats: walk the last 30 Berlin-tz day keys.
let daysInRange30d = 0;
let daysLogged30d = 0;
for (let i = 29; i >= 0; i--) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
const key = dayKey(d);
const bucket = byDay.get(key);
if (!bucket) continue;
daysLogged30d += 1;
const mean = bucket.sum / bucket.count;
if (classify(mean) === "in") daysInRange30d += 1;
}
// lastMetGoalAt: walk back from today until a day is in range.
let lastMetGoalAt: string | null = null;
for (let i = 0; i < 30; i++) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
const key = dayKey(d);
const bucket = byDay.get(key);
if (!bucket) continue;
const mean = bucket.sum / bucket.count;
if (classify(mean) === "in") {
lastMetGoalAt = key;
break;
}
}
// streakDays: consecutive days ending today that are in-range. A
// day with no reading breaks the streak (the user did not meet the
// goal that day, even if today is in-range).
let streakDays = 0;
for (let i = 0; i < 365; i++) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
const key = dayKey(d);
const bucket = byDay.get(key);
if (!bucket) break;
const mean = bucket.sum / bucket.count;
if (classify(mean) !== "in") break;
streakDays += 1;
}
// insufficientData: < 3 readings total OR < 1 day in the last 7.
const insufficientData =
events.length < totalReadingsThreshold || stripPart.daysLogged7d < 1;
return {
daysInRange7d: stripPart.daysInRange7d,
daysLogged7d: stripPart.daysLogged7d,
daysInRange30d,
daysLogged30d,
lastMetGoalAt,
streakDays,
insufficientData,
consistency7d: stripPart.consistency7d,
};
}
function deltaVsLastMonth(type: MeasurementType): number | null {
const recentOfType = recentMeasurements.filter((m) => m.type === type);
const priorOfType = priorMeasurements.filter((m) => m.type === type);
if (recentOfType.length < 3 || priorOfType.length < 3) return null;
const recentAvg =
recentOfType.reduce((s, m) => s + m.value, 0) / recentOfType.length;
const priorAvg =
priorOfType.reduce((s, m) => s + m.value, 0) / priorOfType.length;
return Math.round((recentAvg - priorAvg) * 10) / 10;
function computeStrip(
events: ReadonlyArray<{ measuredAt: Date; value: number }>,
classify: (mean: number) => DayBand | null,
): {
consistency7d: ReadonlyArray<DayBand | null>;
daysInRange7d: number;
daysLogged7d: number;
} {
const byDay = new Map<string, { sum: number; count: number }>();
for (const ev of events) {
const day = dayKey(ev.measuredAt);
const bucket = byDay.get(day) ?? { sum: 0, count: 0 };
bucket.sum += ev.value;
bucket.count += 1;
byDay.set(day, bucket);
}
const strip: Array<DayBand | null> = [];
let daysInRange7d = 0;
let daysLogged7d = 0;
for (const key of last7DayKeys) {
const bucket = byDay.get(key);
if (!bucket) {
strip.push(null);
continue;
}
daysLogged7d += 1;
const mean = bucket.sum / bucket.count;
const band = classify(mean);
strip.push(band);
if (band === "in") daysInRange7d += 1;
}
return { consistency7d: strip, daysInRange7d, daysLogged7d };
}
/**
* Helper: classify a value against a target range with an orange band
* symmetrical to the green band (the same heuristic RangeBar uses
* client-side). When `min`/`max` is null we treat the input as
* unclassifiable and return null.
*/
function makeRangeClassifier(
range: { min: number; max: number } | null,
options?: { orangeMin?: number; orangeMax?: number },
): (mean: number) => DayBand | null {
if (!range) return () => null;
const span = range.max - range.min;
const orangeMin = options?.orangeMin ?? range.min - span * 0.3;
const orangeMax = options?.orangeMax ?? range.max + span * 0.3;
return (mean: number) => {
if (mean >= range.min && mean <= range.max) return "in";
if (mean >= orangeMin && mean <= orangeMax) return "near";
return "out";
};
}
/**
* Per-day BP-in-target classifier: a day is "in" if every BP pair on
* that day (sys+dia matched within 5 min) passes `isBpReadingInTarget`.
* "near" if at least one pair passes; "out" otherwise.
*
* Returns a function (day-key, dayEvents) → band. We pre-compute the
* by-day map once because the route already paginates BP twice (sys
* + dia) and we want to avoid quadratic work.
*/
function buildBpPairsByDay(): Map<string, DayBand | null> {
if (!bpRange) return new Map();
const sysByDay = new Map<string, Array<{ date: Date; value: number }>>();
const diaByDay = new Map<string, Array<{ date: Date; value: number }>>();
for (const m of recentMeasurements) {
if (m.type === "BLOOD_PRESSURE_SYS") {
const k = dayKey(m.measuredAt);
const arr = sysByDay.get(k) ?? [];
arr.push({ date: m.measuredAt, value: m.value });
sysByDay.set(k, arr);
} else if (m.type === "BLOOD_PRESSURE_DIA") {
const k = dayKey(m.measuredAt);
const arr = diaByDay.get(k) ?? [];
arr.push({ date: m.measuredAt, value: m.value });
diaByDay.set(k, arr);
}
}
const out = new Map<string, DayBand | null>();
const allKeys = new Set([...sysByDay.keys(), ...diaByDay.keys()]);
for (const key of allKeys) {
const sys = sysByDay.get(key) ?? [];
const dia = diaByDay.get(key) ?? [];
const pairs = pairByTimestamp(sys, dia, 5 * 60 * 1000);
if (pairs.length === 0) {
out.set(key, null);
continue;
}
const inCount = pairs.filter((p) =>
isBpReadingInTarget(p.a, p.b, bpRange),
).length;
if (inCount === pairs.length) out.set(key, "in");
else if (inCount > 0) out.set(key, "near");
else out.set(key, "out");
}
return out;
}
// We need bpRange before the per-target loop runs so the helpers above
// can use it; reorder the local so it's available.
const bpRange = age != null ? getBpTargetsByAge(age, gender) : null;
/**
* Roll up consistency for a target whose bands come from a pre-mapped
* `dayBandByKey` map (used for BP-in-target and medication compliance
* which have day-level classification rather than mean-classification).
*/
function rollupFromDayMap(
dayBandByKey: Map<string, DayBand | null>,
totalDaysThreshold = 3,
): ConsistencyOutput {
const loggedKeys = Array.from(dayBandByKey.entries()).filter(
([, v]) => v !== null,
);
if (loggedKeys.length < totalDaysThreshold) {
// Cold-start: still emit the strip.
const strip: Array<DayBand | null> = last7DayKeys.map(
(k) => dayBandByKey.get(k) ?? null,
);
const daysLogged7d = strip.filter((b) => b !== null).length;
const daysInRange7d = strip.filter((b) => b === "in").length;
return {
...EMPTY_CONSISTENCY,
consistency7d: strip,
daysLogged7d,
daysInRange7d,
insufficientData: true,
};
}
const strip: Array<DayBand | null> = last7DayKeys.map(
(k) => dayBandByKey.get(k) ?? null,
);
const daysLogged7d = strip.filter((b) => b !== null).length;
const daysInRange7d = strip.filter((b) => b === "in").length;
let daysInRange30d = 0;
let daysLogged30d = 0;
for (let i = 29; i >= 0; i--) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
const key = dayKey(d);
const band = dayBandByKey.get(key);
if (!band) continue;
daysLogged30d += 1;
if (band === "in") daysInRange30d += 1;
}
let lastMetGoalAt: string | null = null;
for (let i = 0; i < 30; i++) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
const key = dayKey(d);
if (dayBandByKey.get(key) === "in") {
lastMetGoalAt = key;
break;
}
}
let streakDays = 0;
for (let i = 0; i < 365; i++) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
const key = dayKey(d);
const band = dayBandByKey.get(key);
if (band === undefined || band === null) break;
if (band !== "in") break;
streakDays += 1;
}
return {
daysInRange7d,
daysLogged7d,
daysInRange30d,
daysLogged30d,
lastMetGoalAt,
streakDays,
insufficientData: loggedKeys.length < totalDaysThreshold,
consistency7d: strip,
};
}
// Memo: pre-compute the BP-pairs day map once.
const bpPairsByDay = buildBpPairsByDay();
// Build target items
const targets: TargetItem[] = [];
@@ -244,22 +539,28 @@ export const GET = apiHandler(async () => {
const cls = classifyBMI(bmi);
weightClassification = { category: cls.category, color: cls.color };
}
targets.push({
type: "WEIGHT",
label: "Weight",
current: latestByType.WEIGHT ?? null,
average30: avg30ByType.WEIGHT ?? null,
trend: computeTrend("WEIGHT"),
unit: "kg",
range: weightRange,
classification: weightClassification,
source: "WHO BMI",
points30d: sparklinePoints("WEIGHT"),
deltaVsLastMonth: deltaVsLastMonth("WEIGHT"),
});
{
const consistency = rollupConsistency(
recentMeasurements.filter((m) => m.type === "WEIGHT"),
makeRangeClassifier(weightRange),
);
targets.push({
type: "WEIGHT",
label: "Weight",
current: latestByType.WEIGHT ?? null,
average30: avg30ByType.WEIGHT ?? null,
trend: computeTrend("WEIGHT"),
unit: "kg",
range: weightRange,
classification: weightClassification,
source: "WHO BMI",
...consistency,
});
}
// 2. Blood Pressure (sys/dia combined)
const bpRange = age != null ? getBpTargetsByAge(age, gender) : null;
// `bpRange` is hoisted to the helpers block above so the BP-pairs
// day-map can use it; do not redeclare here.
let bpClassification: { category: string; color: string } | null = null;
if (
latestByType.BLOOD_PRESSURE_SYS != null &&
@@ -271,20 +572,26 @@ export const GET = apiHandler(async () => {
);
bpClassification = { category: cls.category, color: cls.color };
}
targets.push({
type: "BLOOD_PRESSURE",
label: "Blood pressure",
current: latestByType.BLOOD_PRESSURE_SYS ?? null,
average30: avg30ByType.BLOOD_PRESSURE_SYS ?? null,
trend: computeTrend("BLOOD_PRESSURE_SYS"),
unit: "mmHg",
range: bpRange ? { min: bpRange.sysLow, max: bpRange.sysHigh } : null,
classification: bpClassification,
source: "ESH 2023",
points30d: sparklinePoints("BLOOD_PRESSURE_SYS"),
deltaVsLastMonth: deltaVsLastMonth("BLOOD_PRESSURE_SYS"),
// Extra fields for diastolic
} as TargetItem);
{
// Per-day BP classifier reuses the sys+dia paired classifier from
// `bpPairsByDay`. This is the right semantic for "in target band"
// because a systolic in range with a diastolic out of range is
// still an out-of-target reading.
const consistency = rollupFromDayMap(bpPairsByDay);
targets.push({
type: "BLOOD_PRESSURE",
label: "Blood pressure",
current: latestByType.BLOOD_PRESSURE_SYS ?? null,
average30: avg30ByType.BLOOD_PRESSURE_SYS ?? null,
trend: computeTrend("BLOOD_PRESSURE_SYS"),
unit: "mmHg",
range: bpRange ? { min: bpRange.sysLow, max: bpRange.sysHigh } : null,
classification: bpClassification,
source: "ESH 2023",
...consistency,
// Extra fields for diastolic
} as TargetItem);
}
// 2b. Blood pressure target-hit rate over 30 days
if (bpRange) {
@@ -312,6 +619,9 @@ export const GET = apiHandler(async () => {
? Math.round((bpInTargetCount / bpPairs.length) * 100 * 10) / 10
: null;
// BP-in-target consistency mirrors the BP pair-by-day map above:
// a day is "in" when every pair on that day clears the BP target.
const inTargetConsistency = rollupFromDayMap(bpPairsByDay);
targets.push({
type: "BLOOD_PRESSURE_IN_TARGET",
label: "Blood pressure on target",
@@ -329,6 +639,7 @@ export const GET = apiHandler(async () => {
: { category: "Low", color: "#ff5555" }
: null,
source: "ESH 2023",
...inTargetConsistency,
});
}
@@ -339,19 +650,31 @@ export const GET = apiHandler(async () => {
const cls = classifyPulseByTarget(latestByType.PULSE, pulseTarget);
pulseClassification = { category: cls.category, color: cls.color };
}
targets.push({
type: "PULSE",
label: "Resting pulse",
current: latestByType.PULSE ?? null,
average30: avg30ByType.PULSE ?? null,
trend: computeTrend("PULSE"),
unit: "bpm",
range: { min: pulseTarget.greenMin, max: pulseTarget.greenMax },
classification: pulseClassification,
source: pulseTarget.source,
points30d: sparklinePoints("PULSE"),
deltaVsLastMonth: deltaVsLastMonth("PULSE"),
});
{
const pulseRange = {
min: pulseTarget.greenMin,
max: pulseTarget.greenMax,
};
const consistency = rollupConsistency(
recentMeasurements.filter((m) => m.type === "PULSE"),
makeRangeClassifier(pulseRange, {
orangeMin: pulseTarget.orangeMin,
orangeMax: pulseTarget.orangeMax,
}),
);
targets.push({
type: "PULSE",
label: "Resting pulse",
current: latestByType.PULSE ?? null,
average30: avg30ByType.PULSE ?? null,
trend: computeTrend("PULSE"),
unit: "bpm",
range: pulseRange,
classification: pulseClassification,
source: pulseTarget.source,
...consistency,
});
}
// 4. Sleep Duration
const sleepRange = getSleepDurationRange();
@@ -360,19 +683,24 @@ export const GET = apiHandler(async () => {
const cls = classifySleepDuration(latestByType.SLEEP_DURATION);
sleepClassification = { category: cls.category, color: cls.color };
}
targets.push({
type: "SLEEP_DURATION",
label: "Sleep duration",
current: latestByType.SLEEP_DURATION ?? null,
average30: avg30ByType.SLEEP_DURATION ?? null,
trend: computeTrend("SLEEP_DURATION"),
unit: "h",
range: sleepRange,
classification: sleepClassification,
source: "AASM/SRS",
points30d: sparklinePoints("SLEEP_DURATION"),
deltaVsLastMonth: deltaVsLastMonth("SLEEP_DURATION"),
});
{
const consistency = rollupConsistency(
recentMeasurements.filter((m) => m.type === "SLEEP_DURATION"),
makeRangeClassifier(sleepRange),
);
targets.push({
type: "SLEEP_DURATION",
label: "Sleep duration",
current: latestByType.SLEEP_DURATION ?? null,
average30: avg30ByType.SLEEP_DURATION ?? null,
trend: computeTrend("SLEEP_DURATION"),
unit: "h",
range: sleepRange,
classification: sleepClassification,
source: "AASM/SRS",
...consistency,
});
}
// 5. BMI (derived from weight + height)
if (heightCm) {
@@ -395,19 +723,17 @@ export const GET = apiHandler(async () => {
bmiClassification = { category: cls.category, color: cls.color };
}
// BMI sparkline / delta derive from the weight series \u2014 divide each
// point by height\u00B2 so the y-axis matches the BMI range bar above.
const weightPoints = sparklinePoints("WEIGHT");
const bmiPoints =
weightPoints != null
? weightPoints.map((v) => Math.round((v / heightSq) * 10) / 10)
: null;
const weightDelta = deltaVsLastMonth("WEIGHT");
const bmiDelta =
weightDelta != null
? Math.round((weightDelta / heightSq) * 10) / 10
: null;
// BMI consistency derives from the weight series: divide each
// bucket by height\u00B2 so the day's mean BMI is classified against
// the BMI range bar (18.5 \u2014 24.9).
const bmiRange = { min: 18.5, max: 24.9 };
const bmiEvents = recentMeasurements
.filter((m) => m.type === "WEIGHT")
.map((m) => ({ measuredAt: m.measuredAt, value: m.value / heightSq }));
const consistency = rollupConsistency(
bmiEvents,
makeRangeClassifier(bmiRange),
);
targets.push({
type: "BMI",
label: "BMI",
@@ -415,11 +741,10 @@ export const GET = apiHandler(async () => {
average30: avgBmi,
trend: computeTrend("WEIGHT"), // BMI trend follows weight trend
unit: "kg/m\u00B2",
range: { min: 18.5, max: 24.9 },
range: bmiRange,
classification: bmiClassification,
source: "WHO",
points30d: bmiPoints,
deltaVsLastMonth: bmiDelta,
...consistency,
});
}
@@ -435,19 +760,24 @@ export const GET = apiHandler(async () => {
// marathon consolidated them onto `getBodyFatTargetRange` (ACE
// fitness + acceptable bands).
const bodyFatRange = age != null ? getBodyFatTargetRange(gender) : null;
targets.push({
type: "BODY_FAT",
label: "Body fat",
current: latestByType.BODY_FAT ?? null,
average30: avg30ByType.BODY_FAT ?? null,
trend: computeTrend("BODY_FAT"),
unit: "%",
range: bodyFatRange,
classification: bodyFatClassification,
source: "ACE",
points30d: sparklinePoints("BODY_FAT"),
deltaVsLastMonth: deltaVsLastMonth("BODY_FAT"),
});
{
const consistency = rollupConsistency(
recentMeasurements.filter((m) => m.type === "BODY_FAT"),
makeRangeClassifier(bodyFatRange),
);
targets.push({
type: "BODY_FAT",
label: "Body fat",
current: latestByType.BODY_FAT ?? null,
average30: avg30ByType.BODY_FAT ?? null,
trend: computeTrend("BODY_FAT"),
unit: "%",
range: bodyFatRange,
classification: bodyFatClassification,
source: "ACE",
...consistency,
});
}
// 7. Activity Steps
const stepsRange = getStepsRange();
@@ -456,25 +786,31 @@ export const GET = apiHandler(async () => {
const cls = classifySteps(avg30ByType.ACTIVITY_STEPS);
stepsClassification = { category: cls.category, color: cls.color };
}
targets.push({
type: "ACTIVITY_STEPS",
label: "Steps/day",
current: latestByType.ACTIVITY_STEPS ?? null,
average30: avg30ByType.ACTIVITY_STEPS ?? null,
trend: computeTrend("ACTIVITY_STEPS"),
unit: "steps",
range: stepsRange,
classification: stepsClassification,
points30d: sparklinePoints("ACTIVITY_STEPS"),
deltaVsLastMonth: deltaVsLastMonth("ACTIVITY_STEPS"),
// WHO publishes activity *time* (150–300 min/wk moderate),
// not a step quota. The closest peer-reviewed dose-response
// for the 8 000–15 000 band is Saint-Maurice JAMA 2020. The
// AI prompts at src/lib/insights/prompts/{base-system,
// general-status}.ts already enforce this attribution; this
// surface label was the last "WHO" mislabel in the codebase.
source: "Saint-Maurice JAMA 2020",
});
{
const consistency = rollupConsistency(
recentMeasurements.filter((m) => m.type === "ACTIVITY_STEPS"),
makeRangeClassifier(stepsRange),
);
targets.push({
type: "ACTIVITY_STEPS",
label: "Steps/day",
current: latestByType.ACTIVITY_STEPS ?? null,
average30: avg30ByType.ACTIVITY_STEPS ?? null,
trend: computeTrend("ACTIVITY_STEPS"),
unit: "steps",
range: stepsRange,
classification: stepsClassification,
// WHO publishes activity *time* (150–300 min/wk moderate),
// not a step quota. The closest peer-reviewed dose-response
// for the 8 000–15 000 band is Saint-Maurice JAMA 2020. The
// AI prompts at src/lib/ai/prompts/base-system.ts and
// src/lib/ai/prompts/general-status.ts already enforce this
// attribution; this surface label was the last "WHO" mislabel
// in the codebase.
source: "Saint-Maurice JAMA 2020",
...consistency,
});
}
// 8. Medication Compliance (average across active medications)
const activeMedications = await prisma.medication.findMany({
@@ -563,6 +899,33 @@ export const GET = apiHandler(async () => {
) / 10
: complianceRate7;
// Per-day compliance: for each Berlin-tz day, classify as "in" if
// every scheduled dose that day was taken; "near" if at least one
// was taken; "out" if all were skipped/missed; null if there were
// no scheduled doses (the user's regimen excluded that day).
const dayCountsByKey = new Map<
string,
{ taken: number; expected: number }
>();
for (const event of intakeEvents) {
const key = dayKey(event.scheduledFor);
const cur = dayCountsByKey.get(key) ?? { taken: 0, expected: 0 };
cur.expected += 1;
if (event.takenAt && !event.skipped) cur.taken += 1;
dayCountsByKey.set(key, cur);
}
const dayBandByKey = new Map<string, DayBand | null>();
for (const [key, counts] of dayCountsByKey.entries()) {
if (counts.expected === 0) {
dayBandByKey.set(key, null);
continue;
}
const ratio = counts.taken / counts.expected;
if (ratio >= 0.99) dayBandByKey.set(key, "in");
else if (ratio >= 0.5) dayBandByKey.set(key, "near");
else dayBandByKey.set(key, "out");
}
const consistency = rollupFromDayMap(dayBandByKey);
targets.push({
type: "MEDICATION_COMPLIANCE",
label: "Medication compliance",
@@ -587,6 +950,7 @@ export const GET = apiHandler(async () => {
compliance30: medication.compliance30,
})),
},
...consistency,
});
}
@@ -641,6 +1005,15 @@ export const GET = apiHandler(async () => {
: { category: "Low", color: "#ff5555" }
: null;
const moodRange = { min: 3.5, max: 5 };
const moodEventsForConsistency = recentMood.map((entry) => ({
measuredAt: entry.moodLoggedAt,
value: entry.score,
}));
const moodConsistency = rollupConsistency(
moodEventsForConsistency,
makeRangeClassifier(moodRange, { orangeMin: 2, orangeMax: 5 }),
);
targets.push({
type: "MOOD_SCORE",
label: "Mood",
@@ -648,9 +1021,10 @@ export const GET = apiHandler(async () => {
average30: moodAvg30,
trend: moodTrend,
unit: "/ 5",
range: { min: 3.5, max: 5 },
range: moodRange,
classification: moodClassification,
source: "moodLog",
...moodConsistency,
});
// Mood stability: standard deviation of recent scores (lower = more stable)
@@ -668,6 +1042,10 @@ export const GET = apiHandler(async () => {
? { category: "Stable", color: "#f1fa8c" }
: { category: "Fluctuating", color: "#ff5555" };
// Mood stability is computed from the same recent-mood window;
// consistency mirrors MOOD_SCORE so the strip aligns with the
// user's logging cadence rather than introducing a synthetic
// "\u03C3 in range per day" rule that would be hard to reason about.
targets.push({
type: "MOOD_STABILITY",
label: "Mood stability",
@@ -678,6 +1056,7 @@ export const GET = apiHandler(async () => {
range: { min: 0, max: 0.5 },
classification: stabilityClassification,
source: "moodLog",
...moodConsistency,
});
}
}
@@ -744,6 +1123,15 @@ export const GET = apiHandler(async () => {
}
}
const consistency = rollupConsistency(
recent.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
makeRangeClassifier(
range,
eff.range
? { orangeMin: eff.range.orangeMin, orangeMax: eff.range.orangeMax }
: undefined,
),
);
targets.push({
type: `BLOOD_GLUCOSE_${ctx}`,
label: labelKeyByContext[ctx],
@@ -754,16 +1142,46 @@ export const GET = apiHandler(async () => {
range,
classification,
source: eff.isOverride ? "Custom" : "ADA 2024 / DDG",
...consistency,
});
}
// --------------------------------------------------------------------
// v1.4.25 W3e — page summary. "X of Y targets met this week" is the
// single most-asked-about glanceable answer. We define "met this
// week" as the target having `daysInRange7d >= 4` AND not flagged as
// `insufficientData`. The streak highlight surfaces the metric with
// the longest current ≥ 3 day streak; nothing renders client-side
// when no target hits the bar.
// --------------------------------------------------------------------
const targetsMetThisWeek = targets.filter(
(target) => !target.insufficientData && target.daysInRange7d >= 4,
).length;
let streakHighlight: TargetPageSummary["streakHighlight"] = null;
for (const target of targets) {
if (target.streakDays < 3) continue;
if (!streakHighlight || target.streakDays > streakHighlight.days) {
streakHighlight = { metric: target.type, days: target.streakDays };
}
}
const pageSummary: TargetPageSummary = {
targetsMetThisWeek,
totalTargets: targets.length,
streakHighlight,
};
annotate({
action: { name: "insights.targets" },
meta: { targetCount: targets.length },
meta: {
targetCount: targets.length,
targetsMetThisWeek,
streakHighlightMetric: streakHighlight?.metric ?? null,
},
});
return apiSuccess({
targets,
pageSummary,
// Extra diastolic data for BP display
bpDiastolic: {
current: latestByType.BLOOD_PRESSURE_DIA ?? null,
@@ -0,0 +1,119 @@
/**
* v1.4.25 W16c — PR detection enqueue coverage for
* `POST /api/measurements/batch`. The full per-entry envelope is
* exercised by the integration suite; this file checks the hook fires
* with the right `silent` flag for the small / large batch cases and
* stays out of the way when nothing landed.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
findMany: vi.fn(),
createMany: vi.fn(),
},
$transaction: vi.fn(async (fn: unknown) => {
if (typeof fn === "function") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (fn as any)(prisma as unknown as { measurement: unknown });
}
}),
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/jobs/pr-detection", () => ({
enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function makeRequest(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/measurements/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
function validStepEntry(externalId: string) {
return {
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
value: 1200,
unit: "count",
startDate: "2026-05-14T06:30:00.000Z",
endDate: "2026-05-14T06:45:00.000Z",
externalId,
};
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 60,
resetAt: Date.now() + 60_000,
});
vi.mocked(prisma.measurement.findMany).mockResolvedValue([]);
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 0 });
});
describe("POST /api/measurements/batch — PR detection enqueue (v1.4.25 W16c)", () => {
it("enqueues with silent=false for a small healthy batch", async () => {
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
const res = await POST(makeRequest({ entries: [validStepEntry("ext-a")] }));
expect(res.status).toBe(200);
expect(enqueuePrDetection).toHaveBeenCalledTimes(1);
expect(enqueuePrDetection).toHaveBeenCalledWith("user-1", {
silent: false,
});
});
it("sets silent=true once the batch crosses the 50-entry threshold", async () => {
const big = Array.from({ length: 51 }, (_, i) =>
validStepEntry(`ext-${i}`),
);
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 51 });
const res = await POST(makeRequest({ entries: big }));
expect(res.status).toBe(200);
expect(enqueuePrDetection).toHaveBeenCalledWith("user-1", {
silent: true,
});
});
});
+106 -30
View File
@@ -34,10 +34,30 @@ import {
import { withIdempotency } from "@/lib/idempotency";
import { mapAppleHealthEntry } from "@/lib/measurements/apple-health-mapping";
import { validateMeasurementRange } from "@/lib/validations/measurement";
import { deviceTypeEnum } from "@/lib/validations/source-priority";
import { checkRateLimit } from "@/lib/rate-limit";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
import { Prisma } from "@/generated/prisma/client";
// v1.4.25 W16c — historical-backfill threshold for PR push
// suppression. A batch larger than this fires the detection job with
// `silent: true` so a multi-year Apple Health backfill writes records
// without spamming the user with hundreds of pushes. Tuned generously
// — a healthy daily-sync batch is typically well under 50 entries.
const PR_DETECTION_SILENT_THRESHOLD = 50;
const MAX_BATCH_ENTRIES = 500;
// v1.4.25 W10 reconcile (security H-2): cap batch ingest at 60
// batches per user per minute. Healthy iOS sync drains its
// HealthKit observer queue in well under one batch per minute (the
// observer pattern coalesces), so 60/min × 500 entries/batch =
// 30 000 rows/min headroom — generous for legitimate use, and a
// hard stop for a leaked wildcard token trying to saturate the
// write pipeline.
const BATCH_RATE_LIMIT_MAX = 60;
const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
const batchEntrySchema = z.object({
hkIdentifier: z.string().min(1).max(120),
value: z.number().finite(),
@@ -47,6 +67,14 @@ const batchEntrySchema = z.object({
sleepStage: z.number().int().min(0).max(20).optional(),
externalId: z.string().min(1).max(120),
externalSourceVersion: z.string().min(1).max(120).optional(),
// v1.4.25 W8c — optional device-type tag. The iOS client maps the
// `HKDevice.model` of each sample to one of the canonical device
// classes (watch | band | ring | phone | scale | other | unknown).
// Backward-compatible: every pre-W8c iOS build skips the field and
// the row is stored with `deviceType = null`; the canonical picker
// treats null as `unknown` and only uses it as a tiebreaker when a
// ranked device-type coexists in the same daily bucket.
deviceType: deviceTypeEnum.nullable().optional(),
});
const batchPayloadSchema = z.object({
@@ -75,6 +103,25 @@ export const POST = apiHandler(withIdempotency<[NextRequest]>(postBatch));
async function postBatch(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
// v1.4.25 W10 reconcile (security H-2): per-user rate limit. Without
// it, a leaked wildcard iOS token can sustain unbounded writes
// (500 rows/batch × N batches/sec) and degrade the database for
// every other user on the host. 60 batches/min/user is generous
// for healthy iOS sync and tight enough that a misbehaving client
// bottoms out within a minute.
const rl = await checkRateLimit(
`measurements:batch:${user.id}`,
BATCH_RATE_LIMIT_MAX,
BATCH_RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
annotate({
action: { name: "measurement.batch.ingest" },
meta: { outcome: "rate_limited" },
});
return apiError("Too many batch submissions, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson<unknown>(request);
if (jsonError) return jsonError;
@@ -90,7 +137,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
(rawBody as { entries: unknown[] }).entries.length > MAX_BATCH_ENTRIES
) {
return apiError(`Batch exceeds the ${MAX_BATCH_ENTRIES}-entry limit`, 422, {
errorCode: "coach.batch.too_large",
errorCode: "measurement.batch.too_large",
});
}
@@ -156,6 +203,11 @@ async function postBatch(request: NextRequest): Promise<Response> {
externalId: entry.externalId,
externalSourceVersion: entry.externalSourceVersion ?? null,
sleepStage: mapped.sleepStage ?? null,
// v1.4.25 W8c — pass the iOS-supplied device-type through to
// the row. Stays null for pre-W8c iOS builds; the canonical
// picker treats null as `unknown` so legacy ingest keeps
// working without a server-side default.
deviceType: entry.deviceType ?? null,
},
});
}
@@ -223,44 +275,39 @@ async function postBatch(request: NextRequest): Promise<Response> {
}
});
// If `skipDuplicates` quietly absorbed a row (race with another
// batch), reconcile the per-entry status so the inserted +
// duplicate counts still equal `prepared.length`.
// v1.4.25 W10 reconcile (senior-dev H-1): the previous "stored
// vs not-stored" check was an effective no-op. Under standard
// Postgres semantics, `skipDuplicates` absorbs duplicate-key
// conflicts but the row is STILL present in the table (written
// by the other batch that won the race). So every row we
// attempted is in the DB after the call, and the
// `!stored.has(...)` branch never fired — leaving the aggregate
// `inserted` / `duplicate` counts inconsistent with the per-
// entry statuses under contention.
//
// Pragmatic fix: trust the `createMany` return count. The DB
// round-trip cannot distinguish a row this request wrote from
// a row the racing request wrote (the unique index sees both
// as the same key), so we cannot identify the SPECIFIC raced
// rows. What we CAN do is preserve count integrity for the
// iOS sync cursor: `insertedCount` is already the truth (it
// came from `createMany.count`); we only need to downgrade
// enough per-entry "inserted" statuses to "duplicate" so the
// per-entry envelope sums match. Order doesn't matter — the
// client checkpoints past both statuses, and the DB state for
// either outcome is identical (the row is now stored,
// single-copy).
const racedDuplicates = toInsert.length - insertedCount;
if (racedDuplicates > 0) {
// We can't tell *which* rows raced from the createMany return
// value — recheck the DB and downgrade matching `inserted`
// rows to `duplicate`. This is a rare path so the extra
// round-trip is acceptable.
const recheck = await prisma.measurement.findMany({
where: {
userId: user.id,
source: "APPLE_HEALTH",
OR: toInsert.map((row) => ({
type: row.type,
externalId: row.externalId as string,
})),
},
select: { type: true, externalId: true },
});
const stored = new Set(
recheck.map((row) => `${row.type}::${row.externalId}`),
);
// Anything `prepared` flagged as `inserted` but isn't in
// `stored` was never written and was raced by another batch —
// surface as duplicate.
let downgraded = 0;
for (const p of prepared) {
if (
results[p.index]?.status === "inserted" &&
!stored.has(`${p.row.type}::${p.row.externalId}`)
) {
if (downgraded >= racedDuplicates) break;
if (results[p.index]?.status === "inserted") {
results[p.index] = { index: p.index, status: "duplicate" };
duplicateCount += 1;
downgraded += 1;
}
}
insertedCount -= downgraded;
}
}
}
@@ -280,6 +327,35 @@ async function postBatch(request: NextRequest): Promise<Response> {
},
});
// v1.4.25 W16c — kick off PR detection for this user. We always
// enqueue when at least one row was written (or the batch had any
// measurements to consider) so a single off-day reading still gets
// evaluated; the warm-up gate inside the detector decides whether
// it's a record. Suppress push notifications for historical
// backfills above the silent threshold.
if (insertedCount > 0 || duplicateCount > 0) {
const silent = entries.length > PR_DETECTION_SILENT_THRESHOLD;
try {
await enqueuePrDetection(user.id, { silent });
await auditLog("personal_records.detection_enqueued", {
userId: user.id,
details: {
source: "measurement.batch",
batchSize: entries.length,
silent,
},
});
} catch (err) {
// Enqueue failure is non-fatal — the 30-minute fallback cron
// picks the user up in the next slot. Log so the operator
// notices repeated failures in Wide-Event traffic.
annotate({
action: { name: "personal_records.detection_enqueue_failed" },
meta: { error: err instanceof Error ? err.message : String(err) },
});
}
}
annotate({
action: { name: "measurement.batch.ingest" },
meta: {
@@ -0,0 +1,107 @@
/**
* `DELETE /api/measurements/by-external-ids` — iOS deletion reconciliation.
*
* HealthKit's API does not surface a stream of deleted UUIDs, so the
* iOS client performs a periodic 30-day window reconciliation: it pulls
* the current set of HealthKit sample UUIDs in the window and posts the
* externalIds the server holds that are no longer present. This endpoint
* accepts that batch-delete list and removes every matching row owned by
* the calling user.
*
* Idempotency contract:
* - Cross-user safety: rows owned by another user are silently skipped
* (Prisma's `deleteMany` with `userId` in the where-clause guarantees
* this), so a duplicate / replayed delete is a no-op rather than a
* 401.
* - Empty arrays return `{ deletedCount: 0 }` with 200 — the iOS
* client should not consider that a failure.
* - The batch cap mirrors the ingest path (`/api/measurements/batch`)
* so the two surfaces share the same operational ceiling.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
const MAX_BATCH_ENTRIES = 500;
const payloadSchema = z.object({
externalIds: z
.array(z.string().min(1).max(120))
.min(0)
.max(MAX_BATCH_ENTRIES),
});
export const DELETE = apiHandler(deleteByExternalIds);
async function deleteByExternalIds(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const { data: rawBody, error: jsonError } = await safeJson<unknown>(request);
if (jsonError) return jsonError;
// Distinguish "too many entries" from "validation failed" so the iOS
// client surfaces a useful diagnostic — same shape the ingest endpoint
// uses for its symmetric cap.
if (
typeof rawBody === "object" &&
rawBody !== null &&
"externalIds" in rawBody &&
Array.isArray((rawBody as { externalIds: unknown }).externalIds) &&
(rawBody as { externalIds: unknown[] }).externalIds.length >
MAX_BATCH_ENTRIES
) {
return apiError(`Batch exceeds the ${MAX_BATCH_ENTRIES}-entry limit`, 422, {
errorCode: "measurement.delete.too_large",
});
}
const parsed = payloadSchema.safeParse(rawBody);
if (!parsed.success) {
return apiError(parsed.error.issues[0]?.message ?? "Invalid batch", 422);
}
const { externalIds } = parsed.data;
if (externalIds.length === 0) {
annotate({
action: { name: "measurement.delete.by_external_ids" },
meta: { processed: 0, deleted: 0 },
});
return apiSuccess({ deletedCount: 0 });
}
// Prisma `deleteMany` with the userId predicate is the cross-user
// 404 guard: rows belonging to other users are simply not matched.
// No need for a pre-flight findMany — the unique index on
// `(userId, type, source, externalId)` keeps the matching set small
// even when the user has overlapping HealthKit history.
const result = await prisma.measurement.deleteMany({
where: {
userId: user.id,
externalId: { in: externalIds },
},
});
await auditLog("measurement.delete.by_external_ids", {
userId: user.id,
ipAddress: getClientIp(request),
details: { processed: externalIds.length, deleted: result.count },
});
annotate({
action: { name: "measurement.delete.by_external_ids" },
meta: { processed: externalIds.length, deleted: result.count },
});
return apiSuccess({ deletedCount: result.count });
}
+10 -1
View File
@@ -96,6 +96,10 @@ async function postMeasurement(request: NextRequest) {
notes: m.notes ?? null,
glucoseContext:
(m.glucoseContext as GlucoseContext | undefined) ?? null,
// v1.4.25 W10 reconcile (code-review M4): mirror the
// single-entry path so the multi-entry batch persists
// `deviceType` instead of silently dropping it.
deviceType: m.deviceType ?? null,
},
}),
),
@@ -127,7 +131,7 @@ async function postMeasurement(request: NextRequest) {
return apiError(parsed.error.issues[0].message, 422);
}
const { type, value, measuredAt, notes, source, glucoseContext } =
const { type, value, measuredAt, notes, source, glucoseContext, deviceType } =
parsed.data;
// Handle unique constraint violation
@@ -143,6 +147,11 @@ async function postMeasurement(request: NextRequest) {
measuredAt,
notes: notes ?? null,
glucoseContext: (glucoseContext as GlucoseContext | undefined) ?? null,
// v1.4.25 W10 reconcile (code-review M4): the deviceType column
// already accepts client metadata from the batch route. Mirror
// the behaviour here so single-entry POST persists the tag
// instead of silently dropping it.
deviceType: deviceType ?? null,
},
});
} catch (err) {
@@ -6,6 +6,7 @@ import { auditLog } from "@/lib/auth/audit";
import { hashToken } from "@/lib/auth/hmac";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
@@ -22,10 +23,9 @@ export const GET = apiHandler(
}
const { id } = await params;
const medication = await prisma.medication.findUnique({ where: { id } });
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404);
}
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const scope = medicationScope(id);
const now = new Date();
@@ -63,8 +63,12 @@ export const PUT = apiHandler(
}
const { id } = await params;
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const medication = await prisma.medication.findUnique({ where: { id } });
if (!medication || medication.userId !== user.id) {
if (!medication) {
return apiError("Medication not found", 404);
}
@@ -0,0 +1,168 @@
/**
* v1.4.25 W19e — cadence + compliance API route tests.
*
* Mirrors the W19d side-effect test fixture pattern: external
* dependencies are mocked at module boundaries (Prisma, session,
* logging transport), and each test pins one contract case.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findUnique: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
beforeEach(() => {
vi.resetAllMocks();
});
describe("GET /api/medications/[id]/cadence", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/cadence"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 404 when the medication is not owned by the caller", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "OTHER",
createdAt: new Date(),
schedules: [],
} as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/cadence"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(404);
});
it("returns 422 for an out-of-range days parameter", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
createdAt: new Date(),
schedules: [],
} as never);
const res = await GET(
new NextRequest(
"http://localhost/api/medications/med-1/cadence?days=9999",
),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("returns the timeline + chips for an owned medication", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
createdAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
schedules: [
{
id: "sch-1",
medicationId: "med-1",
windowStart: "08:00",
windowEnd: "09:00",
label: null,
dose: null,
daysOfWeek: null,
},
],
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([]);
const res = await GET(
new NextRequest(
"http://localhost/api/medications/med-1/cadence?days=14",
),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.windowDays).toBe(14);
expect(Array.isArray(json.data.timeline)).toBe(true);
expect(json.data.chips).toMatchObject({
adherenceRate: expect.anything(),
currentStreak: expect.any(Number),
longestStreak: expect.any(Number),
missedLast30: expect.any(Number),
});
});
it("defaults to a 30-day window when no `days` query param is supplied", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
createdAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
schedules: [],
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([]);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/cadence"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.windowDays).toBe(30);
});
it("returns a `null` next-dose when no schedule expands in the lookahead", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
createdAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
schedules: [],
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([]);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/cadence"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.next).toBeNull();
});
});
@@ -0,0 +1,144 @@
/**
* v1.4.25 W19e — GLP-1 cadence + compliance read endpoint.
*
* GET /api/medications/[id]/cadence?days=30
* - returns the schedule's 30-day (configurable) expected-vs-actual
* dose timeline plus the four compliance chip values that drive
* the detail-page section.
*
* Pure computation — no writes. The route delegates math to the
* `src/lib/medications/scheduling/{cadence,compliance}` pure modules
* so the chart, the chips, and the server agree dose-for-dose.
*
* Auth: cookie-session via requireAuth(); the medication is verified
* to belong to the caller before any read.
*/
import { NextRequest } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiError, apiSuccess } from "@/lib/api-response";
import {
buildCadenceTimeline,
computeNextDose,
} from "@/lib/medications/scheduling/cadence";
import { complianceChips } from "@/lib/medications/scheduling/compliance";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { resolveUserTimezone } from "@/lib/tz/resolver";
type RouteParams = { params: Promise<{ id: string }> };
const querySchema = z.object({
days: z.coerce.number().int().min(1).max(180).optional(),
});
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const med = await prisma.medication.findUnique({
where: { id },
include: { schedules: true },
});
if (!med) {
return apiError("Medication not found", 404);
}
const url = new URL(request.url);
const parsed = querySchema.safeParse({
days: url.searchParams.get("days") ?? undefined,
});
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const windowDays = parsed.data.days ?? 30;
const asOf = new Date();
// Pull intake events covering the window + a small buffer so a dose
// logged at the edge can still pair with its slot.
const bufferMs = 24 * 60 * 60 * 1000;
const from = new Date(asOf.getTime() - windowDays * 24 * 60 * 60 * 1000 - bufferMs);
const events = await prisma.medicationIntakeEvent.findMany({
where: {
medicationId: id,
userId: user.id,
scheduledFor: { gte: from },
},
select: { scheduledFor: true, takenAt: true, skipped: true },
orderBy: { scheduledFor: "asc" },
});
// The cadence-anchor is the medication's createdAt for stable
// intervalWeeks=N phasing. Falling back to `from` would re-anchor
// every window-size change, which would make a 30-day vs 90-day
// chart disagree on the bi-weekly grid.
const anchor = med.createdAt;
// v1.4.25 W21 Fix-O — resolve the user's IANA zone so the cadence
// helpers compute every local-day boundary, window-time application,
// and streak day-key in the user's clock instead of the host's
// system time. A Tokyo user reading the same medication gets the
// same chip values as a Berlin user — the host-relative day flip
// was a sneaky bug at the 08:00-Berlin / 16:00-Tokyo intersection.
const userTz = await resolveUserTimezone(user.id);
const timeline = buildCadenceTimeline(
med.schedules,
events,
asOf,
windowDays,
anchor,
userTz,
);
const chips = complianceChips(
med.schedules,
events,
asOf,
windowDays,
anchor,
userTz,
);
const next = computeNextDose(med.schedules, asOf, 14, anchor, userTz);
annotate({
action: {
name: "medication.cadence",
entity_type: "medication",
entity_id: id,
},
meta: {
window_days: windowDays,
slots: timeline.length,
adherence: chips.adherenceRate,
},
});
return apiSuccess({
windowDays,
anchorIso: anchor.toISOString(),
next: next
? {
windowStart: next.windowStart.toISOString(),
windowEnd: next.windowEnd.toISOString(),
scheduleIndex: next.scheduleIndex,
}
: null,
chips,
timeline: timeline.map((p) => ({
day: p.day.toISOString(),
windowStart: p.windowStart.toISOString(),
windowEnd: p.windowEnd.toISOString(),
scheduleIndex: p.scheduleIndex,
status: p.status,
})),
});
},
);
@@ -7,6 +7,7 @@ import {
classifyIntakeTiming,
} from "@/lib/analytics/compliance";
import type { DailyComplianceEntry } from "@/lib/analytics/compliance";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
type RouteParams = { params: Promise<{ id: string }> };
@@ -15,12 +16,16 @@ export const GET = apiHandler(
const { user } = await requireAuth();
const { id } = await params;
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const medication = await prisma.medication.findUnique({
where: { id },
include: { schedules: true },
});
if (!medication || medication.userId !== user.id) {
if (!medication) {
return apiError("Medication not found", 404);
}
@@ -0,0 +1,337 @@
/**
* v1.4.25 W21 Fix-K — GLP-1 convenience-route POST tests.
*
* Pins the sec-H1 hardening pass: Zod parse, 30/min/user rate-limit,
* `medication.glp1.update` audit row, bounded `doseValue` / `note` /
* `effectiveFrom`, XOR refinement between the doseChange / inventory
* branches. Mirrors the sibling inventory + side-effect fixture
* pattern so the four routes share one test shape.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findUnique: vi.fn() },
medicationDoseChange: { create: vi.fn() },
medicationInventoryEvent: { create: vi.fn() },
},
}));
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().mockResolvedValue({
allowed: true,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: vi.fn(() => ({ "X-RateLimit-Remaining": "0" })),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
const MED_OK = { id: "med-1", userId: "user-1" };
function jsonReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/medications/med-1/glp1", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 29,
resetAt: Date.now() + 60_000,
});
});
describe("POST /api/medications/[id]/glp1", () => {
it("rejects unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await POST(
jsonReq({
doseChange: {
effectiveFrom: "2026-05-14T08:00:00Z",
doseValue: 0.5,
doseUnit: "mg",
},
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 404 when the medication belongs to a different user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "OTHER",
} as never);
const res = await POST(
jsonReq({
doseChange: {
effectiveFrom: "2026-05-14T08:00:00Z",
doseValue: 0.5,
doseUnit: "mg",
},
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(404);
});
it("returns 422 when neither doseChange nor inventory is provided", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(jsonReq({}), {
params: Promise.resolve({ id: "med-1" }),
});
expect(res.status).toBe(422);
});
it("returns 422 when both doseChange and inventory are provided", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq({
doseChange: {
effectiveFrom: "2026-05-14T08:00:00Z",
doseValue: 0.5,
doseUnit: "mg",
},
inventory: { delta: 1, reason: "refill" },
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("returns 422 when doseValue is negative", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq({
doseChange: {
effectiveFrom: "2026-05-14T08:00:00Z",
doseValue: -1,
doseUnit: "mg",
},
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("returns 422 when doseValue is NaN (not a finite number)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
// JSON cannot encode NaN, but Number.NaN reaches the schema if the
// client base64-encodes or hand-crafts the body. Simulate the
// post-decode shape by passing `null` (which fails the finite gate)
// and `1e400` (which JSON renders as null too) via the same path.
const res = await POST(
jsonReq({
doseChange: {
effectiveFrom: "2026-05-14T08:00:00Z",
doseValue: null,
doseUnit: "mg",
},
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("returns 422 when doseValue exceeds 100 mg cap", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq({
doseChange: {
effectiveFrom: "2026-05-14T08:00:00Z",
doseValue: 250,
doseUnit: "mg",
},
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("returns 422 when note exceeds 500 chars", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq({
doseChange: {
effectiveFrom: "2026-05-14T08:00:00Z",
doseValue: 0.5,
doseUnit: "mg",
note: "x".repeat(501),
},
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("returns 422 when effectiveFrom predates 2020", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq({
doseChange: {
effectiveFrom: "1995-01-01T00:00:00Z",
doseValue: 0.5,
doseUnit: "mg",
},
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("returns 422 when inventory delta is zero", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq({ inventory: { delta: 0, reason: "noop" } }),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("returns 429 when the rate limit is exceeded", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await POST(
jsonReq({
doseChange: {
effectiveFrom: "2026-05-14T08:00:00Z",
doseValue: 0.5,
doseUnit: "mg",
},
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(429);
expect(res.headers.get("X-RateLimit-Remaining")).toBe("0");
});
it("creates a doseChange row and writes a medication.glp1.update audit entry", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationDoseChange.create).mockResolvedValue({
id: "dc-new",
effectiveFrom: new Date("2026-05-14T08:00:00Z"),
doseValue: 0.5,
doseUnit: "mg",
note: null,
} as never);
const res = await POST(
jsonReq({
doseChange: {
effectiveFrom: "2026-05-14T08:00:00Z",
doseValue: 0.5,
doseUnit: "mg",
},
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(201);
expect(prisma.medicationDoseChange.create).toHaveBeenCalledWith({
data: expect.objectContaining({
medicationId: "med-1",
effectiveFrom: new Date("2026-05-14T08:00:00Z"),
doseValue: 0.5,
doseUnit: "mg",
note: null,
}),
});
expect(auditLog).toHaveBeenCalledWith(
"medication.glp1.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
medicationId: "med-1",
kind: "doseChange",
doseChangeId: "dc-new",
doseValue: 0.5,
doseUnit: "mg",
}),
}),
);
});
it("creates an inventory row and writes a medication.glp1.update audit entry", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationInventoryEvent.create).mockResolvedValue({
id: "iv-new",
delta: 1,
reason: "refill",
} as never);
const res = await POST(
jsonReq({ inventory: { delta: 1, reason: "refill" } }),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(201);
expect(prisma.medicationInventoryEvent.create).toHaveBeenCalledWith({
data: { medicationId: "med-1", delta: 1, reason: "refill" },
});
expect(auditLog).toHaveBeenCalledWith(
"medication.glp1.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
medicationId: "med-1",
kind: "inventory",
inventoryEventId: "iv-new",
delta: 1,
reason: "refill",
}),
}),
);
});
});
+220
View File
@@ -0,0 +1,220 @@
/**
* v1.4.25 W4d — GLP-1 medication details endpoint.
*
* Returns per-medication extras the GLP-1 card variant + the dashboard
* tile + the doctor-report PDF section read: chronological dose-change
* history, the last 12 injection events (with optional site
* rotation data), and the running pen-inventory math. The base
* /api/medications/[id] GET is unchanged so v1.4.24 consumers keep
* working untouched.
*
* v1.4.25 W21 Fix-K — POST hardened with Zod parse, audit-log, 30/min
* rate-limit, finite-number guard on `doseValue`, length cap on `note`,
* and sane bounds on `effectiveFrom`. Mirrors the sibling
* `/inventory` + `/side-effects` route shape.
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { glp1PostBodySchema } from "@/lib/validations/medication";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
const LOW_STOCK_DOSE_THRESHOLD = 4;
const POST_RATE_LIMIT = 30;
const POST_WINDOW_MS = 60_000;
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const medication = await prisma.medication.findUnique({
where: { id },
include: {
doseChanges: { orderBy: { effectiveFrom: "asc" } },
inventoryEvents: { orderBy: { occurredAt: "asc" } },
intakeEvents: {
where: { takenAt: { not: null } },
orderBy: { takenAt: "desc" },
take: 12,
select: { takenAt: true, injectionSite: true },
},
schedules: true,
},
});
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404);
}
// Inventory math: running sum of every inventory event. Negative
// when more doses were consumed than purchased — surfaces as a
// low-stock warning rather than a hard error so the UI keeps
// working when the user backdates an inventory event.
let inventory: {
pensRemaining: number | null;
dosesRemaining: number | null;
weeksOfSupply: number | null;
lowStock: boolean;
} | null = null;
if (medication.dosesPerUnit && medication.inventoryEvents.length > 0) {
const pens = medication.inventoryEvents.reduce(
(sum, ev) => sum + ev.delta,
0,
);
const pensRemaining = Math.max(0, pens);
const dosesRemaining = pensRemaining * medication.dosesPerUnit;
// Approximate weeks-of-supply assuming weekly cadence (the
// canonical GLP-1 case). The Coach snapshot does the same.
const weeksOfSupply = dosesRemaining;
const lowStock = dosesRemaining < LOW_STOCK_DOSE_THRESHOLD;
inventory = {
pensRemaining,
dosesRemaining,
weeksOfSupply,
lowStock,
};
}
return apiSuccess({
doseChanges: medication.doseChanges.map((dc) => ({
id: dc.id,
effectiveFrom: dc.effectiveFrom.toISOString(),
doseValue: dc.doseValue,
doseUnit: dc.doseUnit,
note: dc.note,
})),
recentIntakes: medication.intakeEvents.map((iv) => ({
takenAt: iv.takenAt ? iv.takenAt.toISOString() : null,
injectionSite: iv.injectionSite,
})),
inventory,
});
},
);
/**
* POST creates a new dose change OR inventory event (the body picks
* one — caller specifies which). Convenience endpoint so the
* medication-card disclosure can write rows without dispatching to
* /api/medications/[id]/dose-change + /api/medications/[id]/inventory
* separately for the v1.4.25 cut.
*
* Validation: `glp1PostBodySchema` (XOR of `doseChange` / `inventory`),
* with finite-number guards on `doseValue` + `delta`, bounded `note`
* + `reason`, and a ±5-year window on `effectiveFrom`.
* Rate-limit: 30/min/user (same as sibling POST routes).
* Audit: `medication.glp1.update` for every successful mutation.
*/
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
// Per-user POST rate-limit — matches the 30/min sibling routes
// (inventory, side-effects). Generous for a hand-driven session,
// tight enough to cut off the spam case.
const rl = await checkRateLimit(
`medication-glp1:post:${user.id}`,
POST_RATE_LIMIT,
POST_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
});
}
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = glp1PostBodySchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const ip = getClientIp(request);
if (parsed.data.doseChange) {
const { effectiveFrom, doseValue, doseUnit, note } =
parsed.data.doseChange;
const created = await prisma.medicationDoseChange.create({
data: {
medicationId: id,
effectiveFrom,
doseValue,
doseUnit,
note: note ?? null,
},
});
await auditLog("medication.glp1.update", {
userId: user.id,
ipAddress: ip,
details: {
medicationId: id,
kind: "doseChange",
doseChangeId: created.id,
doseValue,
doseUnit,
},
});
annotate({
action: {
name: "medication.glp1.doseChange.create",
entity_type: "medication_dose_change",
entity_id: created.id,
},
meta: { medication_id: id, doseValue, doseUnit },
});
return apiSuccess({ doseChange: created }, 201);
}
// The schema's XOR refinement guarantees `inventory` is present
// when `doseChange` is not, so the non-null assertion is safe.
const { delta, reason } = parsed.data.inventory!;
const created = await prisma.medicationInventoryEvent.create({
data: { medicationId: id, delta, reason },
});
await auditLog("medication.glp1.update", {
userId: user.id,
ipAddress: ip,
details: {
medicationId: id,
kind: "inventory",
inventoryEventId: created.id,
delta,
reason,
},
});
annotate({
action: {
name: "medication.glp1.inventory.create",
entity_type: "medication_inventory_event",
entity_id: created.id,
},
meta: { medication_id: id, delta },
});
return apiSuccess({ inventory: created }, 201);
},
);
+54 -10
View File
@@ -13,6 +13,8 @@ import {
listIntakeEventsSchema,
} from "@/lib/validations/medication";
import { withIdempotency } from "@/lib/idempotency";
import { consumeOneDose } from "@/lib/medications/inventory/service";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
@@ -25,10 +27,9 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
const { user } = await requireAuth();
const { id } = await params;
const medication = await prisma.medication.findUnique({ where: { id } });
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404);
}
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const { data: body, error: jsonError } = await safeJson(request);
@@ -94,10 +95,45 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
: []),
]);
// v1.4.25 W19b — pen-inventory dose decrement. Only fires for
// non-skipped intakes; a skipped event is not a consumption event.
// No-op when the medication has no tracked pens (most non-GLP-1
// meds). Failures here must never block the intake write, so
// errors are swallowed and logged — the intake is the source of
// truth, the inventory is an opt-in companion.
let inventoryOutcome: Awaited<ReturnType<typeof consumeOneDose>> = null;
if (!skipped) {
try {
inventoryOutcome = await consumeOneDose({
userId: user.id,
medicationId: id,
intakeAt: event.takenAt ?? event.scheduledFor,
});
} catch (err) {
annotate({
action: { name: "medication.inventory.consume_error" },
meta: {
medication_id: id,
message: err instanceof Error ? err.message : String(err),
},
});
}
}
await auditLog("medication.intake", {
userId: user.id,
ipAddress: getClientIp(request),
details: { medicationId: id, eventId: event.id, skipped },
details: {
medicationId: id,
eventId: event.id,
skipped,
...(inventoryOutcome
? {
inventoryItemId: inventoryOutcome.itemId,
inventoryChange: inventoryOutcome.change,
}
: {}),
},
});
annotate({
@@ -106,7 +142,16 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
entity_type: "intake_event",
entity_id: event.id,
},
meta: { medication_id: id, skipped },
meta: {
medication_id: id,
skipped,
...(inventoryOutcome
? {
inventory_item_id: inventoryOutcome.itemId,
inventory_change: inventoryOutcome.change,
}
: {}),
},
});
return apiSuccess(event, 201);
@@ -117,10 +162,9 @@ export const GET = apiHandler(
const { user } = await requireAuth();
const { id } = await params;
const medication = await prisma.medication.findUnique({ where: { id } });
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404);
}
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const searchParams = Object.fromEntries(request.nextUrl.searchParams);
const parsed = listIntakeEventsSchema.safeParse(searchParams);
@@ -0,0 +1,183 @@
/**
* v1.4.25 W19b — per-pen inventory item operations.
*
* PATCH /api/medications/[id]/inventory/[itemId]
* - mark-as-first-use (starts the 30-day clock manually)
* - mark-as-used-up (terminal — operator override when a pen is
* physically discarded but the dose ledger hasn't reached zero)
* - update printed expiry (e.g. carton label correction)
* - update notes
*
* DELETE /api/medications/[id]/inventory/[itemId]
* - hard delete; admin / cleanup path. The audit log captures
* the before-state so a row can be reconstructed if needed.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { updateInventoryItemSchema } from "@/lib/validations/medication";
import {
computeExpiresAt,
computeInventoryState,
} from "@/lib/medications/inventory/state-machine";
type RouteParams = { params: Promise<{ id: string; itemId: string }> };
async function loadOwnedItem(
medicationId: string,
itemId: string,
userId: string,
) {
const item = await prisma.medicationInventoryItem.findUnique({
where: { id: itemId },
});
if (!item || item.userId !== userId || item.medicationId !== medicationId) {
return null;
}
return item;
}
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id, itemId } = await params;
const existing = await loadOwnedItem(id, itemId, user.id);
if (!existing) return apiError("Inventory item not found", 404);
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = updateInventoryItemSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const { markAsFirstUseAt, markAsUsedUp, printedExpiry, notes } =
parsed.data;
// Compose the next-row shape. Each mutation field is optional and
// commutative — applying them in any order produces the same row.
let nextFirstUseAt = existing.firstUseAt;
let nextState = existing.state;
let nextDosesRemaining = existing.dosesRemaining;
let nextPrintedExpiry = existing.printedExpiry;
if (markAsFirstUseAt) {
// The clock can be set manually if the user opened the pen
// without logging an intake event.
nextFirstUseAt = markAsFirstUseAt;
if (nextState === "ACTIVE") nextState = "IN_USE";
}
if (printedExpiry !== undefined) {
nextPrintedExpiry = printedExpiry;
}
if (markAsUsedUp === true) {
nextDosesRemaining = 0;
nextState = "USED_UP";
}
const nextExpiresAt = computeExpiresAt(nextFirstUseAt, nextPrintedExpiry);
// Re-run the canonical state machine over the composed next-row
// view. The clause-by-clause updates above set the state ad-hoc
// (`ACTIVE → IN_USE` on first use, `* → USED_UP` on mark-as-used),
// but a back-dated `markAsFirstUseAt` whose 30-day window already
// elapsed should land at EXPIRED, not IN_USE. The state machine is
// pure and idempotent — running it once more with the composed view
// collapses every edge case onto the same decision tree the intake
// hook and the daily expire cron already share. USED_UP is terminal
// (dosesRemaining === 0 ⇒ USED_UP wins at clause 1), so the manual
// override remains sticky.
nextState = computeInventoryState(
{
state: nextState,
dosesTotal: existing.dosesTotal,
dosesRemaining: nextDosesRemaining,
firstUseAt: nextFirstUseAt,
printedExpiry: nextPrintedExpiry,
},
Date.now(),
);
const updated = await prisma.medicationInventoryItem.update({
where: { id: itemId },
data: {
state: nextState,
firstUseAt: nextFirstUseAt,
dosesRemaining: nextDosesRemaining,
printedExpiry: nextPrintedExpiry,
expiresAt: nextExpiresAt,
notes: notes === undefined ? existing.notes : notes,
},
});
await auditLog("medication.inventory.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
itemId,
prevState: existing.state,
nextState: updated.state,
},
});
annotate({
action: {
name: "medication.inventory.update",
entity_type: "inventory_item",
entity_id: itemId,
},
meta: { medication_id: id, prev: existing.state, next: updated.state },
});
return apiSuccess(updated);
},
);
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id, itemId } = await params;
const existing = await loadOwnedItem(id, itemId, user.id);
if (!existing) return apiError("Inventory item not found", 404);
await prisma.medicationInventoryItem.delete({ where: { id: itemId } });
await auditLog("medication.inventory.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
itemId,
finalState: existing.state,
dosesRemaining: existing.dosesRemaining,
},
});
annotate({
action: {
name: "medication.inventory.delete",
entity_type: "inventory_item",
entity_id: itemId,
},
meta: { medication_id: id },
});
return apiSuccess({ id: itemId, deleted: true });
},
);
@@ -0,0 +1,351 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findUnique: vi.fn() },
medicationInventoryItem: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
},
}));
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().mockResolvedValue({
allowed: true,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, POST } from "../route";
import { PATCH, DELETE } from "../[itemId]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
const MED_OK = { id: "med-1", userId: "user-1" };
function jsonReq(url: string, body: unknown, method = "POST"): NextRequest {
return new NextRequest(url, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 29,
resetAt: Date.now() + 60_000,
});
});
describe("GET /api/medications/[id]/inventory", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/inventory"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 404 when the medication is not owned by the caller", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "OTHER",
} as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/inventory"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(404);
});
it("returns the inventory list for the owned medication", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationInventoryItem.findMany).mockResolvedValue([
{ id: "inv-1", state: "ACTIVE" },
{ id: "inv-2", state: "IN_USE" },
] as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/inventory"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { items: unknown[]; meta: { total: number } };
};
expect(body.data.items).toHaveLength(2);
expect(body.data.meta.total).toBe(2);
});
});
describe("POST /api/medications/[id]/inventory", () => {
it("rejects unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
dosesTotal: 4,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 422 when dosesTotal is missing or invalid", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
dosesTotal: 0,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("creates an ACTIVE pen with computed expiresAt", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationInventoryItem.create).mockResolvedValue({
id: "inv-new",
state: "ACTIVE",
} as never);
const printed = "2027-06-01T00:00:00Z";
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
dosesTotal: 4,
printedExpiry: printed,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(201);
expect(prisma.medicationInventoryItem.create).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: "user-1",
medicationId: "med-1",
state: "ACTIVE",
dosesTotal: 4,
dosesRemaining: 4,
firstUseAt: null,
printedExpiry: new Date(printed),
expiresAt: new Date(printed),
}),
});
});
it("returns 429 when the rate limit is exceeded", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
dosesTotal: 4,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(429);
});
});
describe("PATCH /api/medications/[id]/inventory/[itemId]", () => {
const existingActive = {
id: "inv-1",
userId: "user-1",
medicationId: "med-1",
state: "ACTIVE" as const,
dosesTotal: 4,
dosesRemaining: 4,
firstUseAt: null,
printedExpiry: null,
purchasedAt: null,
notes: null,
expiresAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
it("flips ACTIVE → IN_USE on markAsFirstUseAt", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue(
existingActive as never,
);
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
...existingActive,
state: "IN_USE",
} as never);
const intake = "2026-05-14T12:00:00Z";
const res = await PATCH(
jsonReq(
"http://localhost/api/medications/med-1/inventory/inv-1",
{ markAsFirstUseAt: intake },
"PATCH",
),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(200);
const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0];
expect(updateCall.data).toMatchObject({
state: "IN_USE",
firstUseAt: new Date(intake),
});
});
it("flips state to USED_UP and zeros remaining on markAsUsedUp", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
...existingActive,
state: "IN_USE",
firstUseAt: new Date("2026-05-01"),
dosesRemaining: 2,
} as never);
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
...existingActive,
state: "USED_UP",
dosesRemaining: 0,
} as never);
const res = await PATCH(
jsonReq(
"http://localhost/api/medications/med-1/inventory/inv-1",
{ markAsUsedUp: true },
"PATCH",
),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(200);
const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0];
expect(updateCall.data).toMatchObject({
state: "USED_UP",
dosesRemaining: 0,
});
});
it("flips ACTIVE → EXPIRED when markAsFirstUseAt is more than 30 days back-dated", async () => {
// Regression for the W19b state-machine bypass: composing the next
// state by hand sent ACTIVE → IN_USE on any first-use stamp, but a
// back-dated stamp whose 30-day window already lapsed must land at
// EXPIRED. Re-running `computeInventoryState` after the composed
// view closes the gap.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue(
existingActive as never,
);
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
...existingActive,
state: "EXPIRED",
} as never);
// 45 days in the past — well past the 30-day in-use window.
const backdated = new Date(
Date.now() - 45 * 24 * 60 * 60 * 1000,
).toISOString();
const res = await PATCH(
jsonReq(
"http://localhost/api/medications/med-1/inventory/inv-1",
{ markAsFirstUseAt: backdated },
"PATCH",
),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(200);
const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0];
expect(updateCall.data).toMatchObject({
state: "EXPIRED",
firstUseAt: new Date(backdated),
});
});
it("rejects 404 when the item belongs to another user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
...existingActive,
userId: "OTHER",
} as never);
const res = await PATCH(
jsonReq(
"http://localhost/api/medications/med-1/inventory/inv-1",
{ markAsUsedUp: true },
"PATCH",
),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(404);
});
});
describe("DELETE /api/medications/[id]/inventory/[itemId]", () => {
it("hard-deletes the row and audit-logs the final state", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
id: "inv-1",
userId: "user-1",
medicationId: "med-1",
state: "EXPIRED",
dosesRemaining: 2,
} as never);
vi.mocked(prisma.medicationInventoryItem.delete).mockResolvedValue({
id: "inv-1",
} as never);
const res = await DELETE(
new NextRequest("http://localhost/api/medications/med-1/inventory/inv-1", {
method: "DELETE",
}),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(200);
expect(prisma.medicationInventoryItem.delete).toHaveBeenCalledWith({
where: { id: "inv-1" },
});
});
});
@@ -0,0 +1,123 @@
/**
* v1.4.25 W19b — pen / vial inventory CRUD.
*
* GET /api/medications/[id]/inventory — list (user-scoped, all states)
* POST /api/medications/[id]/inventory — register a new pen / vial
*
* Per-item operations live on the `[itemId]` sub-route.
*
* Auth: cookie-session via requireAuth(); the medication is verified
* to belong to the caller before any read/write touches inventory.
* Rate-limit 30/min/user on the POST path (creation is the only
* surface that can spam writes; GET is read-only and benign).
* Audit-log every mutation with the affected itemId.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { createInventoryItemSchema } from "@/lib/validations/medication";
import { buildCreateInventoryInput } from "@/lib/medications/inventory/service";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
type RouteParams = { params: Promise<{ id: string }> };
const POST_RATE_LIMIT = 30;
const POST_WINDOW_MS = 60_000;
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const items = await prisma.medicationInventoryItem.findMany({
where: { userId: user.id, medicationId: id },
orderBy: [{ state: "asc" }, { expiresAt: "asc" }, { createdAt: "asc" }],
});
annotate({
action: { name: "medication.inventory.list" },
meta: { medication_id: id, total: items.length },
});
return apiSuccess({ items, meta: { total: items.length } });
},
);
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
// Per-user POST rate-limit — 30/min is generous for normal pen
// registrations but cuts off the spam case.
const rl = await checkRateLimit(
`medication-inventory:post:${user.id}`,
POST_RATE_LIMIT,
POST_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
});
}
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = createInventoryItemSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const { dosesTotal, printedExpiry, purchasedAt, notes } = parsed.data;
const created = await prisma.medicationInventoryItem.create({
data: buildCreateInventoryInput({
userId: user.id,
medicationId: id,
dosesTotal,
printedExpiry: printedExpiry ?? null,
purchasedAt: purchasedAt ?? null,
notes: notes ?? null,
}),
});
await auditLog("medication.inventory.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
itemId: created.id,
dosesTotal,
},
});
annotate({
action: {
name: "medication.inventory.create",
entity_type: "inventory_item",
entity_id: created.id,
},
meta: { medication_id: id },
});
return apiSuccess(created, 201);
},
);
@@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { phaseConfigSchema } from "@/lib/validations/phase-config";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
@@ -12,13 +13,9 @@ export const GET = apiHandler(
const { user } = await requireAuth();
const { id } = await params;
const medication = await prisma.medication.findUnique({
where: { id },
select: { userId: true },
});
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404);
}
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const config = await prisma.reminderPhaseConfig.findUnique({
where: { medicationId: id },
@@ -53,13 +50,9 @@ export const PUT = apiHandler(
const { user } = await requireAuth();
const { id } = await params;
const medication = await prisma.medication.findUnique({
where: { id },
select: { userId: true },
});
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404);
}
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const { data: body, error: jsonError } = await safeJson(request);
@@ -95,13 +88,9 @@ export const DELETE = apiHandler(
const { user } = await requireAuth();
const { id } = await params;
const medication = await prisma.medication.findUnique({
where: { id },
select: { userId: true },
});
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404);
}
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
await prisma.reminderPhaseConfig.deleteMany({
where: { medicationId: id },
+12 -2
View File
@@ -71,8 +71,16 @@ export const PUT = apiHandler(
return apiError(parsed.error.issues[0].message, 422);
}
const { name, dose, category, active, notificationsEnabled, schedules } =
parsed.data;
const {
name,
dose,
category,
treatmentClass,
dosesPerUnit,
active,
notificationsEnabled,
schedules,
} = parsed.data;
const pausedAtPatch =
active === undefined
@@ -93,6 +101,8 @@ export const PUT = apiHandler(
const baseUpdateData = {
...(name !== undefined && { name }),
...(dose !== undefined && { dose }),
...(treatmentClass !== undefined && { treatmentClass }),
...(dosesPerUnit !== undefined && { dosesPerUnit }),
...(active !== undefined && { active }),
...(notificationsEnabled !== undefined && { notificationsEnabled }),
...(schedules && {
@@ -0,0 +1,72 @@
/**
* v1.4.25 W19d — per-row side-effect operations.
*
* DELETE /api/medications/[id]/side-effects/[logId]
* - hard delete; the row is user-owned, non-clinical, and the
* audit log captures enough state to reconstruct it if needed.
*
* Marc-decision (from the W19d brief): allow delete at any time
* rather than a 24-hour retraction window. Side-effect rows are not
* a clinical record; the user owns them and a stale mis-entry should
* never be undeletable. The audit trail preserves "what was deleted
* when" for any forensic reconstruction.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
type RouteParams = { params: Promise<{ id: string; logId: string }> };
async function loadOwnedRow(
medicationId: string,
logId: string,
userId: string,
) {
const row = await prisma.medicationSideEffect.findUnique({
where: { id: logId },
});
if (!row || row.userId !== userId || row.medicationId !== medicationId) {
return null;
}
return row;
}
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id, logId } = await params;
const existing = await loadOwnedRow(id, logId, user.id);
if (!existing) return apiError("Side-effect log not found", 404);
await prisma.medicationSideEffect.delete({ where: { id: logId } });
await auditLog("medication.sideEffect.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
sideEffectId: logId,
entry: existing.entry,
severity: existing.severity,
occurredAt: existing.occurredAt.toISOString(),
},
});
annotate({
action: {
name: "medication.sideEffect.delete",
entity_type: "medication_side_effect",
entity_id: logId,
},
meta: { medication_id: id, entry: existing.entry },
});
return apiSuccess({ id: logId, deleted: true });
},
);
@@ -0,0 +1,352 @@
/**
* v1.4.25 W19d — side-effect API route tests.
*
* Mirrors the inventory route test fixture pattern: every external
* dependency is mocked at module boundaries (Prisma, session, audit,
* rate-limit, logging transport), and each describe block exercises
* one HTTP verb's contract.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findUnique: vi.fn() },
medicationSideEffect: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
},
},
}));
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().mockResolvedValue({
allowed: true,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, POST } from "../route";
import { DELETE } from "../[logId]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
const MED_OK = { id: "med-1", userId: "user-1" };
function jsonReq(url: string, body: unknown, method = "POST"): NextRequest {
return new NextRequest(url, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 29,
resetAt: Date.now() + 60_000,
});
});
describe("GET /api/medications/[id]/side-effects", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/side-effects"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 404 when the medication is not owned by the caller", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "OTHER",
} as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/side-effects"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(404);
});
it("returns the logs for the owned medication", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationSideEffect.findMany).mockResolvedValue([
{ id: "se-1", entry: "NAUSEA", severity: 2 },
{ id: "se-2", entry: "DIARRHEA", severity: 3 },
] as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/side-effects"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { items: unknown[]; meta: { total: number } };
};
expect(body.data.items).toHaveLength(2);
expect(body.data.meta.total).toBe(2);
});
it("applies from / to / limit query params", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationSideEffect.findMany).mockResolvedValue(
[] as never,
);
const from = "2026-05-01T00:00:00Z";
const to = "2026-05-14T00:00:00Z";
await GET(
new NextRequest(
`http://localhost/api/medications/med-1/side-effects?from=${from}&to=${to}&limit=10`,
),
{ params: Promise.resolve({ id: "med-1" }) },
);
const call = vi.mocked(prisma.medicationSideEffect.findMany).mock.calls[0][0];
expect(call).toMatchObject({
where: {
userId: "user-1",
medicationId: "med-1",
occurredAt: { gte: new Date(from), lt: new Date(to) },
},
take: 10,
});
});
});
describe("POST /api/medications/[id]/side-effects", () => {
it("rejects unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "GI",
entry: "NAUSEA",
severity: 2,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 422 when severity is out of range", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "GI",
entry: "NAUSEA",
severity: 7,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("derives category server-side and ignores any client-supplied category (v1.4.25 W21 Fix-N / code-M6)", async () => {
// Client maliciously sends category=INJECTION_SITE for a
// NAUSEA entry. The server now derives the canonical category
// from `entry`, so the row lands with category="GI" regardless
// of the client claim. Backwards-compatible with older clients
// that still send `category`.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationSideEffect.create).mockResolvedValue({
id: "se-new",
entry: "NAUSEA",
category: "GI",
severity: 2,
} as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "INJECTION_SITE",
entry: "NAUSEA",
severity: 2,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(201);
const callArgs = vi.mocked(prisma.medicationSideEffect.create).mock
.calls[0][0] as { data: { category: string } };
expect(callArgs.data.category).toBe("GI");
});
it("creates a row with the server-derived category", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationSideEffect.create).mockResolvedValue({
id: "se-new",
entry: "NAUSEA",
category: "GI",
severity: 2,
} as never);
const occurredAt = "2026-05-14T08:00:00Z";
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "GI",
entry: "NAUSEA",
severity: 2,
occurredAt,
notes: "after breakfast",
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(201);
expect(prisma.medicationSideEffect.create).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: "user-1",
medicationId: "med-1",
category: "GI",
entry: "NAUSEA",
severity: 2,
occurredAt: new Date(occurredAt),
notes: "after breakfast",
}),
});
});
it("defaults occurredAt to now when omitted", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationSideEffect.create).mockResolvedValue({
id: "se-new",
} as never);
const before = Date.now();
await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "GLP1_SPECIFIC",
entry: "EARLY_SATIETY",
severity: 3,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
const after = Date.now();
const call = vi.mocked(prisma.medicationSideEffect.create).mock.calls[0][0];
const created = call.data.occurredAt as Date;
expect(created.getTime()).toBeGreaterThanOrEqual(before);
expect(created.getTime()).toBeLessThanOrEqual(after);
});
it("returns 429 when the rate limit is exceeded", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "GI",
entry: "NAUSEA",
severity: 2,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(429);
});
});
describe("DELETE /api/medications/[id]/side-effects/[logId]", () => {
it("returns 401 unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await DELETE(
new NextRequest(
"http://localhost/api/medications/med-1/side-effects/se-1",
{ method: "DELETE" },
),
{ params: Promise.resolve({ id: "med-1", logId: "se-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 404 when the row belongs to another user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationSideEffect.findUnique).mockResolvedValue({
id: "se-1",
userId: "OTHER",
medicationId: "med-1",
entry: "NAUSEA",
severity: 2,
occurredAt: new Date(),
} as never);
const res = await DELETE(
new NextRequest(
"http://localhost/api/medications/med-1/side-effects/se-1",
{ method: "DELETE" },
),
{ params: Promise.resolve({ id: "med-1", logId: "se-1" }) },
);
expect(res.status).toBe(404);
});
it("hard-deletes the row and audit-logs entry + severity", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationSideEffect.findUnique).mockResolvedValue({
id: "se-1",
userId: "user-1",
medicationId: "med-1",
entry: "NAUSEA",
severity: 3,
occurredAt: new Date("2026-05-14T08:00:00Z"),
} as never);
vi.mocked(prisma.medicationSideEffect.delete).mockResolvedValue({
id: "se-1",
} as never);
const res = await DELETE(
new NextRequest(
"http://localhost/api/medications/med-1/side-effects/se-1",
{ method: "DELETE" },
),
{ params: Promise.resolve({ id: "med-1", logId: "se-1" }) },
);
expect(res.status).toBe(200);
expect(prisma.medicationSideEffect.delete).toHaveBeenCalledWith({
where: { id: "se-1" },
});
});
});
@@ -0,0 +1,163 @@
/**
* v1.4.25 W19d — GLP-1 side-effect log CRUD (collection).
*
* GET /api/medications/[id]/side-effects?from=ISO&to=ISO&limit=50
* - returns the user's logs for this medication, newest first,
* optionally bounded by an [from, to) window.
*
* POST /api/medications/[id]/side-effects
* - creates a new entry. Body: { category, entry, severity,
* occurredAt?, notes? }. Category is verified against the
* authoritative entry → category mapping; mismatch → 422.
*
* Auth: cookie-session via requireAuth(); the medication is verified
* to belong to the caller before any read/write.
* Rate-limit: 30/min/user on the POST path.
* Audit-log every mutation with the affected row id.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
createSideEffectSchema,
listSideEffectsSchema,
} from "@/lib/medications/side-effects/validators";
import { categoryForEntry } from "@/lib/medications/side-effects/taxonomy";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
type RouteParams = { params: Promise<{ id: string }> };
const POST_RATE_LIMIT = 30;
const POST_WINDOW_MS = 60_000;
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const url = new URL(request.url);
const parsed = listSideEffectsSchema.safeParse({
from: url.searchParams.get("from") ?? undefined,
to: url.searchParams.get("to") ?? undefined,
limit: url.searchParams.get("limit") ?? undefined,
});
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const { from, to, limit } = parsed.data;
const items = await prisma.medicationSideEffect.findMany({
where: {
userId: user.id,
medicationId: id,
...(from || to
? {
occurredAt: {
...(from ? { gte: from } : {}),
...(to ? { lt: to } : {}),
},
}
: {}),
},
orderBy: { occurredAt: "desc" },
take: limit,
});
annotate({
action: { name: "medication.sideEffect.list" },
meta: { medication_id: id, total: items.length },
});
return apiSuccess({ items, meta: { total: items.length } });
},
);
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
// Per-user POST rate-limit — 30/min comfortably absorbs a session
// of bulk back-fill (e.g. "log yesterday's symptoms") while cutting
// off automated abuse.
const rl = await checkRateLimit(
`medication-side-effect:post:${user.id}`,
POST_RATE_LIMIT,
POST_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
});
}
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = createSideEffectSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const { entry, severity, occurredAt, notes } = parsed.data;
// v1.4.25 W21 Fix-N (code-M6) — category is derived server-side
// from the entry via the authoritative taxonomy mapping. The wire
// schema no longer accepts `category`; older clients that still
// send it now have it ignored by Zod's strict drop, and the row
// lands with the correct (entry-derived) category every time.
const category = categoryForEntry(entry);
const created = await prisma.medicationSideEffect.create({
data: {
userId: user.id,
medicationId: id,
category,
entry,
severity,
occurredAt: occurredAt ?? new Date(),
notes: notes ?? null,
},
});
await auditLog("medication.sideEffect.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
sideEffectId: created.id,
entry,
severity,
},
});
annotate({
action: {
name: "medication.sideEffect.create",
entity_type: "medication_side_effect",
entity_id: created.id,
},
meta: { medication_id: id, entry, severity },
});
return apiSuccess(created, 201);
},
);
@@ -0,0 +1,227 @@
/**
* v1.4.25 W19f — titration-ladder route tests.
*
* Mirrors the W19e cadence-route test fixture: external dependencies
* are mocked at module boundaries (prisma, session, logging
* transport) so each test pins one contract case. The pure ladder
* helpers are covered separately in
* `src/lib/medications/titration/__tests__/ladder.test.ts`.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function makeMed(
overrides: Partial<{
name: string;
dose: string;
treatmentClass: "GLP1" | "GENERIC";
userId: string;
doseChanges: Array<{ effectiveFrom: Date; doseValue: number }>;
}> = {},
) {
return {
id: "med-1",
name: overrides.name ?? "Mounjaro",
dose: overrides.dose ?? "5 mg",
treatmentClass: overrides.treatmentClass ?? ("GLP1" as const),
userId: overrides.userId ?? "user-1",
createdAt: new Date(),
doseChanges: overrides.doseChanges ?? [],
};
}
beforeEach(() => {
vi.resetAllMocks();
});
describe("GET /api/medications/[id]/titration", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 404 when the medication is not owned by the caller", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
makeMed({ userId: "OTHER" }) as never,
);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(404);
});
it("returns 404 when the medication is not GLP-1", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
makeMed({ treatmentClass: "GENERIC" }) as never,
);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(404);
});
it("returns 404 when the medication brand is not in the GLP-1 catalog", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
makeMed({ name: "MysteryDrug" }) as never,
);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(404);
});
it("returns the ladder + current step for a Mounjaro user on 5 mg", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
makeMed({
name: "Mounjaro",
dose: "5 mg",
doseChanges: [
{
effectiveFrom: new Date(Date.now() - 6 * 7 * 24 * 60 * 60 * 1000),
doseValue: 2.5,
},
{
effectiveFrom: new Date(Date.now() - 2 * 7 * 24 * 60 * 60 * 1000),
doseValue: 5,
},
],
}) as never,
);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.drugId).toBe("tirzepatide");
expect(json.data.ladder.length).toBe(6);
expect(json.data.currentStep.doseMg).toBe(5);
expect(json.data.currentStepIndex).toBe(1);
expect(json.data.weeksOnCurrentStep).toBe(2);
expect(json.data.nextStep.doseMg).toBe(7.5);
expect(json.data.escalationDue).toBe(false);
});
it("returns a null current step when the user dose is outside any ladder bucket", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
makeMed({
name: "Mounjaro",
dose: "6 mg", // outside the ±10 % window of 5 and 7.5
doseChanges: [],
}) as never,
);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.currentStep).toBeNull();
expect(json.data.currentStepIndex).toBeNull();
expect(json.data.nextStep).toBeNull();
expect(json.data.escalationDue).toBe(false);
});
it("returns a null next-step at the ladder ceiling", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
makeMed({
name: "Mounjaro",
dose: "15 mg",
doseChanges: [
{
effectiveFrom: new Date(Date.now() - 5 * 7 * 24 * 60 * 60 * 1000),
doseValue: 15,
},
],
}) as never,
);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.currentStep.doseMg).toBe(15);
expect(json.data.nextStep).toBeNull();
expect(json.data.escalationDue).toBe(false);
});
it("flags escalationDue=true past the EMA dwell-time on a non-ceiling step", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
makeMed({
name: "Mounjaro",
dose: "2.5 mg",
doseChanges: [
{
// Six weeks ago, past tirzepatide's 4-week interval.
effectiveFrom: new Date(Date.now() - 6 * 7 * 24 * 60 * 60 * 1000),
doseValue: 2.5,
},
],
}) as never,
);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.currentStep.doseMg).toBe(2.5);
expect(json.data.weeksOnCurrentStep).toBe(6);
expect(json.data.escalationDue).toBe(true);
});
});
@@ -0,0 +1,136 @@
/**
* v1.4.25 W19f — GLP-1 titration-ladder read endpoint.
*
* GET /api/medications/[id]/titration
* - returns the EMA-reference ladder for the medication's drug,
* the user's current step (matched within ±10 % tolerance to
* their latest dose), how long they've been on that step, the
* immediate-next step (if any), and the observational
* escalation-due flag.
*
* Pure computation — no writes. Delegates math to the
* `src/lib/medications/titration/ladder` module so the route, the
* Coach snapshot (future wave), and the detail-page section all read
* the same numbers.
*
* Auth: cookie-session via requireAuth(); medication is verified to
* belong to the caller before any read. Defence-in-depth: returns
* 404 for non-GLP-1 medications (the UI already gates on
* `treatmentClass === "GLP1"`).
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiError, apiSuccess } from "@/lib/api-response";
import {
findDrugByBrand,
findDrugIdByBrand,
} from "@/lib/medications/glp1-knowledge";
import { parseDoseMgOrNull } from "@/lib/medications/dose-string";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import {
escalationDue,
findCurrentStep,
ladderFromRecord,
nextStep,
weeksOnCurrentStep,
} from "@/lib/medications/titration/ladder";
type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper so
// the 404 leak shape stays consistent across every medication
// sub-route.
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const med = await prisma.medication.findUnique({
where: { id },
include: {
doseChanges: { orderBy: { effectiveFrom: "asc" } },
},
});
if (!med) {
return apiError("Medication not found", 404);
}
if (med.treatmentClass !== "GLP1") {
// Defence-in-depth: the UI already gates the section on this
// class, but the route refuses to surface the ladder for a
// non-GLP-1 row so we never leak a misleading "you are here"
// on a generic medication.
return apiError("Medication not found", 404);
}
// Resolve the GLP-1 drug record from the medication name. Same
// path the DrugLevelChart uses; null when the medication name
// doesn't map to a catalog brand (e.g. user typed a generic
// INN instead of a brand). v1.4.25 W21 Fix-N — both lookups go
// through the shared helpers in glp1-knowledge.
const record = findDrugByBrand(med.name);
const drugId = findDrugIdByBrand(med.name);
if (!record || !drugId) {
return apiError("Medication not found", 404);
}
const ladder = ladderFromRecord(record);
// Resolve the user's most-recent dose. Prefer the
// MedicationDoseChange stream (the history of record); fall back
// to the legacy free-text `medication.dose` if the stream is empty.
const latestChange =
med.doseChanges.length > 0
? med.doseChanges[med.doseChanges.length - 1]
: null;
const latestDoseMg = latestChange
? latestChange.doseValue
: parseDoseMgOrNull(med.dose);
const currentStep = findCurrentStep(drugId, latestDoseMg);
const next = nextStep(drugId, currentStep);
const asOf = new Date();
const weeks = weeksOnCurrentStep(
drugId,
currentStep,
med.doseChanges.map((dc) => ({
effectiveFrom: dc.effectiveFrom,
doseValue: dc.doseValue,
})),
asOf,
);
const due = escalationDue(drugId, currentStep, weeks);
annotate({
action: {
name: "medication.titration",
entity_type: "medication",
entity_id: id,
},
meta: {
drug_id: drugId,
step_index: currentStep?.stepIndex ?? null,
weeks_on_step: weeks,
},
});
return apiSuccess({
drugId,
drugInn: record.inn,
ladder,
currentStep,
currentStepIndex: currentStep?.stepIndex ?? null,
weeksOnCurrentStep: weeks,
nextStep: next,
escalationDue: due,
sourceEMA: record.sourceEMA,
});
},
);
+11 -11
View File
@@ -23,6 +23,7 @@ import {
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
const querySchema = z.object({
scope: z.enum(["today", "compliance"]),
@@ -42,9 +43,9 @@ const updateSchema = z.object({
.optional(),
});
function startOfDayBerlin(date: Date): Date {
function startOfDayInTz(date: Date, tz: string): Date {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
@@ -55,12 +56,6 @@ function startOfDayBerlin(date: Date): Date {
return new Date(`${y}-${m}-${d}T00:00:00.000Z`);
}
function berlinDayKey(date: Date): string {
return new Intl.DateTimeFormat("sv-SE", {
timeZone: "Europe/Berlin",
}).format(date);
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -73,8 +68,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { scope, days } = parsed.data;
// v1.4.25 W7b — anchor "today" and per-day compliance buckets to the
// user's display timezone so a 23:30 reading in Pacific/Auckland lands
// in today's bucket rather than yesterday's Berlin one.
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
if (scope === "today") {
const todayStart = startOfDayBerlin(new Date());
const todayStart = startOfDayInTz(new Date(), userTz);
const todayEnd = new Date(todayStart.getTime() + 86_400_000);
const events = await prisma.medicationIntakeEvent.findMany({
where: {
@@ -119,10 +119,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
const buckets = new Map<string, { scheduled: number; taken: number }>();
for (let i = 0; i < days; i++) {
const d = new Date(Date.now() - i * 86_400_000);
buckets.set(berlinDayKey(d), { scheduled: 0, taken: 0 });
buckets.set(userDayKey(d, userTz), { scheduled: 0, taken: 0 });
}
for (const e of events) {
const key = berlinDayKey(e.scheduledFor);
const key = userDayKey(e.scheduledFor, userTz);
const bucket = buckets.get(key);
if (!bucket) continue;
bucket.scheduled += 1;
+11 -1
View File
@@ -83,6 +83,11 @@ export const GET = apiHandler(async () => {
return apiSuccess(
medications.map((m) => ({
...m,
// v1.4.25 W4d — `category` is the existing clinical taxonomy
// (BLOOD_PRESSURE / VITAMIN / ...) from the side-table; the
// Prisma model has its own `treatmentClass` (GENERIC | GLP1) that
// spreads through `...m` and unlocks the GLP-1 surfaces. Two
// orthogonal concepts, both exposed.
category: categoryMap[m.id] ?? "OTHER",
lastTakenAt: lastTakenAtByMedicationId[m.id] ?? null,
todayEventCount: todayEventCountByMedId[m.id] ?? 0,
@@ -101,13 +106,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError(parsed.error.issues[0].message, 422);
}
const { name, dose, category, schedules } = parsed.data;
const { name, dose, category, treatmentClass, dosesPerUnit, schedules } =
parsed.data;
const medication = await prisma.medication.create({
data: {
userId: user.id,
name,
dose,
// v1.4.25 W4d — treatmentClass + dosesPerUnit are optional in the
// wire schema; Prisma fills the default GENERIC when omitted.
...(treatmentClass !== undefined && { treatmentClass }),
...(dosesPerUnit !== undefined && { dosesPerUnit }),
schedules: {
create: schedules.map((s) => ({
windowStart: s.windowStart,
+9 -7
View File
@@ -13,15 +13,10 @@ import {
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
type RouteParams = { params: Promise<{ id: string }> };
function toBerlinDate(date: Date): string {
return new Intl.DateTimeFormat("sv-SE", {
timeZone: "Europe/Berlin",
}).format(date);
}
function parseTags(tags: string | null): string[] {
if (!tags) return [];
try {
@@ -80,8 +75,15 @@ export const PUT = apiHandler(
updateData.score = getScoreForMood(data.mood);
}
if (data.moodLoggedAt !== undefined) {
// v1.4.25 W7b — re-anchor the row's `date` to the user's current
// displayTimezone. Also refresh the `tz` column so the row's
// attribution stays consistent with the new `date`. Legacy rows
// promoted via this PUT therefore migrate to per-row tz without
// a separate backfill.
const tz = user.timezone ?? DEFAULT_TIMEZONE;
updateData.moodLoggedAt = data.moodLoggedAt;
updateData.date = toBerlinDate(data.moodLoggedAt);
updateData.date = moodDateKey(data.moodLoggedAt, tz);
updateData.tz = tz;
}
if (data.tags !== undefined) {
updateData.tags = data.tags ? JSON.stringify(data.tags) : null;
+8 -7
View File
@@ -16,12 +16,7 @@ import { Prisma } from "@/generated/prisma/client";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { withIdempotency } from "@/lib/idempotency";
function toBerlinDate(date: Date): string {
return new Intl.DateTimeFormat("sv-SE", {
timeZone: "Europe/Berlin",
}).format(date);
}
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
function parseTags(tags: string | null): string[] {
if (!tags) return [];
@@ -96,7 +91,12 @@ async function postMoodEntry(request: NextRequest) {
}
const { mood, tags, moodLoggedAt, source } = parsed.data;
const date = toBerlinDate(moodLoggedAt);
// v1.4.25 W7b (Decision A) — anchor the `date` string to the user's
// current displayTimezone and store the resolved zone on the row.
// Legacy rows with `tz IS NULL` continue to read as Europe/Berlin
// (see `src/lib/mood/date-key.ts`).
const tz = user.timezone ?? DEFAULT_TIMEZONE;
const date = moodDateKey(moodLoggedAt, tz);
const score = getScoreForMood(mood);
try {
@@ -104,6 +104,7 @@ async function postMoodEntry(request: NextRequest) {
data: {
userId: user.id,
date,
tz,
mood,
score,
tags: tags ? JSON.stringify(tags) : null,
@@ -0,0 +1,251 @@
/**
* v1.4.25 W21 Fix-M — concurrency regression for
* `POST /api/onboarding/step`.
*
* The step write was fetch-then-update: two parallel tabs both
* reading `onboardingStep = 2` and both POSTing `step: 3` would both
* succeed because the second write didn't re-check the precondition.
* The route now claims the row via `updateMany` with the precondition
* embedded in the WHERE clause, so exactly one update lands and the
* loser sees `count = 0` and returns 409.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
findUniqueOrThrow: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({
getSession: vi.fn(),
setOnboardingPendingCookie: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../step/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function req(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/onboarding/step", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 29,
resetAt: Date.now() + 60_000,
});
});
describe("POST /api/onboarding/step — happy path", () => {
it("advances 0 → 1 on the first wizard submission", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
onboardingStep: 0,
onboardingCompletedAt: null,
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
onboardingStep: 1,
onboardingCompletedAt: null,
} as never);
const res = await POST(req({ step: 1 }));
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { step: number } };
expect(body.data.step).toBe(1);
});
it("marks completion on step 4 (sets onboardingCompletedAt)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
onboardingStep: 3,
onboardingCompletedAt: null,
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
onboardingStep: 4,
onboardingCompletedAt: new Date("2026-05-14T12:00:00Z"),
} as never);
const res = await POST(req({ step: 4 }));
expect(res.status).toBe(200);
const updateCall = vi.mocked(prisma.user.updateMany).mock.calls[0][0];
expect(updateCall.data).toMatchObject({
onboardingStep: 4,
});
expect(updateCall.data).toHaveProperty("onboardingCompletedAt");
});
});
describe("POST /api/onboarding/step — guard rails", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await POST(req({ step: 1 }));
expect(res.status).toBe(401);
});
it("returns 422 for an out-of-range step value", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await POST(req({ step: 99 }));
expect(res.status).toBe(422);
});
it("returns 409 when the user has already completed onboarding", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
onboardingStep: 4,
onboardingCompletedAt: new Date("2026-05-01T12:00:00Z"),
} as never);
const res = await POST(req({ step: 1 }));
expect(res.status).toBe(409);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("returns 409 when the requested step is out of order", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
onboardingStep: 1,
onboardingCompletedAt: null,
} as never);
// The route sees current = 1; submitting step 3 (skipping 2) must
// be rejected before the write.
const res = await POST(req({ step: 3 }));
expect(res.status).toBe(409);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
});
describe("POST /api/onboarding/step — concurrent write race", () => {
it("returns 409 when the conditional update affects zero rows", async () => {
// Regression for Fix-M / code-M7: two parallel tabs both read
// `onboardingStep = 2`, both POST `step: 3`. The first lands;
// when the second runs the conditional updateMany, the WHERE
// clause no longer matches (the column moved to 3) and Prisma
// returns `count = 0`. The route must surface 409 so the client
// can refresh and retry instead of double-advancing the wizard.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
onboardingStep: 2,
onboardingCompletedAt: null,
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await POST(req({ step: 3 }));
expect(res.status).toBe(409);
// findUniqueOrThrow must NOT be called when the claim fails —
// the route returns before re-reading the row.
expect(prisma.user.findUniqueOrThrow).not.toHaveBeenCalled();
});
it("includes the precondition in the updateMany WHERE clause", async () => {
// The whole point of the conditional update is that the WHERE
// clause re-asserts the precondition we read. Pin the contract
// so a future refactor can't quietly drop the guard.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
onboardingStep: 2,
onboardingCompletedAt: null,
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
onboardingStep: 3,
onboardingCompletedAt: null,
} as never);
await POST(req({ step: 3 }));
const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0][0]
.where as Record<string, unknown>;
expect(whereArg).toMatchObject({
id: "user-1",
onboardingCompletedAt: null,
});
// Strict-equality precondition — Migration 0060 dropped the
// legacy nullable path so every advance uses `{ in: [current] }`.
expect(whereArg.onboardingStep).toEqual({ in: [2] });
});
it("advances a step-0 row to step 1 using the strict-equality precondition", async () => {
// Migration 0060 (v1.4.25 W21 Fix-O) backfilled NULL → 0 and
// flipped `onboarding_step` to NOT NULL, so the conditional
// update always uses the strict-equality form — there is no
// legacy null branch left for the route to widen.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
onboardingStep: 0,
onboardingCompletedAt: null,
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
onboardingStep: 1,
onboardingCompletedAt: null,
} as never);
const res = await POST(req({ step: 1 }));
expect(res.status).toBe(200);
const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0][0]
.where as Record<string, unknown>;
expect(whereArg.onboardingStep).toEqual({ in: [0] });
});
});
describe("POST /api/onboarding/step — rate limit", () => {
it("returns 429 when the per-user rate limit is exceeded", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await POST(req({ step: 1 }));
expect(res.status).toBe(429);
});
});
+178
View File
@@ -0,0 +1,178 @@
import type { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { setOnboardingPendingCookie } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
/**
* v1.4.25 W14b — POST /api/onboarding/step.
*
* Persists the user's progress through the rebuilt
* `/onboarding/[step]` wizard. Body: `{ step: number }` where `step`
* is 1..4. The endpoint enforces a strict step-by-step contract:
*
* - Submitted step must equal `current + 1` (no skipping ahead).
* - Submitting step 4 marks completion — `onboardingCompletedAt`
* flips from null to NOW() in the same write and the
* `hl_onboarding` proxy cookie is cleared.
* - Already-completed users (`onboardingCompletedAt != null`)
* receive a 409. Replays must call this endpoint only while the
* wizard is in-progress.
*
* Companion to:
* - `GET ` not implemented (the User row is already loaded into
* every server-rendered step page).
* - `POST /api/onboarding/complete` remains the legacy v1.4.20
* completion path used by the old single-file wizard; the new
* flow uses this endpoint with `step: 4` instead.
*
* Rate limit: 30 writes / 10 min / user — generous for a 4-step flow
* but tight enough to defang a stuck retry loop.
*/
const stepBodySchema = z.object({
step: z.number().int().min(1).max(4),
});
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "onboarding.step" } });
const rl = await checkRateLimit(
`onboarding-step:${user.id}`,
30,
10 * 60 * 1000,
);
if (!rl.allowed) {
annotate({
action: { name: "onboarding.step" },
meta: { outcome: "rate_limited" },
});
return apiError("Too many onboarding writes, try again later", 429);
}
const { data: body, error: jsonError } = await safeJson<unknown>(request);
if (jsonError) return jsonError;
const parsed = stepBodySchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "onboarding.step" },
meta: { outcome: "validation_failed" },
});
return apiError("Invalid step payload", 422);
}
const { step } = parsed.data;
// Re-fetch the fresh User row so concurrent step submissions in
// separate tabs can't race past each other — the session.user
// snapshot might be a request-old.
const fresh = await prisma.user.findUnique({
where: { id: user.id },
select: {
onboardingStep: true,
onboardingCompletedAt: true,
},
});
if (!fresh) {
return apiError("User not found", 404);
}
if (fresh.onboardingCompletedAt) {
annotate({
action: { name: "onboarding.step" },
meta: { outcome: "already_completed" },
});
return apiError("Onboarding already completed", 409);
}
const current = fresh.onboardingStep;
if (step !== current + 1) {
annotate({
action: { name: "onboarding.step" },
meta: { outcome: "out_of_order", current, requested: step },
});
return apiError(
`Step out of order — current step is ${current}`,
409,
);
}
const completing = step === 4;
// Conditional update — the WHERE clause re-asserts the
// precondition we read above so the write can only land on a row
// whose state still matches what we validated. Two parallel tabs
// both reading `onboardingStep = 2` and both POSTing `step: 3`
// would otherwise both succeed (the read-then-write race); with
// this guard exactly one update sees `count = 1`, the second sees
// `count = 0` and returns 409. `onboardingCompletedAt: null` also
// catches a race with `POST /api/onboarding/complete`. Migration
// 0060 (v1.4.25 W21) backfilled NULL → 0 and flipped the column
// to NOT NULL, so the legacy null branch this code carried before
// is dropped — every row now matches the strict-equality form.
const claimed = await prisma.user.updateMany({
where: {
id: user.id,
onboardingCompletedAt: null,
onboardingStep: { in: [current] },
},
data: {
onboardingStep: step,
...(completing ? { onboardingCompletedAt: new Date() } : {}),
},
});
if (claimed.count !== 1) {
annotate({
action: { name: "onboarding.step" },
meta: { outcome: "concurrent_write", current, requested: step },
});
return apiError(
"Onboarding step changed concurrently, refresh and retry",
409,
);
}
const updated = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
select: {
onboardingStep: true,
onboardingCompletedAt: true,
},
});
if (completing) {
// Mirror /api/onboarding/complete — clear the proxy-readable
// pending cookie so the next navigation drops the /onboarding
// redirect immediately.
await setOnboardingPendingCookie(false);
}
await auditLog("onboarding.step", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
step,
completed: completing,
},
});
annotate({
action: { name: "onboarding.step" },
meta: { outcome: completing ? "completed" : "advanced", step },
});
return apiSuccess({
step: updated.onboardingStep,
onboardingCompletedAt: updated.onboardingCompletedAt,
});
});
@@ -0,0 +1,161 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
personalRecord: {
findMany: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function req(url: string): NextRequest {
return new NextRequest(url, { method: "GET" });
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.personalRecord.findMany).mockResolvedValue([]);
});
describe("GET /api/personal-records", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(req("http://localhost/api/personal-records"));
expect(res.status).toBe(401);
});
it("returns an empty list for a user with no records", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(req("http://localhost/api/personal-records"));
expect(res.status).toBe(200);
const body = (await res.json()) as { data: unknown[]; error: null };
expect(body.data).toEqual([]);
expect(body.error).toBeNull();
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
orderBy: { achievedAt: "desc" },
take: 100,
});
});
it("passes through a valid metricType filter", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(
req("http://localhost/api/personal-records?metricType=VO2_MAX"),
);
expect(res.status).toBe(200);
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1", metricType: "VO2_MAX" },
orderBy: { achievedAt: "desc" },
take: 100,
});
});
it("drops an unknown metricType silently (loose-typed filter contract)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(
req("http://localhost/api/personal-records?metricType=BOGUS"),
);
expect(res.status).toBe(200);
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
orderBy: { achievedAt: "desc" },
take: 100,
});
});
it("honours an explicit ?limit param", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(req("http://localhost/api/personal-records?limit=25"));
expect(res.status).toBe(200);
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
orderBy: { achievedAt: "desc" },
take: 25,
});
});
it("clamps ?limit to the project-wide ceiling of 500", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(
req("http://localhost/api/personal-records?limit=999999"),
);
expect(res.status).toBe(200);
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
orderBy: { achievedAt: "desc" },
take: 500,
});
});
it("falls back to default on garbage ?limit (defence-in-depth)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(req("http://localhost/api/personal-records?limit=abc"));
expect(res.status).toBe(200);
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
orderBy: { achievedAt: "desc" },
take: 100,
});
});
it("seeded record round-trips through the response", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const fakeRecord = {
id: "pr-1",
userId: "user-1",
metricType: "VO2_MAX",
metricSlot: null,
direction: "MAX",
value: 47.2,
unit: "mL/(kg·min)",
achievedAt: new Date("2026-05-01T07:00:00.000Z"),
sourceMeasurementId: "m-1",
source: "APPLE_HEALTH",
externalId: null,
createdAt: new Date(),
};
vi.mocked(prisma.personalRecord.findMany).mockResolvedValue([
fakeRecord,
] as never);
const res = await GET(req("http://localhost/api/personal-records"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: Array<{ value: number; metricType: string }>;
};
expect(body.data).toHaveLength(1);
expect(body.data[0].value).toBe(47.2);
expect(body.data[0].metricType).toBe("VO2_MAX");
});
});
+75
View File
@@ -0,0 +1,75 @@
/**
* GET /api/personal-records
*
* v1.4.25 W8d — schema-only release of the PersonalRecord feature.
* The detection worker that actually populates rows lands in a later
* release (v1.4.26 or v1.5 — TBD). This route exists today so the
* v1.5 iOS-Swift app can build its query path against a stable
* contract from day one.
*
* Query params:
* - metricType: optional MeasurementType filter (e.g. ?metricType=VO2_MAX)
* - limit: optional pagination cap (default 100, max 500)
*
* Response envelope (matches the project-wide `apiSuccess` contract):
* { data: PersonalRecord[], error: null }
*/
import { prisma } from "@/lib/db";
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import type { MeasurementType } from "@/generated/prisma/client";
export const dynamic = "force-dynamic";
// v1.4.25 W10 reconcile (Sr-M4) — pagination bounds. Matches the
// `listMeasurementsSchema` ceiling (max 500) so every ingest-and-read
// endpoint on the route surface shares the same upper bound. Today
// the worker hasn't populated any rows so even an uncapped findMany is
// harmless, but a power user with multi-year Apple Health history could
// accumulate 50+ PRs per metric × 14 PR-trackable metrics once the
// worker lands. Add the cap now so the v1.5 iOS app's query path is
// bounded from day one.
const DEFAULT_LIMIT = 100;
const MAX_LIMIT = 500;
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "personalRecords.list" } });
const { searchParams } = new URL(request.url);
const metricTypeParam = searchParams.get("metricType");
const limitParam = searchParams.get("limit");
// Defensive parse — drop unknown values rather than 400 so the
// caller's loosely-typed filter doesn't take the page down.
const metricType: MeasurementType | null =
metricTypeParam && measurementTypeEnum.safeParse(metricTypeParam).success
? (metricTypeParam as MeasurementType)
: null;
// Limit clamp: parse → clamp [1, MAX_LIMIT] → default fallback. A
// garbage value (`?limit=abc`, `?limit=-1`, `?limit=999999`) silently
// clamps to the default rather than 400-ing — same defence-in-depth
// stance the metricType parse takes.
let limit = DEFAULT_LIMIT;
if (limitParam != null) {
const parsed = Number(limitParam);
if (Number.isInteger(parsed) && parsed > 0) {
limit = Math.min(parsed, MAX_LIMIT);
}
}
const records = await prisma.personalRecord.findMany({
where: {
userId: user.id,
...(metricType ? { metricType } : {}),
},
orderBy: { achievedAt: "desc" },
take: limit,
});
return apiSuccess(records);
});
+10 -2
View File
@@ -3,7 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
import { exchangeCode } from "@/lib/withings/client";
import { exchangeCode, WITHINGS_OAUTH_SCOPE } from "@/lib/withings/client";
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
import { setupWebhook } from "@/lib/withings/sync";
import { markReconnected } from "@/lib/integrations/status";
@@ -72,7 +72,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
// Upsert connection (user may be reconnecting)
// v1.4.25 W5d — persist the OAuth scope we requested. The token
// response doesn't echo the granted scopes back as a top-level
// field, but Withings honours the scope param verbatim — there's
// no scope downgrade unless the user explicitly revokes one in
// their Health Mate account, which would fail the token exchange
// entirely. Storing the requested set is therefore a safe
// representation of what the connection holds.
await prisma.withingsConnection.upsert({
where: { userId: user.id },
update: {
@@ -80,6 +86,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
tokenExpiresAt: expiresAt,
scope: WITHINGS_OAUTH_SCOPE,
},
create: {
userId: user.id,
@@ -87,6 +94,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
tokenExpiresAt: expiresAt,
scope: WITHINGS_OAUTH_SCOPE,
},
});
+15 -4
View File
@@ -5,7 +5,10 @@ import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { decrypt } from "@/lib/crypto";
import { unsubscribeWebhook } from "@/lib/withings/client";
import { getWithingsWebhookCallbackUrl } from "@/lib/withings/sync";
import {
WITHINGS_NOTIFY_APPLIS,
getWithingsWebhookCallbackUrl,
} from "@/lib/withings/sync";
import { markDisconnected } from "@/lib/integrations/status";
/**
@@ -23,13 +26,21 @@ export const POST = apiHandler(async () => {
return apiError("No Withings connection", 404);
}
// Try to unsubscribe webhook (best-effort)
// Best-effort unsubscribe across every appli category we subscribed
// to in setupWebhook. A token-expired failure on one category should
// not stop us from cleaning up the others.
try {
const accessToken = decrypt(connection.accessToken);
const callbackUrl = getWithingsWebhookCallbackUrl();
await unsubscribeWebhook(accessToken, callbackUrl);
for (const appli of WITHINGS_NOTIFY_APPLIS) {
try {
await unsubscribeWebhook(accessToken, callbackUrl, appli);
} catch {
// Ignore — token might be expired or the appli was never subscribed.
}
}
} catch {
// Ignore — token might be expired
// Ignore — token might be undecryptable.
}
await prisma.withingsConnection.delete({
+9 -1
View File
@@ -4,7 +4,7 @@ import { annotate, getEvent } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { decrypt, encrypt } from "@/lib/crypto";
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
import { refreshAccessToken } from "@/lib/withings/client";
import { hasActivityScope, refreshAccessToken } from "@/lib/withings/client";
import { isWithingsRefreshReauthFailure } from "@/lib/withings/sync";
import { markReauthRequired } from "@/lib/integrations/status";
@@ -37,6 +37,7 @@ export const GET = apiHandler(async () => {
lastSyncedAt: true,
tokenExpiresAt: true,
createdAt: true,
scope: true,
},
});
@@ -95,5 +96,12 @@ export const GET = apiHandler(async () => {
tokenExpired,
tokenRefreshFailed,
tokenExpiresAt,
// v1.4.25 W5d — `scope` is the comma-separated OAuth scope string
// returned during the auth flow; `hasActivityScope` is the
// pre-computed convenience flag the Settings reconnect banner
// reads. Null `scope` means a legacy v1.4.24 connection that's
// never re-authed since v1.4.25 — it counts as "missing activity".
scope: connection.scope,
hasActivityScope: hasActivityScope(connection.scope),
});
});
@@ -0,0 +1,214 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
// --- Module-boundary mocks must come before importing the route. ---
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
vi.mock("@/lib/db", () => ({
prisma: {
withingsConnection: { findFirst: vi.fn() },
},
}));
vi.mock("@/lib/withings/sync", () => ({
syncUserMeasurements: vi.fn(),
}));
// v1.4.25 W17b/c — activity / sleep paths dispatch through pg-boss.
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: vi.fn(),
}));
vi.mock("@/lib/withings/sync-activity", () => ({
syncUserActivity: vi.fn(),
}));
vi.mock("@/lib/withings/sync-sleep", () => ({
syncUserSleep: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => ({
setAuth: vi.fn(),
addWarning: vi.fn(),
})),
}));
import { POST, GET, HEAD } from "../route";
import { prisma } from "@/lib/db";
import { syncUserMeasurements } from "@/lib/withings/sync";
import { checkRateLimit } from "@/lib/rate-limit";
import { getEvent } from "@/lib/logging/context";
const ORIGINAL_SECRET = process.env.WITHINGS_WEBHOOK_SECRET;
beforeEach(() => {
vi.resetAllMocks();
process.env.WITHINGS_WEBHOOK_SECRET = "test-secret";
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 30,
resetAt: Date.now() + 60_000,
} as never);
vi.mocked(getEvent).mockReturnValue({
setAuth: vi.fn(),
addWarning: vi.fn(),
} as never);
vi.mocked(syncUserMeasurements).mockResolvedValue(undefined as never);
});
afterEach(() => {
if (ORIGINAL_SECRET === undefined) delete process.env.WITHINGS_WEBHOOK_SECRET;
else process.env.WITHINGS_WEBHOOK_SECRET = ORIGINAL_SECRET;
});
function jsonRequest(token: string, body: unknown): NextRequest {
return new NextRequest(
`http://localhost/api/withings/webhook/${encodeURIComponent(token)}`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
},
);
}
function paramsFor(token: string) {
return { params: Promise.resolve({ token }) };
}
describe("POST /api/withings/webhook/[token] (path-segment secret)", () => {
it("returns 200 + triggers sync when the path token matches WITHINGS_WEBHOOK_SECRET", async () => {
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
userId: "user-42",
withingsUserId: "wu-1",
} as never);
const res = await POST(
jsonRequest("test-secret", { userid: "wu-1" }),
paramsFor("test-secret"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { status: string };
expect(body.status).toBe("ok");
expect(prisma.withingsConnection.findFirst).toHaveBeenCalledWith({
where: { withingsUserId: "wu-1" },
});
expect(syncUserMeasurements).toHaveBeenCalledTimes(1);
expect(syncUserMeasurements).toHaveBeenCalledWith("user-42");
});
it("returns 401 when the path token does NOT match the configured secret", async () => {
const res = await POST(
jsonRequest("WRONG", { userid: "wu-1" }),
paramsFor("WRONG"),
);
expect(res.status).toBe(401);
expect(syncUserMeasurements).not.toHaveBeenCalled();
expect(prisma.withingsConnection.findFirst).not.toHaveBeenCalled();
});
it("returns 401 when the path token is the empty string (missing)", async () => {
// Next.js never routes /api/withings/webhook/ to this handler — that
// path lands on the legacy route. The empty-string case here covers
// direct invocations and adversarial proxies that strip the segment.
const res = await POST(
new NextRequest("http://localhost/api/withings/webhook/", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ userid: "wu-1" }),
}),
paramsFor(""),
);
expect(res.status).toBe(401);
expect(syncUserMeasurements).not.toHaveBeenCalled();
});
it("returns 401 when WITHINGS_WEBHOOK_SECRET is unset, even if the URL carries a token", async () => {
delete process.env.WITHINGS_WEBHOOK_SECRET;
const addWarning = vi.fn();
vi.mocked(getEvent).mockReturnValue({
setAuth: vi.fn(),
addWarning,
} as never);
const res = await POST(
jsonRequest("anything", { userid: "wu-1" }),
paramsFor("anything"),
);
expect(res.status).toBe(401);
expect(addWarning).toHaveBeenCalledWith(
"WITHINGS_WEBHOOK_SECRET not configured",
);
});
it("constant-time compare rejects a token of the wrong length without short-circuiting on prefix match", async () => {
// "test-secret" is the configured value; "test-secret-extra" shares
// the full prefix. A non-timing-safe compare would still return false
// here, so the assertion is on the response only — the security
// property is exercised by the underlying timingSafeEqual call shape.
const res = await POST(
jsonRequest("test-secret-extra", { userid: "wu-1" }),
paramsFor("test-secret-extra"),
);
expect(res.status).toBe(401);
});
it("returns 429 when rate-limited, before the auth check", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
} as never);
const res = await POST(
jsonRequest("WRONG", { userid: "wu-1" }),
paramsFor("WRONG"),
);
expect(res.status).toBe(429);
expect(syncUserMeasurements).not.toHaveBeenCalled();
});
});
describe("HEAD /api/withings/webhook/[token]", () => {
it("returns 200 for a valid token, 401 otherwise", async () => {
const okReq = new NextRequest(
"http://localhost/api/withings/webhook/test-secret",
{ method: "HEAD" },
);
const ok = await HEAD(okReq, paramsFor("test-secret"));
expect(ok.status).toBe(200);
const badReq = new NextRequest(
"http://localhost/api/withings/webhook/WRONG",
{ method: "HEAD" },
);
const bad = await HEAD(badReq, paramsFor("WRONG"));
expect(bad.status).toBe(401);
});
});
describe("GET /api/withings/webhook/[token]", () => {
it("returns 200 + ok body for a valid token, 401 otherwise", async () => {
const okReq = new NextRequest(
"http://localhost/api/withings/webhook/test-secret",
);
const ok = await GET(okReq, paramsFor("test-secret"));
expect(ok.status).toBe(200);
const badReq = new NextRequest(
"http://localhost/api/withings/webhook/WRONG",
);
const bad = await GET(badReq, paramsFor("WRONG"));
expect(bad.status).toBe(401);
});
});
@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import {
applyWebhookRateLimit,
processWithingsNotification,
timingSafeStringEqual,
} from "@/lib/withings/webhook-handler";
/**
* Withings webhook entrypoint (v1.4.25 W17a — path-segment secret).
*
* The shared secret travels in the URL path (`/api/withings/webhook/<secret>`)
* rather than the query string. Withings has no facility for adding HTTP
* headers or signing webhook bodies, so the only authenticity surface a
* subscriber controls is the callback URL itself; moving the secret out of
* the query string keeps it out of the `query_string` column most reverse
* proxies log by default (Caddy, nginx, Coolify) and out of GlitchTip's
* URL/breadcrumb capture path which already strips `?…` aggressively.
*
* The legacy `?secret=` form at `/api/withings/webhook` stays alive for one
* release cycle so existing Withings subscriptions keep delivering while
* users re-subscribe.
*/
interface RouteContext {
params: Promise<{ token: string }>;
}
async function verifyTokenSegment(
token: string | undefined,
): Promise<boolean> {
const expected = process.env.WITHINGS_WEBHOOK_SECRET;
if (!expected) {
getEvent()?.addWarning("WITHINGS_WEBHOOK_SECRET not configured");
return false;
}
if (!token) return false;
return timingSafeStringEqual(expected, token);
}
export const POST = apiHandler(
async (request: NextRequest, context: RouteContext) => {
annotate({ action: { name: "withings.webhook" } });
const limited = await applyWebhookRateLimit(request);
if (limited) return limited;
const { token } = await context.params;
if (!(await verifyTokenSegment(token))) {
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
}
return processWithingsNotification(request);
},
);
/**
* Withings sends HEAD/GET to verify the URL when a subscription is
* created.
*/
export const HEAD = apiHandler(
async (_request: NextRequest, context: RouteContext) => {
annotate({ action: { name: "withings.webhook.verify" } });
const { token } = await context.params;
if (!(await verifyTokenSegment(token))) {
return new NextResponse(null, { status: 401 });
}
getEvent()?.setAuth({ auth_method: "webhook_secret" });
return new NextResponse(null, { status: 200 });
},
);
export const GET = apiHandler(
async (_request: NextRequest, context: RouteContext) => {
annotate({ action: { name: "withings.webhook.verify" } });
const { token } = await context.params;
if (!(await verifyTokenSegment(token))) {
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
}
getEvent()?.setAuth({ auth_method: "webhook_secret" });
return NextResponse.json({ status: "ok" }, { status: 200 });
},
);
@@ -17,6 +17,21 @@ vi.mock("@/lib/withings/sync", () => ({
syncUserMeasurements: vi.fn(),
}));
// v1.4.25 W17b/c — activity / sleep paths dispatch through pg-boss.
// Mock the boss-instance accessor to return a fake boss for the
// appli=16 / 44 tests and `null` otherwise.
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: vi.fn(),
}));
vi.mock("@/lib/withings/sync-activity", () => ({
syncUserActivity: vi.fn(),
}));
vi.mock("@/lib/withings/sync-sleep", () => ({
syncUserSleep: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: vi.fn(() => ({})),
@@ -30,11 +45,20 @@ vi.mock("@/lib/logging/context", () => ({
})),
}));
import { POST, GET, HEAD } from "../route";
import {
POST,
GET,
HEAD,
getLegacyFormTotal,
__resetLegacyFormTotalForTests,
} from "../route";
import { prisma } from "@/lib/db";
import { syncUserMeasurements } from "@/lib/withings/sync";
import { syncUserActivity } from "@/lib/withings/sync-activity";
import { syncUserSleep } from "@/lib/withings/sync-sleep";
import { checkRateLimit } from "@/lib/rate-limit";
import { getEvent } from "@/lib/logging/context";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
const ORIGINAL_SECRET = process.env.WITHINGS_WEBHOOK_SECRET;
@@ -51,6 +75,11 @@ beforeEach(() => {
addWarning: vi.fn(),
} as never);
vi.mocked(syncUserMeasurements).mockResolvedValue(undefined as never);
// Default: pg-boss not available → activity / sleep paths fall back
// to inline sync. Individual tests override with a fake boss.
vi.mocked(getGlobalBoss).mockReturnValue(null);
vi.mocked(syncUserActivity).mockResolvedValue(0 as never);
vi.mocked(syncUserSleep).mockResolvedValue(0 as never);
});
afterEach(() => {
@@ -144,7 +173,8 @@ describe("POST /api/withings/webhook", () => {
expect(syncUserMeasurements).toHaveBeenCalledWith("user-42");
});
it("legacy ?secret=… query path still authorises and emits a Wide Event warning", async () => {
it("legacy ?secret=… query path still authorises and emits a Wide Event warning with the migration URL (Fix-K sec-M2)", async () => {
__resetLegacyFormTotalForTests();
const addWarning = vi.fn();
vi.mocked(getEvent).mockReturnValue({
setAuth: vi.fn(),
@@ -163,6 +193,30 @@ describe("POST /api/withings/webhook", () => {
expect(addWarning).toHaveBeenCalledWith(
expect.stringMatching(/legacy URL query/i),
);
// Fix-K sec-M2: warning text now spells out the migration URL so
// anyone watching the access log knows what to re-subscribe to.
expect(addWarning).toHaveBeenCalledWith(
expect.stringContaining("/api/withings/webhook/[token]"),
);
// Fix-K sec-M2: in-memory counter increments on every legacy
// ?secret=… call so the release-gate can watch usage trend to zero.
expect(getLegacyFormTotal()).toBe(1);
});
it("counter stays at zero when the secret arrives via the header (Fix-K sec-M2)", async () => {
__resetLegacyFormTotalForTests();
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
userId: "user-h",
withingsUserId: "wu-h",
} as never);
await POST(
jsonRequest(
{ userid: "wu-h" },
{ "x-withings-webhook-secret": "test-secret" },
),
);
expect(getLegacyFormTotal()).toBe(0);
});
it("accepts form-encoded payloads (Withings legacy delivery format)", async () => {
@@ -224,6 +278,120 @@ describe("POST /api/withings/webhook", () => {
});
});
describe("POST /api/withings/webhook — appli dispatch (W17b/c)", () => {
it("appli=16 enqueues onto withings-activity-sync (not the measure path)", async () => {
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
userId: "user-act",
withingsUserId: "wu-act",
} as never);
const send = vi.fn().mockResolvedValue(undefined);
vi.mocked(getGlobalBoss).mockReturnValue({ send } as never);
const res = await POST(
formRequest(
{ userid: "wu-act", appli: "16" },
{ "x-withings-webhook-secret": "test-secret" },
),
);
expect(res.status).toBe(200);
expect(send).toHaveBeenCalledTimes(1);
expect(send.mock.calls[0][0]).toBe("withings-activity-sync");
expect(send.mock.calls[0][1]).toMatchObject({ userId: "user-act" });
// Critical: appli=16 must NOT trigger the measure path.
expect(syncUserMeasurements).not.toHaveBeenCalled();
});
it("appli=44 enqueues onto withings-sleep-sync (not the measure path)", async () => {
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
userId: "user-sleep",
withingsUserId: "wu-sleep",
} as never);
const send = vi.fn().mockResolvedValue(undefined);
vi.mocked(getGlobalBoss).mockReturnValue({ send } as never);
const res = await POST(
formRequest(
{ userid: "wu-sleep", appli: "44" },
{ "x-withings-webhook-secret": "test-secret" },
),
);
expect(res.status).toBe(200);
expect(send).toHaveBeenCalledTimes(1);
expect(send.mock.calls[0][0]).toBe("withings-sleep-sync");
expect(send.mock.calls[0][1]).toMatchObject({ userId: "user-sleep" });
expect(syncUserMeasurements).not.toHaveBeenCalled();
});
it("appli=16 falls back to inline activity sync when pg-boss is unavailable", async () => {
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
userId: "user-act-inline",
withingsUserId: "wu-act-inline",
} as never);
// Default beforeEach already sets `getGlobalBoss → null`.
const res = await POST(
formRequest(
{ userid: "wu-act-inline", appli: "16" },
{ "x-withings-webhook-secret": "test-secret" },
),
);
expect(res.status).toBe(200);
expect(syncUserActivity).toHaveBeenCalledWith("user-act-inline");
expect(syncUserMeasurements).not.toHaveBeenCalled();
});
it("appli=44 falls back to inline sleep sync when pg-boss is unavailable", async () => {
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
userId: "user-sleep-inline",
withingsUserId: "wu-sleep-inline",
} as never);
const res = await POST(
formRequest(
{ userid: "wu-sleep-inline", appli: "44" },
{ "x-withings-webhook-secret": "test-secret" },
),
);
expect(res.status).toBe(200);
expect(syncUserSleep).toHaveBeenCalledWith("user-sleep-inline");
expect(syncUserMeasurements).not.toHaveBeenCalled();
});
it("appli=4 (BP) keeps the legacy measure path", async () => {
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
userId: "user-bp",
withingsUserId: "wu-bp",
} as never);
const res = await POST(
formRequest(
{ userid: "wu-bp", appli: "4" },
{ "x-withings-webhook-secret": "test-secret" },
),
);
expect(res.status).toBe(200);
expect(syncUserMeasurements).toHaveBeenCalledWith("user-bp");
expect(syncUserActivity).not.toHaveBeenCalled();
expect(syncUserSleep).not.toHaveBeenCalled();
});
it("a missing appli falls back to the measure path (legacy Withings subscriptions)", async () => {
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
userId: "user-legacy",
withingsUserId: "wu-legacy",
} as never);
const res = await POST(
formRequest(
{ userid: "wu-legacy" },
{ "x-withings-webhook-secret": "test-secret" },
),
);
expect(res.status).toBe(200);
expect(syncUserMeasurements).toHaveBeenCalledWith("user-legacy");
});
});
describe("HEAD /api/withings/webhook (Withings URL verification)", () => {
it("returns 200 with a valid header secret", async () => {
const req = new NextRequest("http://localhost/api/withings/webhook", {
+90 -83
View File
@@ -1,110 +1,117 @@
import { timingSafeEqual } from "node:crypto";
import { prisma } from "@/lib/db";
import { syncUserMeasurements } from "@/lib/withings/sync";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { getClientIp, safeJson } from "@/lib/api-response";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import {
applyWebhookRateLimit,
processWithingsNotification,
timingSafeStringEqual,
} from "@/lib/withings/webhook-handler";
/**
* Audit C-3 / phase P2: Withings legacy callback URLs include the shared
* secret as a `?secret=…` query param, which leaks into reverse-proxy
* access logs and any error-tracking pipeline that captures `request.url`
* (see `reportToGlitchtip`). New deployments should configure Withings to
* send the secret via the `X-Withings-Webhook-Secret` header instead.
* Legacy Withings webhook entrypoint.
*
* The query-param fallback stays for backwards compatibility with existing
* Withings configurations; remove once all integrators have migrated.
* Pre-v1.4.25 callback URLs accepted the shared secret in two places:
*
* 1. `?secret=…` — the original form, which Withings preserves on
* every notification and which therefore lands in
* reverse-proxy access logs (the `query_string`
* column) and any error-tracking pipeline that
* captures `request.url`.
* 2. `X-Withings-Webhook-Secret` header — Withings does NOT support
* setting custom headers on notifications, so this
* path only fires for manual replay tests.
*
* v1.4.25 W17a moved the production path to
* `/api/withings/webhook/[token]`, where the secret travels as a path
* segment instead of a query parameter. This route stays alive for
* one release cycle so existing Withings subscriptions keep delivering
* while users re-subscribe (which also happens organically when they
* reconnect for the new `user.activity` OAuth scope shipped in W5d).
*
* Removal target: v1.4.27 (after all live subscriptions have rotated).
*
* v1.4.25 W21 Fix-K — added an in-memory `withings.webhook.legacy_form_total`
* counter the `/api/admin/status` endpoint surfaces so the release-gate
* can read "legacy form usage trending toward zero" before the v1.4.27
* cut. The warning text now includes the re-subscription URL so anyone
* watching the access log knows what to migrate to.
*/
function hasValidWebhookSecret(request: NextRequest): boolean {
const MIGRATION_URL = "/api/withings/webhook/[token]" as const;
// In-memory counter — resets on every deploy / cold-start. That is the
// intended granularity: we want to know "how many legacy-form requests
// did this process serve since it came up", not a forever-running
// cumulative metric (which would belong in Prisma).
const counters = {
legacy_form_total: 0,
};
/**
* Read the legacy-form counter. Exported so `/api/admin/status` can
* surface it without a backchannel.
*/
export function getLegacyFormTotal(): number {
return counters.legacy_form_total;
}
/**
* Reset the legacy-form counter. Test-only utility; production code
* does not call this. Documented so a future smoke test can wire up
* a deterministic "counter was 0 before the call" assertion.
*/
export function __resetLegacyFormTotalForTests(): void {
counters.legacy_form_total = 0;
}
function hasValidWebhookSecret(
request: NextRequest,
): Promise<{ ok: boolean; via: "header" | "query" | "none" }> {
const expected = process.env.WITHINGS_WEBHOOK_SECRET;
if (!expected) {
getEvent()?.addWarning("WITHINGS_WEBHOOK_SECRET not configured");
return false;
return Promise.resolve({ ok: false, via: "none" });
}
const fromHeader = request.headers.get("x-withings-webhook-secret");
const fromQuery = request.nextUrl.searchParams.get("secret");
const received = fromHeader ?? fromQuery;
if (!received) return false;
if (!received) return Promise.resolve({ ok: false, via: "none" });
if (!fromHeader && fromQuery) {
const via: "header" | "query" = fromHeader ? "header" : "query";
return timingSafeStringEqual(expected, received).then((ok) => ({ ok, via }));
}
async function checkAndWarn(
request: NextRequest,
): Promise<NextResponse | null> {
const auth = await hasValidWebhookSecret(request);
if (!auth.ok) {
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
}
if (auth.via === "query") {
counters.legacy_form_total += 1;
getEvent()?.addWarning(
"withings webhook secret received via legacy URL query — migrate to X-Withings-Webhook-Secret header",
`withings webhook secret received via legacy URL query — migrate to ${MIGRATION_URL} (re-subscribe with the path-segment token form)`,
);
}
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(received, "utf8");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
return null;
}
/**
* Withings webhook notification endpoint.
* Withings webhook notification endpoint (legacy form).
* Withings sends a POST when new measurements are available.
* The webhook sends: userid, startdate, enddate, appli
* The webhook sends: userid, startdate, enddate, appli.
*/
export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "withings.webhook" } });
annotate({ action: { name: "withings.webhook.legacy" } });
const ip = getClientIp(request);
const rl = await checkRateLimit(`withings-webhook:${ip}`, 30, 60 * 1000);
if (!rl.allowed) {
return NextResponse.json(
{ status: "rate_limited" },
{ status: 429, headers: rateLimitHeaders(rl) },
);
}
const limited = await applyWebhookRateLimit(request);
if (limited) return limited;
if (!hasValidWebhookSecret(request)) {
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
}
const unauth = await checkAndWarn(request);
if (unauth) return unauth;
getEvent()?.setAuth({ auth_method: "webhook_secret" });
// Withings sends form-encoded or JSON depending on version
const contentType = request.headers.get("content-type") ?? "";
let withingsUserId: string | null = null;
if (contentType.includes("application/x-www-form-urlencoded")) {
const formData = await request.formData();
withingsUserId = formData.get("userid") as string;
} else {
const { data: body, error: jsonError } = await safeJson<{
userid?: string | number;
}>(request);
if (jsonError) return jsonError;
withingsUserId = body.userid?.toString() ?? null;
}
if (!withingsUserId) {
return NextResponse.json({ status: "ignored" }, { status: 200 });
}
annotate({ meta: { withings_user_id: withingsUserId } });
// Find user by Withings user ID
const connection = await prisma.withingsConnection.findFirst({
where: { withingsUserId },
});
if (!connection) {
getEvent()?.addWarning(
"Webhook for unknown withings user: " + withingsUserId,
);
return NextResponse.json({ status: "unknown_user" }, { status: 200 });
}
// Sync measurements (non-blocking response for Withings)
syncUserMeasurements(connection.userId).catch((err) => {
getEvent()?.addWarning(
"Sync failed for user " + connection.userId + ": " + err,
);
});
return NextResponse.json({ status: "ok" }, { status: 200 });
return processWithingsNotification(request);
});
/**
@@ -112,8 +119,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
*/
export const HEAD = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "withings.webhook.verify" } });
if (!hasValidWebhookSecret(request)) {
const auth = await hasValidWebhookSecret(request);
if (!auth.ok) {
return new NextResponse(null, { status: 401 });
}
getEvent()?.setAuth({ auth_method: "webhook_secret" });
@@ -122,8 +129,8 @@ export const HEAD = apiHandler(async (request: NextRequest) => {
export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "withings.webhook.verify" } });
if (!hasValidWebhookSecret(request)) {
const auth = await hasValidWebhookSecret(request);
if (!auth.ok) {
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
}
getEvent()?.setAuth({ auth_method: "webhook_secret" });
@@ -0,0 +1,401 @@
/**
* Unit suite for `POST /api/workouts/batch`.
*
* Database-free coverage of the surface that doesn't require a real
* Postgres — the per-entry status envelope, the rate-limit gate, the
* payload-size ceiling, the Zod-parse short-circuits, and the
* race-reconciliation downgrade pass. Full request-lifecycle assertions
* (idempotency replay, real-DB dedup, concurrent-write race) live in
* the matching `tests/integration/workout-batch-create.test.ts`.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
workout: {
findMany: vi.fn(),
createMany: vi.fn(),
findFirst: vi.fn(),
},
workoutRoute: {
createMany: vi.fn(),
},
$transaction: vi.fn(async (fn: unknown) => {
// Same shape as the real Prisma client — invoke the supplied
// callback with the mocked client itself so `tx.workout.createMany`
// calls land on the same vi.fn instances.
if (typeof fn === "function") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (fn as any)((prisma as unknown as { workout: unknown }));
}
}),
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/jobs/pr-detection", () => ({
enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../batch/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function makeRequest(
body: unknown,
opts: {
contentLength?: number | null;
headers?: Record<string, string>;
} = {},
): NextRequest {
const baseHeaders: Record<string, string> = {
"content-type": "application/json",
...(opts.headers ?? {}),
};
const serialized = JSON.stringify(body);
if (opts.contentLength !== null) {
baseHeaders["content-length"] = String(
opts.contentLength ?? serialized.length,
);
}
return new NextRequest("http://localhost/api/workouts/batch", {
method: "POST",
headers: baseHeaders,
body: serialized,
});
}
function validWorkout(externalId: string, overrides: Record<string, unknown> = {}) {
return {
sportType: "running",
startedAt: "2026-05-14T06:30:00.000Z",
endedAt: "2026-05-14T07:15:00.000Z",
source: "APPLE_HEALTH",
externalId,
...overrides,
};
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 60,
resetAt: Date.now() + 60_000,
});
vi.mocked(prisma.workout.findMany).mockResolvedValue([]);
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 0 });
vi.mocked(prisma.workout.findFirst).mockResolvedValue(null);
vi.mocked(prisma.workoutRoute.createMany).mockResolvedValue({ count: 0 });
});
describe("POST /api/workouts/batch — auth + size + rate-limit", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await POST(makeRequest({ workouts: [validWorkout("a")] }));
expect(res.status).toBe(401);
});
it("returns 413 when Content-Length exceeds the 5 MB ceiling", async () => {
const res = await POST(
makeRequest(
{ workouts: [validWorkout("uuid-1")] },
{ contentLength: 6 * 1024 * 1024 },
),
);
expect(res.status).toBe(413);
const body = (await res.json()) as {
error: string;
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe("workout.batch.payload_too_large");
});
it("returns 429 when the rate-limit gate blocks the call", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await POST(makeRequest({ workouts: [validWorkout("uuid-1")] }));
expect(res.status).toBe(429);
// We must NOT have queried Prisma after a rate-limit miss — the
// gate's whole point is to short-circuit before the DB.
expect(prisma.workout.createMany).not.toHaveBeenCalled();
});
});
describe("POST /api/workouts/batch — validation", () => {
it("returns 400 when the workouts array exceeds the per-batch cap", async () => {
const workouts = Array.from({ length: 101 }, (_, i) =>
validWorkout(`uuid-${i}`),
);
const res = await POST(makeRequest({ workouts }));
expect(res.status).toBe(400);
const body = (await res.json()) as {
error: string;
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe("workout.batch.too_large");
});
it("returns 400 when the Zod schema rejects the payload", async () => {
const res = await POST(
makeRequest({
workouts: [
{
// Missing required fields — Zod must reject.
sportType: "running",
},
],
}),
);
expect(res.status).toBe(400);
const body = (await res.json()) as {
error: string;
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe("workout.batch.invalid");
});
it("returns 400 when a route exceeds the 20 000-point cap", async () => {
// 20 001 points — schema must reject before the handler reaches
// its DB layer. We assert no Prisma query fires.
const coordinates: [number, number][] = [];
for (let i = 0; i < 20_001; i++) {
coordinates.push([11 + i * 1e-7, 49 + i * 1e-7]);
}
const res = await POST(
makeRequest(
{
workouts: [
validWorkout("uuid-route-cap", {
route: { geometry: { type: "LineString", coordinates } },
}),
],
},
// Skip the Content-Length check — 20k coordinates serialised
// is well under 5 MB and the request as built has the real
// header anyway. The test asserts that the SCHEMA cap fires
// even when the byte cap doesn't.
{ contentLength: null },
),
);
expect(res.status).toBe(400);
expect(prisma.workout.createMany).not.toHaveBeenCalled();
});
});
describe("POST /api/workouts/batch — per-entry status envelope", () => {
it("marks every fresh entry as inserted", async () => {
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 2 });
const res = await POST(
makeRequest({
workouts: [
validWorkout("uuid-fresh-1"),
validWorkout("uuid-fresh-2"),
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
processed: number;
inserted: number;
duplicates: number;
entries: Array<{ status: string }>;
};
};
expect(body.data.processed).toBe(2);
expect(body.data.inserted).toBe(2);
expect(body.data.duplicates).toBe(0);
expect(body.data.entries.every((e) => e.status === "inserted")).toBe(true);
});
it("flags entries already in the DB as duplicate without re-inserting", async () => {
vi.mocked(prisma.workout.findMany).mockResolvedValue([
{ source: "APPLE_HEALTH", externalId: "uuid-existing" },
] as never);
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
const res = await POST(
makeRequest({
workouts: [
validWorkout("uuid-existing"),
validWorkout("uuid-fresh"),
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
inserted: number;
duplicates: number;
entries: Array<{ index: number; status: string }>;
};
};
expect(body.data.inserted).toBe(1);
expect(body.data.duplicates).toBe(1);
expect(body.data.entries[0]?.status).toBe("duplicate");
expect(body.data.entries[1]?.status).toBe("inserted");
});
it("downgrades inserted statuses to duplicate when createMany.count < attempted (race)", async () => {
// Pre-flight findMany returns empty (no obvious duplicates), so
// every entry is initially marked "inserted". createMany then
// returns count=1 — the other batch already won the race for one
// row. The race-reconciliation block must downgrade exactly one
// "inserted" status to "duplicate" so the envelope sums match the
// aggregate counts (the v1.4.25 W10 fix-C invariant).
vi.mocked(prisma.workout.findMany).mockResolvedValue([]);
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
const res = await POST(
makeRequest({
workouts: [
validWorkout("uuid-race-1"),
validWorkout("uuid-race-2"),
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
inserted: number;
duplicates: number;
entries: Array<{ status: string }>;
};
};
const insertedEntries = body.data.entries.filter(
(e) => e.status === "inserted",
).length;
const duplicateEntries = body.data.entries.filter(
(e) => e.status === "duplicate",
).length;
expect(insertedEntries).toBe(body.data.inserted);
expect(duplicateEntries).toBe(body.data.duplicates);
expect(body.data.inserted).toBe(1);
expect(body.data.duplicates).toBe(1);
});
});
describe("POST /api/workouts/batch — nested route attachment", () => {
it("creates a WorkoutRoute row for a workout that ships a route", async () => {
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
// Post-insert lookup returns the newly-inserted workout id so the
// route can be attached by FK.
vi.mocked(prisma.workout.findMany).mockImplementation((async (args: {
select?: { id?: true; source?: true; externalId?: true };
}) => {
if (args.select?.id) {
return [
{
id: "wkt-fresh-id",
source: "APPLE_HEALTH",
externalId: "uuid-with-route",
},
];
}
return [];
}) as never);
const res = await POST(
makeRequest({
workouts: [
validWorkout("uuid-with-route", {
route: {
geometry: {
type: "LineString",
coordinates: [
[11.077, 49.452],
[11.078, 49.453],
],
},
},
}),
],
}),
);
expect(res.status).toBe(200);
expect(prisma.workoutRoute.createMany).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.workoutRoute.createMany).mock.calls[0]?.[0];
const rows = (call as { data: Array<{ workoutId: string }> }).data;
expect(rows[0]?.workoutId).toBe("wkt-fresh-id");
});
});
describe("POST /api/workouts/batch — PR detection enqueue (v1.4.25 W16c)", () => {
it("enqueues PR detection after a successful batch with silent=false", async () => {
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
const res = await POST(
makeRequest({ workouts: [validWorkout("uuid-small")] }),
);
expect(res.status).toBe(200);
expect(enqueuePrDetection).toHaveBeenCalledTimes(1);
expect(enqueuePrDetection).toHaveBeenCalledWith("user-1", {
silent: false,
});
});
it("propagates silent=true when the batch exceeds the historical threshold", async () => {
const big = Array.from({ length: 51 }, (_, i) =>
validWorkout(`uuid-${i}`),
);
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 51 });
const res = await POST(makeRequest({ workouts: big }));
expect(res.status).toBe(200);
expect(enqueuePrDetection).toHaveBeenCalledWith("user-1", {
silent: true,
});
});
it("still enqueues for an all-duplicate batch (cursor cleanup path)", async () => {
// Pre-flight dedup matches all incoming entries, so insertedCount
// stays 0 but duplicateCount becomes 1. The hook fires anyway —
// the detector is cheap and a duplicate batch can still reveal a
// PR if the row was written by another path between dispatches.
vi.mocked(prisma.workout.findMany).mockResolvedValue([
{ source: "APPLE_HEALTH", externalId: "uuid-dup" } as never,
]);
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 0 });
const res = await POST(
makeRequest({ workouts: [validWorkout("uuid-dup")] }),
);
expect(res.status).toBe(200);
expect(enqueuePrDetection).toHaveBeenCalledTimes(1);
expect(enqueuePrDetection).toHaveBeenCalledWith("user-1", {
silent: false,
});
});
});
+503
View File
@@ -0,0 +1,503 @@
/**
* `POST /api/workouts/batch` — typed workout ingest with nested route.
*
* Consumers (v1.5):
* 1. The native iOS app draining its HealthKit observer queue —
* maps `HKWorkout` + `HKWorkoutRoute` samples into the request
* shape locked by `createBatchWorkoutSchema` in
* `src/lib/validations/workout.ts`.
* 2. The Withings activity sync (W17b, deferred) — server-to-server
* ingest with `source: "WITHINGS"` and `externalId` formatted as
* `"${withings.id}:${withings.model_id ?? "0"}:${withings.startdate}"`.
* Withings ships no route geometry, so the nested `route` is
* always absent for WITHINGS-sourced entries.
*
* Cross-cutting concerns mirror `/api/measurements/batch` so the iOS
* sync engine can re-use the same retry / cursor plumbing:
* - `requireAuth()` — cookie + Bearer (narrow-scope-token safe per
* v1.4.25 W10 fix-C: when the route declares no scope, any
* authenticated token passes).
* - `withIdempotency` — `Idempotency-Key` header replays the cached
* response on retry (24h window).
* - `checkRateLimit("workouts:batch:${user.id}", 60, 60s)` — same
* window as the measurements batch. 60 batches/min × 100
* workouts/batch = 6 000 workouts/min headroom (orders of
* magnitude past any healthy iOS sync).
* - `Content-Length` ceiling at 5 MB before parsing — anything
* larger returns 413 so the iOS client falls back to one workout
* per call.
* - Per-entry status (`inserted | duplicate | skipped`) so the iOS
* sync cursor can checkpoint accurately.
*
* Idempotency contract:
* - HTTP-level via `Idempotency-Key` (replays cached envelope).
* - Per-entry via the `@@unique([userId, source, externalId])`
* composite index. Re-posting the same batch surfaces duplicates
* rather than failing the call.
*
* Race reconciliation (mirrors the measurements batch fix from
* v1.4.25 W10): under contention, `createMany` with `skipDuplicates`
* absorbs duplicate-key conflicts but cannot tell us WHICH rows it
* absorbed. We trust the `createMany.count` return value and downgrade
* enough per-entry "inserted" statuses to "duplicate" so the envelope
* sums stay consistent with the aggregate counts. The iOS sync cursor
* advances past both statuses identically.
*
* Prompt-injection surface: `Workout.metadata` is opaque — the Coach
* pipeline reads typed columns only (`sportType`, `durationSec`,
* distance, HR). A future change that pulls metadata into the Coach
* prompt must add deliberate escaping; the ingest path stores it raw.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
import {
createBatchWorkoutSchema,
MAX_WORKOUTS_PER_BATCH,
} from "@/lib/validations/workout";
import { Prisma } from "@/generated/prisma/client";
// v1.4.25 W16c — push-suppression threshold for workout PRs. A batch
// larger than this fires the detection job with `silent: true` so a
// multi-year HKWorkout backfill doesn't spam the user with hundreds of
// pushes. Matches the measurements-batch threshold so behaviour stays
// uniform across both ingest paths.
const PR_DETECTION_SILENT_THRESHOLD = 50;
// v1.4.25 W10 reconcile (security H-2 parity): cap batch ingest at 60
// batches per user per minute. Healthy iOS sync drains its HealthKit
// observer queue in well under one batch per minute (the observer
// pattern coalesces); 60/min × 100 workouts/batch = 6 000 workouts/
// min headroom — generous for legitimate use, and a hard stop for a
// leaked wildcard token trying to saturate the write pipeline.
const BATCH_RATE_LIMIT_MAX = 60;
const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
// 5 MB request-body ceiling. The route geometry is the largest tail in
// a workout payload; with the 20 000-point cap on a single LineString
// AND the 100-workout cap per batch, the worst-case body is bounded
// well below this ceiling for typical lon/lat encodings. A request
// above this ceiling almost certainly indicates a misbehaving client
// or an attempted DoS — return 413 so the iOS client falls back to
// one-workout-per-call.
const MAX_BODY_BYTES = 5 * 1024 * 1024;
/**
* Per-entry outcome the iOS client uses to advance its sync cursor.
* `inserted` and `duplicate` both indicate the row landed (or was
* already present); the client can checkpoint past them identically.
* `skipped` covers entries the server cannot durably store — currently
* unused by this endpoint (the Zod schema rejects malformed entries
* with a 422 before we reach the per-entry pass), but reserved on the
* envelope so the response shape parallels the measurements batch and
* future server-side validation can surface fine-grained skips without
* breaking the iOS DTO.
*/
type EntryStatus = "inserted" | "duplicate" | "skipped";
interface EntryResult {
index: number;
status: EntryStatus;
reason?: string;
}
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBatch));
async function postBatch(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
// Content-Length pre-flight. The body cap exists to bound the
// request-pipeline cost BEFORE Node has to read the full payload —
// a misbehaving client shipping a 50 MB body shouldn't tie up the
// worker for a parse it'd reject anyway. We trust the header here
// because Next.js / undici populates it for any client that sets a
// body length; for chunked uploads (no Content-Length) we fall
// through and rely on the schema-level point / workout caps to keep
// memory bounded.
const contentLength = request.headers.get("content-length");
if (contentLength !== null) {
const parsedLength = Number.parseInt(contentLength, 10);
if (Number.isFinite(parsedLength) && parsedLength > MAX_BODY_BYTES) {
annotate({
action: { name: "workout.batch.ingest" },
meta: { outcome: "payload_too_large", bytes: parsedLength },
});
return apiError(
`Request body exceeds the ${MAX_BODY_BYTES}-byte limit`,
413,
{ errorCode: "workout.batch.payload_too_large" },
);
}
}
// v1.4.25 W10 reconcile (security H-2 parity): per-user rate limit.
// Without it, a leaked wildcard iOS token can sustain unbounded
// writes (100 workouts/batch × N batches/sec) and degrade the
// database for every other user on the host. 60 batches/min/user is
// generous for healthy iOS sync and tight enough that a misbehaving
// client bottoms out within a minute.
const rl = await checkRateLimit(
`workouts:batch:${user.id}`,
BATCH_RATE_LIMIT_MAX,
BATCH_RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
annotate({
action: { name: "workout.batch.ingest" },
meta: { outcome: "rate_limited" },
});
return apiError("Too many batch submissions, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson<unknown>(request);
if (jsonError) return jsonError;
// Distinguish "too many entries" from "validation failed" so the
// client surfaces a useful diagnostic. The cap stops a buggy client
// from saturating the request pipeline; the iOS app paginates above
// this threshold.
if (
typeof rawBody === "object" &&
rawBody !== null &&
"workouts" in rawBody &&
Array.isArray((rawBody as { workouts: unknown }).workouts) &&
(rawBody as { workouts: unknown[] }).workouts.length >
MAX_WORKOUTS_PER_BATCH
) {
return apiError(
`Batch exceeds the ${MAX_WORKOUTS_PER_BATCH}-workout limit`,
400,
{ errorCode: "workout.batch.too_large" },
);
}
const parsed = createBatchWorkoutSchema.safeParse(rawBody);
if (!parsed.success) {
const issue = parsed.error.issues[0];
return apiError(issue?.message ?? "Invalid batch", 400, {
errorCode: "workout.batch.invalid",
});
}
const { workouts } = parsed.data;
// Pre-flight duplicate detection so we can return per-entry status.
// The composite unique index is `(userId, source, externalId)` —
// entries without an `externalId` cannot collide on the key (NULL-
// distinct semantics), so they always insert as new rows.
type Prepared = {
index: number;
row: Prisma.WorkoutCreateManyInput;
route: {
geometry: unknown;
sampleTimestamps: unknown;
} | null;
/** Composite-key probe for the pre-flight findMany. NULL when
* the entry has no externalId — those rows always pass through
* as "inserted" because the NULL-distinct unique index can never
* collide them. */
dedupKey: { source: string; externalId: string } | null;
};
const results: EntryResult[] = new Array(workouts.length);
const prepared: Prepared[] = workouts.map((w, index) => {
const durationSec = Math.max(
0,
Math.round((w.endedAt.getTime() - w.startedAt.getTime()) / 1000),
);
const route = w.route
? {
geometry: w.route.geometry as unknown,
sampleTimestamps: (w.route.sampleTimestamps ?? null) as unknown,
}
: null;
const row: Prisma.WorkoutCreateManyInput = {
userId: user.id,
sportType: w.sportType,
startedAt: w.startedAt,
endedAt: w.endedAt,
durationSec,
totalEnergyKcal: w.totalEnergyKcal ?? null,
totalDistanceM: w.totalDistanceM ?? null,
avgHeartRate: w.avgHeartRate ?? null,
maxHeartRate: w.maxHeartRate ?? null,
minHeartRate: w.minHeartRate ?? null,
stepCount: w.stepCount ?? null,
elevationM: w.elevationM ?? null,
pauseDurationSec: w.pauseDurationSec ?? null,
source: w.source,
externalId: w.externalId ?? null,
externalSourceVersion: w.externalSourceVersion ?? null,
metadata: w.metadata
? (w.metadata as Prisma.InputJsonValue)
: Prisma.JsonNull,
};
return {
index,
row,
route,
dedupKey: w.externalId
? { source: w.source, externalId: w.externalId }
: null,
};
});
let insertedCount = 0;
let duplicateCount = 0;
if (prepared.length > 0) {
const dedupCandidates = prepared
.map((p) => p.dedupKey)
.filter((k): k is { source: string; externalId: string } => k !== null);
const existing = dedupCandidates.length
? await prisma.workout.findMany({
where: {
userId: user.id,
OR: dedupCandidates.map((k) => ({
source: k.source as Prisma.WorkoutCreateManyInput["source"],
externalId: k.externalId,
})),
},
select: { source: true, externalId: true },
})
: [];
const existingSet = new Set(
existing.map((row) => `${row.source}::${row.externalId}`),
);
const toInsert: Prepared[] = [];
for (const p of prepared) {
const keyTuple = p.dedupKey
? `${p.dedupKey.source}::${p.dedupKey.externalId}`
: null;
if (keyTuple !== null && existingSet.has(keyTuple)) {
results[p.index] = { index: p.index, status: "duplicate" };
duplicateCount += 1;
} else {
results[p.index] = { index: p.index, status: "inserted" };
toInsert.push(p);
}
}
if (toInsert.length > 0) {
// Two-step write: createMany the workout rows, then findMany the
// freshly-inserted rows to recover their ids so the route table
// can be populated by FK. We chunk both steps for parity with the
// measurements path and to keep individual SQL statements bounded
// under Postgres's 65k-parameter cap.
const CHUNK = 100;
await prisma.$transaction(async (tx) => {
for (let i = 0; i < toInsert.length; i += CHUNK) {
const slice = toInsert.slice(i, i + CHUNK);
const result = await tx.workout.createMany({
data: slice.map((p) => p.row),
skipDuplicates: true,
});
insertedCount += result.count;
}
// Look up the workouts we just wrote (or that the racing batch
// wrote) so we can attach routes by FK. We probe by the
// composite unique key for entries that have one, and by the
// `(userId, source, startedAt, sportType)` tuple as a fallback
// for manual entries (where externalId is null). The fallback
// is best-effort — manual entries from the iOS client always
// carry an HK uuid, so the null-externalId case is exercised
// only by future surface (manual workout entry).
const routeAttachable = toInsert.filter((p) => p.route !== null);
if (routeAttachable.length > 0) {
const withExternal = routeAttachable.filter(
(p) => p.dedupKey !== null,
);
const withoutExternal = routeAttachable.filter(
(p) => p.dedupKey === null,
);
const lookedUp = withExternal.length
? await tx.workout.findMany({
where: {
userId: user.id,
OR: withExternal.map((p) => ({
source: p.dedupKey!
.source as Prisma.WorkoutCreateManyInput["source"],
externalId: p.dedupKey!.externalId,
})),
},
select: {
id: true,
source: true,
externalId: true,
},
})
: [];
const idBySourceExt = new Map<string, string>();
for (const row of lookedUp) {
if (row.externalId !== null) {
idBySourceExt.set(`${row.source}::${row.externalId}`, row.id);
}
}
// Manual entries — `externalId` is null and the unique index
// is NULL-distinct, so two manual entries on the same instant
// would both insert. We look these up by the deterministic
// (userId, source, startedAt, sportType) tuple for each row.
const manualMatches = await Promise.all(
withoutExternal.map((p) =>
tx.workout.findFirst({
where: {
userId: user.id,
source: p.row.source,
startedAt: p.row.startedAt as Date,
sportType: p.row.sportType,
route: null,
},
select: { id: true },
orderBy: { createdAt: "desc" },
}),
),
);
const routesToInsert: Prisma.WorkoutRouteCreateManyInput[] = [];
for (let i = 0; i < withExternal.length; i++) {
const p = withExternal[i];
const id = idBySourceExt.get(
`${p.dedupKey!.source}::${p.dedupKey!.externalId}`,
);
if (id && p.route) {
routesToInsert.push({
workoutId: id,
geometry: p.route.geometry as Prisma.InputJsonValue,
sampleTimestamps:
p.route.sampleTimestamps === null
? Prisma.JsonNull
: (p.route.sampleTimestamps as Prisma.InputJsonValue),
});
}
}
for (let i = 0; i < withoutExternal.length; i++) {
const p = withoutExternal[i];
const match = manualMatches[i];
if (match?.id && p.route) {
routesToInsert.push({
workoutId: match.id,
geometry: p.route.geometry as Prisma.InputJsonValue,
sampleTimestamps:
p.route.sampleTimestamps === null
? Prisma.JsonNull
: (p.route.sampleTimestamps as Prisma.InputJsonValue),
});
}
}
if (routesToInsert.length > 0) {
for (let i = 0; i < routesToInsert.length; i += CHUNK) {
await tx.workoutRoute.createMany({
data: routesToInsert.slice(i, i + CHUNK),
// 1:1 FK — `workoutId` has a UNIQUE index, so the
// composite unique guards routes too. `skipDuplicates`
// means a re-submitted batch that wins the race on the
// workout row but loses on the route is a no-op rather
// than a hard error.
skipDuplicates: true,
});
}
}
}
});
// v1.4.25 W10 reconcile (senior-dev H-1 parity): when two batches
// race on overlapping externalIds, only one row lands per key.
// `createMany.count` is the source of truth for how many rows
// THIS request wrote; downgrade enough per-entry "inserted"
// statuses to "duplicate" so the per-entry envelope sums match
// the aggregate counts. Order doesn't matter — the iOS sync
// cursor advances past both statuses identically.
const racedDuplicates = toInsert.length - insertedCount;
if (racedDuplicates > 0) {
let downgraded = 0;
for (const p of prepared) {
if (downgraded >= racedDuplicates) break;
if (results[p.index]?.status === "inserted") {
results[p.index] = { index: p.index, status: "duplicate" };
duplicateCount += 1;
downgraded += 1;
}
}
}
}
}
const skipped = results
.filter((r) => r.status === "skipped")
.map((r) => ({ index: r.index, reason: r.reason ?? "unknown" }));
await auditLog("workout.batch.ingest", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
processed: workouts.length,
inserted: insertedCount,
duplicates: duplicateCount,
skipped: skipped.length,
},
});
// v1.4.25 W16c — kick off PR detection for this user. Suppress push
// notifications for historical-backfill batches so a multi-year
// HKWorkout import doesn't fire hundreds of pushes during initial
// sync. The detector also scans Measurement history on the same
// pass, so a workout-only batch still surfaces measurement-side PRs
// the user may have racked up since the last detection.
if (insertedCount > 0 || duplicateCount > 0) {
const silent = workouts.length > PR_DETECTION_SILENT_THRESHOLD;
try {
await enqueuePrDetection(user.id, { silent });
await auditLog("personal_records.detection_enqueued", {
userId: user.id,
details: {
source: "workout.batch",
batchSize: workouts.length,
silent,
},
});
} catch (err) {
annotate({
action: { name: "personal_records.detection_enqueue_failed" },
meta: { error: err instanceof Error ? err.message : String(err) },
});
}
}
annotate({
action: { name: "workout.batch.ingest" },
meta: {
processed: workouts.length,
inserted: insertedCount,
duplicates: duplicateCount,
skipped: skipped.length,
},
});
return apiSuccess({
processed: workouts.length,
inserted: insertedCount,
duplicates: duplicateCount,
skipped,
entries: results,
});
}