mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
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:
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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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" } });
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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 }[]>();
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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) });
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user