chore(release): v1.4.25

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

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

No files matched your search

+28 -11
View File
@@ -107,13 +107,24 @@ describe("v1.4.19 A3 — /insights polish", () => {
expect(advisorBlock?.[0]).not.toContain("onRegenerate");
});
it("hero strip is the always-visible page-level refresh affordance", () => {
const src = load(INSIGHTS_PATH);
// The hero owns the page-level regenerate button (mounted via
// `onRegenerate={advisor.regenerate}` on `<HeroStrip>`).
expect(src).toMatch(
/<HeroStrip[\s\S]*?onRegenerate=\{advisor\.regenerate\}/,
it("tab strip owns the always-visible page-level refresh affordance (v1.4.25 W4)", () => {
// v1.4.25 W4 — the regenerate handler moved out of the mother page
// body entirely and now lives on the layout-mounted
// `<InsightsLayoutShell>` (which renders `<InsightsTabStrip>` with
// the advisor query's regenerate wired in). The mother-page hero
// no longer takes an `onRegenerate` prop, mirroring the v1.4.25 W3
// contract while accommodating the new routed sub-page layout.
const shellSrc = readFileSync(
join(ROOT, "src/components/insights/insights-layout-shell.tsx"),
"utf8",
);
expect(shellSrc).toMatch(
/<InsightsTabStrip[\s\S]*?onRegenerate=[\s\S]*?advisor\.regenerate[\s\S]*?\/>/,
);
const insightsSrc = load(INSIGHTS_PATH);
const heroBlock = insightsSrc.match(/<HeroStrip[\s\S]*?\/>/);
expect(heroBlock).not.toBeNull();
expect(heroBlock?.[0]).not.toContain("onRegenerate");
});
it("does NOT render the duplicate <TrendCard> tile strip on /insights", () => {
@@ -235,10 +246,16 @@ describe("v1.4.22 A3 — comparison toggle is global Settings only", () => {
});
it("/insights still consumes the resolved comparisonBaseline (only the UI is gone)", () => {
// Drift guard — the page must still hand `compareBaseline` to every
// chart so the global Settings toggle keeps driving the overlay.
const src = load(INSIGHTS_PATH);
expect(src).toContain("comparisonBaseline");
expect(src).toContain("compareBaseline");
// Drift guard — v1.4.25 W4 split each metric onto its own sub-route
// under `/insights/<slug>`, and `compareBaseline` propagation now
// happens via `useInsightsLayoutPrefs()` on every sub-page. The
// central hook is the load-bearing surface; assert it carries both
// the resolved value and the persisted preference key.
const hookSrc = readFileSync(
join(ROOT, "src/hooks/use-insights-layout-prefs.ts"),
"utf8",
);
expect(hookSrc).toContain("comparisonBaseline");
expect(hookSrc).toContain("compareBaseline");
});
});
@@ -0,0 +1,133 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.25 W3e — Coach drawer mount + per-card CTA gate.
*
* Two scenarios:
* 1. AI provider configured → per-card "Ask Coach" CTAs render
* alongside each card; the drawer is mounted at the page level.
* 2. AI provider NOT configured → CTAs are completely absent (no
* broken-button state) but the drawer mount stays present (it's
* controlled by the parent regardless; no harm in mounting it
* collapsed, and it costs ~0 since closed Sheets render only the
* portal root).
*/
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/targets",
}));
const sampleData = {
targets: [
{
type: "WEIGHT",
label: "Weight",
current: 78,
average30: 78,
trend: "stable",
unit: "kg",
range: { min: 60, max: 80 },
classification: { category: "Normal", color: "#50fa7b" },
source: "WHO BMI",
daysInRange7d: 5,
daysLogged7d: 7,
lastMetGoalAt: null,
streakDays: 0,
insufficientData: false,
consistency7d: ["in", "in", "in", "in", "in", "in", "in"],
},
],
pageSummary: {
targetsMetThisWeek: 1,
totalTargets: 1,
streakHighlight: null,
},
bpDiastolic: { current: null, average30: null, range: null },
profile: { heightCm: 180, age: 35, gender: "MALE", glucoseUnit: null },
};
let providerChainResponse: { activeProvider: string | null } | null = null;
vi.mock("@tanstack/react-query", () => {
const noopClient = {
invalidateQueries: vi.fn(),
setQueryData: vi.fn(),
getQueryData: vi.fn(),
refetchQueries: vi.fn(),
};
return {
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") {
return {
data: providerChainResponse,
isLoading: false,
isError: false,
refetch: vi.fn(),
};
}
return {
data: sampleData,
isLoading: false,
isError: false,
refetch: vi.fn(),
};
},
useQueryClient: () => noopClient,
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
reset: vi.fn(),
}),
};
});
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "marc",
email: "marc@example.com",
role: "USER",
},
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import TargetsPage from "../targets/page";
function render() {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">
<TargetsPage />
</I18nProvider>,
);
}
describe("/targets page — Coach drawer mount + CTA gate", () => {
it("renders the per-card Coach CTA when an AI provider is configured", () => {
providerChainResponse = { activeProvider: "openai" };
const html = render();
expect(html).toContain('data-slot="target-coach-cta"');
expect(html).toContain("Ask Coach");
});
it("hides every per-card Coach CTA when no AI provider is configured", () => {
providerChainResponse = { activeProvider: null };
const html = render();
expect(html).not.toContain('data-slot="target-coach-cta"');
});
it("renders nothing different when chainStatus query is still loading", () => {
providerChainResponse = null;
const html = render();
// Same as the "no provider" branch — until we know the provider is
// configured we don't render a CTA whose click would 404.
expect(html).not.toContain('data-slot="target-coach-cta"');
});
});
+38 -8
View File
@@ -82,14 +82,44 @@ const sampleData = {
profile: { heightCm: 180, age: 35, gender: "MALE", glucoseUnit: null },
};
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: sampleData,
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
}));
vi.mock("@tanstack/react-query", () => {
const noopClient = {
invalidateQueries: vi.fn(),
setQueryData: vi.fn(),
getQueryData: vi.fn(),
refetchQueries: vi.fn(),
};
return {
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
// v1.4.25 W3e — the targets page now also queries
// ["insights", "provider-chain"] to gate the per-card Coach
// CTA. Return null for that query (no AI provider configured)
// so the CTA hides and the existing test cases keep asserting
// the unchanged visual surface.
if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") {
return {
data: null,
isLoading: false,
isError: false,
refetch: vi.fn(),
};
}
return {
data: sampleData,
isLoading: false,
isError: false,
refetch: vi.fn(),
};
},
useQueryClient: () => noopClient,
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
reset: vi.fn(),
}),
};
});
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
@@ -0,0 +1,133 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.25 W3e — mobile-first responsive layout audit.
*
* Marc directive: "mobile layout must be designed alongside desktop,
* not as an afterthought". This test pins the Tailwind responsive
* utilities so a future refactor cannot quietly collapse the grid
* back to the v1.4.22 `sm:grid-cols-2` and remove the lg three-col
* row.
*
* The three breakpoints we care about:
* • default (<640px): one column, full-width cards
* • sm (640-1023px): two columns
* • lg (1024px+): three columns, slightly wider gutters
*/
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/targets",
}));
const sampleData = {
targets: [
{
type: "WEIGHT",
label: "Weight",
current: 78,
average30: 78,
trend: "stable",
unit: "kg",
range: { min: 60, max: 80 },
classification: { category: "Normal", color: "#50fa7b" },
source: "WHO BMI",
daysInRange7d: 5,
daysLogged7d: 7,
lastMetGoalAt: null,
streakDays: 0,
insufficientData: false,
consistency7d: ["in", "in", "in", "in", "in", "in", "in"],
},
],
pageSummary: {
targetsMetThisWeek: 1,
totalTargets: 1,
streakHighlight: null,
},
bpDiastolic: { current: null, average30: null, range: null },
profile: { heightCm: 180, age: 35, gender: "MALE", glucoseUnit: null },
};
vi.mock("@tanstack/react-query", () => {
const noopClient = {
invalidateQueries: vi.fn(),
setQueryData: vi.fn(),
getQueryData: vi.fn(),
refetchQueries: vi.fn(),
};
return {
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") {
return {
data: { activeProvider: "openai" },
isLoading: false,
isError: false,
refetch: vi.fn(),
};
}
return {
data: sampleData,
isLoading: false,
isError: false,
refetch: vi.fn(),
};
},
useQueryClient: () => noopClient,
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
reset: vi.fn(),
}),
};
});
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "marc",
email: "marc@example.com",
role: "USER",
},
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import TargetsPage from "../targets/page";
function render(locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<TargetsPage />
</I18nProvider>,
);
}
describe("/targets page — mobile-first responsive grid", () => {
it("uses one column by default, two at sm, three at lg", () => {
const html = render();
expect(html).toMatch(/grid-cols-1/);
expect(html).toMatch(/sm:grid-cols-2/);
expect(html).toMatch(/lg:grid-cols-3/);
});
it("uses gap-4 at mobile/sm, gap-6 at lg", () => {
const html = render();
expect(html).toMatch(/\bgap-4\b/);
expect(html).toMatch(/lg:gap-6/);
});
it("renders the Coach CTA full-width on mobile when AI is enabled", () => {
const html = render();
// The TargetCoachButton wraps the Sparkles + "Ask Coach" label;
// its container element should carry the mobile full-width class.
expect(html).toContain("w-full");
expect(html).toContain("sm:w-auto");
});
});
+41 -11
View File
@@ -14,18 +14,48 @@ vi.mock("next/navigation", () => ({
usePathname: () => "/targets",
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: {
targets: [],
bpDiastolic: { current: null, average30: null, range: null },
profile: { heightCm: null, age: null, gender: null, glucoseUnit: null },
vi.mock("@tanstack/react-query", () => {
const noopClient = {
invalidateQueries: vi.fn(),
setQueryData: vi.fn(),
getQueryData: vi.fn(),
refetchQueries: vi.fn(),
};
return {
useQuery: ({ queryKey }: { queryKey: unknown[] }) => {
if (Array.isArray(queryKey) && queryKey[1] === "provider-chain") {
return {
data: null,
isLoading: false,
isError: false,
refetch: vi.fn(),
};
}
return {
data: {
targets: [],
bpDiastolic: { current: null, average30: null, range: null },
profile: {
heightCm: null,
age: null,
gender: null,
glucoseUnit: null,
},
},
isLoading: false,
isError: false,
refetch: vi.fn(),
};
},
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
}));
useQueryClient: () => noopClient,
useMutation: () => ({
mutate: vi.fn(),
mutateAsync: vi.fn(),
isPending: false,
reset: vi.fn(),
}),
};
});
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
@@ -1,123 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.22 C1 — `/targets` (Zielwerte) page upgrade.
*
* Each target card grew a 30-day sparkline + a "Δ vs. last month"
* caption so the page reads as a living health log instead of a
* static reference card. The API ships `points30d: number[]` and
* `deltaVsLastMonth: number` per target; the page renders a tiny
* dependency-free SVG path plus the localised delta caption.
*
* Cards without enough data (fewer than 3 points in either window)
* keep their previous v1.4.19 layout — sparkline and delta caption
* stay absent so cold-start accounts don't see a misleading flat
* trace or a "± 0" comparison that's really "no comparison".
*/
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/targets",
}));
const sampleData = {
targets: [
{
type: "WEIGHT",
label: "Weight",
current: 88.2,
average30: 88.6,
trend: "stable",
unit: "kg",
range: { min: 60, max: 80 },
classification: { category: "Normal", color: "#50fa7b" },
source: "WHO BMI",
points30d: [89.0, 88.8, 88.6, 88.4, 88.2],
deltaVsLastMonth: -2.3,
},
{
type: "PULSE",
label: "Resting pulse",
current: 72,
average30: 72,
trend: "stable",
unit: "bpm",
range: { min: 60, max: 100 },
classification: { category: "On target", color: "#50fa7b" },
source: "AHA",
// No sparkline yet — cold-start account with <3 readings.
points30d: null,
deltaVsLastMonth: null,
},
],
bpDiastolic: { current: null, average30: null, range: null },
profile: { heightCm: 180, age: 35, gender: "MALE", glucoseUnit: null },
};
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: sampleData,
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "tester",
email: "tester@example.com",
role: "USER",
},
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import TargetsPage from "../targets/page";
function render(locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<TargetsPage />
</I18nProvider>,
);
}
describe("/targets page — sparkline + delta", () => {
it("renders a sparkline SVG for cards with points30d", () => {
const html = render();
// The sparkline carries a stable data-slot the visual test pins.
expect(html).toContain('data-slot="target-sparkline"');
// Path with the dracula-purple token, not a hardcoded hex.
expect(html).toContain("var(--dracula-purple)");
});
it("renders the Δ-vs-last-month caption with the signed delta", () => {
const html = render();
expect(html).toContain('data-slot="target-delta"');
// Weight: −2.3 kg vs. last month
expect(html).toMatch(/−2\.3\s*kg/);
expect(html).toContain("vs. last month");
});
it("uses German vs.-Vormonat caption when the locale is `de`", () => {
const html = render("de");
expect(html).toContain("vs. Vormonat");
});
it("skips both sparkline and delta when the target has no points", () => {
const html = render();
// PULSE card has no points30d / no deltaVsLastMonth — only ONE
// `target-sparkline` slot should be present (from the WEIGHT card).
const sparklineMatches = html.match(/data-slot="target-sparkline"/g) ?? [];
expect(sparklineMatches.length).toBe(1);
const deltaMatches = html.match(/data-slot="target-delta"/g) ?? [];
expect(deltaMatches.length).toBe(1);
});
});
@@ -434,4 +434,75 @@ describe("PUT /api/admin/settings", () => {
const res = await PUT(r);
expect(res.status).toBe(415);
});
// v1.4.25 W7 — server-default timezone.
it("persists a valid defaultUserTimezone", async () => {
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
id: "singleton",
registrationEnabled: true,
defaultLocale: "de",
telegramGlobal: true,
ntfyGlobal: true,
webPushGlobal: true,
webPushVapidPublicKey: null,
webPushVapidSubject: null,
webPushVapidPrivateKeyEncrypted: null,
apiGlobal: true,
umamiEnabled: false,
umamiScriptUrl: null,
umamiWebsiteId: null,
glitchtipEnabled: false,
glitchtipDsn: null,
glitchtipEnvironment: null,
githubIssueRepo: null,
githubIssueTokenEncrypted: null,
bugReportEnabled: true,
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
moodLogGlobal: true,
defaultUserTimezone: "Pacific/Auckland",
} as never);
const res = await PUT(jsonReq({ defaultUserTimezone: "Pacific/Auckland" }));
expect(res.status).toBe(200);
const args = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
expect(args?.update).toEqual({ defaultUserTimezone: "Pacific/Auckland" });
});
it("clears the server-default timezone when given empty string", async () => {
vi.mocked(prisma.appSettings.upsert).mockResolvedValue({
id: "singleton",
registrationEnabled: true,
defaultLocale: "de",
telegramGlobal: true,
ntfyGlobal: true,
webPushGlobal: true,
webPushVapidPublicKey: null,
webPushVapidSubject: null,
webPushVapidPrivateKeyEncrypted: null,
apiGlobal: true,
umamiEnabled: false,
umamiScriptUrl: null,
umamiWebsiteId: null,
glitchtipEnabled: false,
glitchtipDsn: null,
glitchtipEnvironment: null,
githubIssueRepo: null,
githubIssueTokenEncrypted: null,
bugReportEnabled: true,
reminderLateMinutes: 120,
reminderMissedMinutes: 240,
moodLogGlobal: true,
defaultUserTimezone: null,
} as never);
const res = await PUT(jsonReq({ defaultUserTimezone: "" }));
expect(res.status).toBe(200);
const args = vi.mocked(prisma.appSettings.upsert).mock.calls[0]?.[0];
expect(args?.update).toEqual({ defaultUserTimezone: null });
});
it("returns 422 for an invalid IANA zone in defaultUserTimezone", async () => {
const res = await PUT(jsonReq({ defaultUserTimezone: "Mars/Olympus" }));
expect(res.status).toBe(422);
expect(prisma.appSettings.upsert).not.toHaveBeenCalled();
});
});
+33
View File
@@ -11,6 +11,10 @@ import { annotate } from "@/lib/logging/context";
import { encrypt } from "@/lib/crypto";
import { adminSettingsSchema } from "@/lib/validations/admin";
import { NextRequest } from "next/server";
import {
invalidateServerDefaultTimezone,
isValidTimezone,
} from "@/lib/tz/resolver";
export const dynamic = "force-dynamic";
@@ -50,6 +54,10 @@ export const GET = apiHandler(async () => {
reminderLateMinutes: settings?.reminderLateMinutes ?? 120,
reminderMissedMinutes: settings?.reminderMissedMinutes ?? 240,
moodLogGlobal: settings?.moodLogGlobal ?? true,
// v1.4.25 W7 — null means "fall back to Europe/Berlin in the
// resolver"; surfacing the raw value lets the admin UI render
// an empty picker placeholder until they opt in.
defaultUserTimezone: settings?.defaultUserTimezone ?? null,
});
});
@@ -186,6 +194,26 @@ export const PUT = apiHandler(async (request: NextRequest) => {
auditDetails.reminderMissedMinutes = data.reminderMissedMinutes;
}
// v1.4.25 W7 — server-default timezone for new signups.
// Empty string clears the override (resolver falls back to
// Europe/Berlin); a non-empty string must pass Intl validation
// upstream of the column write.
let didTouchTimezone = false;
if (data.defaultUserTimezone !== undefined) {
const trimmed = data.defaultUserTimezone.trim();
if (trimmed === "") {
updates.defaultUserTimezone = null;
auditDetails.defaultUserTimezone = null;
didTouchTimezone = true;
} else if (isValidTimezone(trimmed)) {
updates.defaultUserTimezone = trimmed;
auditDetails.defaultUserTimezone = trimmed;
didTouchTimezone = true;
} else {
return apiError("Not a valid IANA timezone.", 422);
}
}
if (Object.keys(updates).length === 0) {
return apiError("No valid fields", 422);
}
@@ -196,6 +224,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
create: { id: "singleton", ...updates },
});
if (didTouchTimezone) {
invalidateServerDefaultTimezone();
}
await auditLog("admin.settings.update", {
userId: user.id,
ipAddress: getClientIp(request),
@@ -230,5 +262,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
reminderLateMinutes: settings.reminderLateMinutes,
reminderMissedMinutes: settings.reminderMissedMinutes,
moodLogGlobal: settings.moodLogGlobal,
defaultUserTimezone: settings.defaultUserTimezone,
});
});
@@ -44,9 +44,17 @@ vi.mock("@/lib/jobs/worker-status", () => ({
})),
}));
// v1.4.25 W21 Fix-K — `/api/admin/status` now reads the legacy-form
// counter from the Withings webhook module. Mock it at module boundary
// so the test fixture stays deterministic.
vi.mock("@/app/api/withings/webhook/route", () => ({
getLegacyFormTotal: vi.fn(() => 0),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { requireAdmin, HttpError } from "@/lib/api-handler";
import { getLegacyFormTotal } from "@/app/api/withings/webhook/route";
const ADMIN_CTX = {
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
@@ -169,4 +177,13 @@ describe("GET /api/admin/status (integration health summary)", () => {
const tokenCallArgs = vi.mocked(prisma.apiToken.count).mock.calls[0]?.[0];
expect(tokenCallArgs).toEqual({ where: { revoked: false } });
});
it("surfaces the legacy Withings webhook counter on the counters block (Fix-K sec-M2)", async () => {
vi.mocked(getLegacyFormTotal).mockReturnValue(7);
const res = await GET();
const body = (await res.json()) as {
data: { counters: { withingsWebhookLegacyFormTotal: number } };
};
expect(body.data.counters.withingsWebhookLegacyFormTotal).toBe(7);
});
});
+7
View File
@@ -3,6 +3,7 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { getWorkerStatus } from "@/lib/jobs/worker-status";
import { getLegacyFormTotal } from "@/app/api/withings/webhook/route";
import { readFileSync } from "node:fs";
// Build timestamp (set at build time via next.config)
@@ -89,5 +90,11 @@ export const GET = apiHandler(async () => {
webPush: webPushConfigured ? { configured: true } : null,
bugReport: bugReportConfigured ? { configured: true } : null,
},
// v1.4.25 W21 Fix-K — in-memory counters that the release-gate can
// read to confirm "legacy form usage trending toward zero" before
// the v1.4.27 cut of the legacy Withings webhook route.
counters: {
withingsWebhookLegacyFormTotal: getLegacyFormTotal(),
},
});
});
+282 -60
View File
@@ -5,11 +5,12 @@ import { apiSuccess } from "@/lib/api-response";
import { summarize, type DataPoint } from "@/lib/analytics/trends";
import { getBpTargets } from "@/lib/analytics/bp-targets";
import { computeBpInTargetWindows } from "@/lib/analytics/bp-in-target";
import { berlinDayKey } from "@/lib/analytics/berlin-day";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import { calculateCompliance } from "@/lib/analytics/compliance";
import {
computeHealthScore,
defaultWeightTargetFromHeight,
type ContributingSource,
type HealthScoreInput,
type HealthScoreResult,
} from "@/lib/analytics/health-score";
@@ -19,8 +20,12 @@ import {
correlateWeightWeekday,
type CorrelationResult,
} from "@/lib/insights/correlations";
import type { MeasurementType } from "@/generated/prisma/client";
import type {
MeasurementSource,
MeasurementType,
} from "@/generated/prisma/client";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
export const dynamic = "force-dynamic";
@@ -28,6 +33,14 @@ export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "analytics.get" } });
// v1.4.25 W7b — every day-bucket call inside this route now honours
// the user's display timezone. The legacy `berlinDayKey()` import
// remains for sleep-stage and correlation paths that share their
// helper signature with non-tz-aware code (`computeSleepStageBreakdown`
// is called with a userId only); the per-call sites below all pass
// `userTz` through `userDayKey()`.
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
// Derived from canonical enum so a new measurement type is auto-summarised
// by /api/analytics (V3 audit: enum drift cousins).
const types = [...measurementTypeEnum.options] as MeasurementType[];
@@ -38,6 +51,12 @@ export const GET = apiHandler(async () => {
// sync history. `summarize()` requires the full series (slope7/30/90,
// anomalies) so groupBy cannot replace this read; the chunked path is
// the smallest pagination contract that still satisfies the helper.
// v1.4.25 W5e — per-metric-class source priority pulled once. The
// aggregator passes the persisted JSON straight through so the
// helper's `parseSourcePriority` runs and falls back to defaults
// when the column is null or malformed.
const sourcePriorityJson = user.sourcePriorityJson;
let totalRowsReadForAggregate = 0;
const measurementsByType = await Promise.all(
types.map((type) =>
@@ -53,9 +72,21 @@ export const GET = apiHandler(async () => {
// = total minutes asleep).
let datapoints: DataPoint[];
if (type === "SLEEP_DURATION") {
// v1.4.25 W5e — pick ONE source per day before summing the
// night's stages. With only WITHINGS + MANUAL today, the
// picker passes everything through; once iOS passthrough
// lands (v1.5) the picker prevents double-counted nights
// (HealthKit forwards Withings' Sleep summary to iOS in
// addition to ScanWatch's own stream).
const sleepRows = pickCanonicalSourceRows(
measurements,
"sleep",
sourcePriorityJson,
(d) => userDayKey(d, userTz),
).canonicalRows;
const byDay = new Map<string, { total: number; date: Date }>();
for (const m of measurements) {
const key = berlinDayKey(m.measuredAt);
for (const m of sleepRows) {
const key = userDayKey(m.measuredAt, userTz);
const slot = byDay.get(key) ?? {
total: 0,
date: m.measuredAt,
@@ -104,7 +135,7 @@ export const GET = apiHandler(async () => {
// included when the user has stage-tagged rows in window; null
// otherwise so the UI can render a plain total without the
// breakdown card painting empty.
const sleepStages = await computeSleepStageBreakdown(user.id);
const sleepStages = await computeSleepStageBreakdown(user.id, userTz);
// BMI calculation
let bmi: number | null = null;
@@ -202,7 +233,7 @@ export const GET = apiHandler(async () => {
// floor from 14 → 20). Each runner gates on n >= 20 + p < 0.05;
// below the bar the result.status === "insufficient" and the UI
// paints an EmptyState.
const correlations = await computeCorrelationHypotheses(user.id);
const correlations = await computeCorrelationHypotheses(user.id, userTz);
// v1.4.20 phase B5 — Personal Health Score. Server-deterministic
// composite of BP-in-target % + weight-trend alignment + mood
@@ -248,12 +279,21 @@ export const GET = apiHandler(async () => {
* `summaries.SLEEP_DURATION` totals without a stage card in that
* case. Returns the sum-per-stage AND the day count covered so the
* UI can render an "averaged across N nights" caption truthfully.
*
* v1.4.25 W3f — also returns a `perNight` array: one entry per
* Berlin-tz day inside the window, with minutes-per-stage for the
* stacked-bar chart. Days with zero stage-tagged rows are omitted
* from `perNight` (the chart treats them as gaps).
*/
async function computeSleepStageBreakdown(userId: string): Promise<{
async function computeSleepStageBreakdown(
userId: string,
userTz: string,
): Promise<{
windowDays: number;
nights: number;
totalMinutes: number;
stages: Record<string, number>;
perNight: Array<{ dayKey: string; stages: Record<string, number> }>;
} | null> {
const DAY_MS = 24 * 60 * 60 * 1000;
const since = new Date(Date.now() - 30 * DAY_MS);
@@ -272,18 +312,33 @@ async function computeSleepStageBreakdown(userId: string): Promise<{
const stages: Record<string, number> = {};
const dayKeys = new Set<string>();
let totalMinutes = 0;
// v1.4.25 W3f — per-day accumulator. Keyed by Berlin-tz day so the
// chart's 7/14/30-day slicer can build a left-aligned series.
const perNightMap = new Map<string, Record<string, number>>();
for (const row of rows) {
if (!row.sleepStage) continue;
stages[row.sleepStage] = (stages[row.sleepStage] ?? 0) + row.value;
totalMinutes += row.value;
dayKeys.add(berlinDayKey(row.measuredAt));
const dayKey = userDayKey(row.measuredAt, userTz);
dayKeys.add(dayKey);
const nightStages = perNightMap.get(dayKey) ?? {};
nightStages[row.sleepStage] =
(nightStages[row.sleepStage] ?? 0) + row.value;
perNightMap.set(dayKey, nightStages);
}
// Sort per-night ascending so the chart consumer can slice the
// trailing N entries for the 7d / 14d / 30d toggle.
const perNight = Array.from(perNightMap.entries())
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([dayKey, stages]) => ({ dayKey, stages }));
return {
windowDays: 30,
nights: dayKeys.size,
totalMinutes,
stages,
perNight,
};
}
@@ -295,7 +350,10 @@ async function computeSleepStageBreakdown(userId: string): Promise<{
* because the user-facing "based on N paired readings · last 30 days"
* source-chip has to remain truthful.
*/
async function computeCorrelationHypotheses(userId: string): Promise<{
async function computeCorrelationHypotheses(
userId: string,
userTz: string,
): Promise<{
bpCompliance: CorrelationResult;
moodPulse: CorrelationResult;
weightWeekday: CorrelationResult;
@@ -324,10 +382,10 @@ async function computeCorrelationHypotheses(userId: string): Promise<{
]);
// ── Hypothesis 1: BP × medication compliance ────────────────
// Aggregate by Berlin-day key so DST + UTC boundary issues don't split
// a day's readings. The day's "compliance %" is taken / expected for
// that calendar day across all medications.
const dayKey = (d: Date): string => berlinDayKey(d);
// Aggregate by the user's display-tz day key so DST + UTC boundary
// issues don't split a day's readings. The day's "compliance %" is
// taken / expected for that calendar day across all medications.
const dayKey = (d: Date): string => userDayKey(d, userTz);
const dailySys = new Map<string, number[]>();
for (const row of sysRows) {
@@ -360,7 +418,7 @@ async function computeCorrelationHypotheses(userId: string): Promise<{
const compliancePct = (slot.taken / slot.expected) * 100;
const meanSys = sysValues.reduce((s, v) => s + v, 0) / sysValues.length;
bpCompliancePairs.push({
date: dateFromBerlinKey(key),
date: dateFromDayKey(key),
systolic: meanSys,
compliancePct,
});
@@ -373,14 +431,14 @@ async function computeCorrelationHypotheses(userId: string): Promise<{
// resting-pulse field, so we accept the noise rather than skip.
const dailyMood = new Map<string, number[]>();
for (const row of moodRows) {
const key = berlinDayKey(row.moodLoggedAt);
const key = userDayKey(row.moodLoggedAt, userTz);
const list = dailyMood.get(key) ?? [];
list.push(row.score);
dailyMood.set(key, list);
}
const dailyPulse = new Map<string, number[]>();
for (const row of pulseRows) {
const key = berlinDayKey(row.measuredAt);
const key = userDayKey(row.measuredAt, userTz);
const list = dailyPulse.get(key) ?? [];
list.push(row.value);
dailyPulse.set(key, list);
@@ -397,7 +455,7 @@ async function computeCorrelationHypotheses(userId: string): Promise<{
const meanPulse =
pulseValues.reduce((s, v) => s + v, 0) / pulseValues.length;
moodPulsePairs.push({
date: dateFromBerlinKey(key),
date: dateFromDayKey(key),
mood: meanMood,
restingPulse: meanPulse,
});
@@ -406,9 +464,17 @@ async function computeCorrelationHypotheses(userId: string): Promise<{
// ── Hypothesis 3: Weight × weekday ──────────────────────────
// 0 = Monday … 6 = Sunday. ISO weekday minus 1.
//
// v1.4.25 W10 reconcile (Code-H1) — buckets the weekday by the
// user's display tz, matching W7's per-user-tz threading that the
// BP, mood, sleep, and pulse aggregators above already honour. The
// pre-W10 helper was pinned to `Europe/Berlin` and would land a
// user-local 23:30 weight reading from `Pacific/Auckland` under
// Sunday's bucket instead of Monday's — Pearson on the wrong
// weekday column.
const weightWeekdayPairs: Array<{ weekday: number; weight: number }> = [];
for (const row of weightRows) {
const isoWeekday = berlinIsoWeekday(row.measuredAt); // 1..7, 1=Mon
const isoWeekday = isoWeekdayInTz(row.measuredAt, userTz); // 1..7, 1=Mon
weightWeekdayPairs.push({
weekday: isoWeekday - 1,
weight: row.value,
@@ -464,6 +530,22 @@ interface ChunkedRow {
measuredAt: Date;
value: number;
sleepStage: string | null;
/** v1.4.25 W5e — needed by `pickCanonicalSourceRows` so the SLEEP /
* cumulative aggregators can pick ONE source per day when more than
* one ingest path contributes to the same metric. */
source: MeasurementSource;
/** v1.4.25 W8c — second axis of the canonical picker. Nullable until
* the iOS app starts shipping HKDevice.model with each sample;
* legacy / Withings rows stay NULL and the picker treats them as
* `unknown`. Carried on every type's read so the cumulative-metric
* path can break Apple-Watch-vs-iPhone ties.
*/
deviceType: string | null;
/** v1.4.25 W8c — feeds the per-MeasurementType device-type override
* inside the picker. The picker keys
* `deviceTypePriority[type]` off this so the user's "phone wins for
* steps but watch wins for HR" config is honoured. */
type: MeasurementType;
}
async function fetchMeasurementSeriesChunked(
@@ -485,6 +567,11 @@ async function fetchMeasurementSeriesChunked(
id: true,
measuredAt: true,
value: true,
source: true,
// v1.4.25 W8c — read deviceType so the canonical picker can
// honour the per-metric / per-device override. Nullable until
// iOS sends it.
deviceType: true,
...(options.includeSleepStage ? { sleepStage: true } : {}),
},
take: MEASUREMENT_CHUNK_SIZE,
@@ -495,6 +582,9 @@ async function fetchMeasurementSeriesChunked(
out.push({
measuredAt: row.measuredAt,
value: row.value,
source: row.source,
deviceType: row.deviceType ?? null,
type,
sleepStage:
"sleepStage" in row
? ((row.sleepStage as string | null) ?? null)
@@ -511,18 +601,34 @@ async function fetchMeasurementSeriesChunked(
// `src/lib/analytics/berlin-day.ts` so the targets route's sparkline
// bucketing shares the same Europe/Berlin contract. The
// `weekday: "short"` formatter still lives here because it's only
// used by `berlinIsoWeekday()` below.
const BERLIN_DATE_PARTS = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit",
weekday: "short",
});
// used by `isoWeekdayInTz()` below.
//
// v1.4.25 W10 reconcile (Code-H1) — formatter is now per-tz so the
// weight-weekday correlator honours the same per-user-tz contract the
// BP/mood/pulse aggregators above already use. Memoised by tz so the
// formatter is built once per unique timezone per process (tz strings
// rarely change at runtime; the cache is bounded by the IANA database
// to a few hundred entries even in the worst case).
const WEEKDAY_FORMATTER_CACHE = new Map<string, Intl.DateTimeFormat>();
function getWeekdayFormatter(timeZone: string): Intl.DateTimeFormat {
let formatter = WEEKDAY_FORMATTER_CACHE.get(timeZone);
if (!formatter) {
formatter = new Intl.DateTimeFormat("en-CA", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
weekday: "short",
});
WEEKDAY_FORMATTER_CACHE.set(timeZone, formatter);
}
return formatter;
}
function dateFromBerlinKey(key: string): Date {
function dateFromDayKey(key: string): Date {
// Anchor to UTC midnight — the date is a sortable bucket label rather
// than a wall-clock timestamp, so DST drift is irrelevant.
// than a wall-clock timestamp, so DST drift is irrelevant. The tz
// info is already baked into the key (built via `userDayKey` upstream).
return new Date(`${key}T00:00:00.000Z`);
}
@@ -536,12 +642,58 @@ const ISO_WEEKDAY: Record<string, number> = {
Sun: 7,
};
function berlinIsoWeekday(d: Date): number {
const parts = BERLIN_DATE_PARTS.formatToParts(d);
function isoWeekdayInTz(d: Date, timeZone: string): number {
const parts = getWeekdayFormatter(timeZone).formatToParts(d);
const weekday = parts.find((p) => p.type === "weekday")?.value ?? "Mon";
return ISO_WEEKDAY[weekday] ?? 1;
}
/**
* v1.4.25 W8e — collapse the persisted `MeasurementSource` enum onto
* the camelCase token set the health-score analytics layer consumes.
*
* - `MANUAL` and `IMPORT` (CSV import — still user-supplied data) both
* surface as `"manual"`.
* - `WITHINGS` and `APPLE_HEALTH` ride one-to-one.
*
* Returns `null` for source values that don't fall into the three
* exposed buckets — defence-in-depth in case the enum grows before the
* client is taught about it.
*/
function mapMeasurementSourceToLabel(
source: MeasurementSource,
): ContributingSource | null {
switch (source) {
case "MANUAL":
case "IMPORT":
return "manual";
case "WITHINGS":
return "withings";
case "APPLE_HEALTH":
return "appleHealth";
default:
return null;
}
}
/**
* v1.4.25 W8e — deduplicate the contributing-source list for a single
* component. Returns the empty array when nothing in the input maps
* onto a known label so downstream `resolveSourceLabel` falls through
* to `none` (matches the empty-state branch).
*/
function uniqueComponentSources(
rows: ReadonlyArray<MeasurementSource>,
): ReadonlyArray<ContributingSource> {
const seen = new Set<ContributingSource>();
for (const src of rows) {
const label = mapMeasurementSourceToLabel(src);
if (label) seen.add(label);
}
return Array.from(seen);
}
/**
* Build the Health Score input from the user's last-30-day weight,
* mood, and medication-compliance data, plus the already-computed
@@ -564,38 +716,58 @@ async function computeUserHealthScore(
const prevSince30d = new Date(now.getTime() - 37 * DAY_MS);
const prevUntil = new Date(now.getTime() - 7 * DAY_MS);
const [weightRows, moodRows, medications] = await Promise.all([
prisma.measurement.findMany({
where: {
userId,
type: "WEIGHT",
measuredAt: { gte: prevSince30d, lte: now },
},
select: { value: true, measuredAt: true },
orderBy: { measuredAt: "asc" },
}),
prisma.moodEntry.findMany({
where: {
userId,
moodLoggedAt: { gte: prevSince30d, lte: now },
},
select: { score: true, moodLoggedAt: true },
orderBy: { moodLoggedAt: "asc" },
}),
prisma.medication.findMany({
where: { userId, active: true },
select: {
id: true,
createdAt: true,
schedules: {
select: {
windowStart: true,
windowEnd: true,
// v1.4.25 W8e — read the `source` column alongside the value so the
// health-score provenance accordion knows which ingest path drove
// each component. The weight + BP reads already paid the row cost;
// adding the column to the SELECT is free in Postgres terms (no extra
// round-trip, no extra plan node) and the alternative — a second
// aggregate just for the source pill — would burn another findMany.
const [weightRows, bpSysRowsForSource, moodRows, medications] =
await Promise.all([
prisma.measurement.findMany({
where: {
userId,
type: "WEIGHT",
measuredAt: { gte: prevSince30d, lte: now },
},
select: { value: true, measuredAt: true, source: true },
orderBy: { measuredAt: "asc" },
}),
// BP source attribution rides on the systolic-readings row set —
// diastolic rows always carry the same `source` because both
// halves of a pair are persisted in the same write. Pull only the
// trailing 30 days to keep the call bounded for power users.
prisma.measurement.findMany({
where: {
userId,
type: "BLOOD_PRESSURE_SYS",
measuredAt: { gte: since30d, lte: now },
},
select: { measuredAt: true, source: true },
orderBy: { measuredAt: "asc" },
}),
prisma.moodEntry.findMany({
where: {
userId,
moodLoggedAt: { gte: prevSince30d, lte: now },
},
select: { score: true, moodLoggedAt: true },
orderBy: { moodLoggedAt: "asc" },
}),
prisma.medication.findMany({
where: { userId, active: true },
select: {
id: true,
createdAt: true,
schedules: {
select: {
windowStart: true,
windowEnd: true,
},
},
},
},
}),
]);
}),
]);
// Compliance30 per active medication, then again for the prior-week
// snapshot. The compliance helper anchors on `Date.now()` internally;
@@ -688,12 +860,62 @@ async function computeUserHealthScore(
return null;
}
// v1.4.25 W8e — build per-component source attribution from the rows
// we already hold in memory. `mapMeasurementSourceToLabel` collapses
// the persisted `MeasurementSource` enum onto the camelCase token list
// the analytics helper consumes; `IMPORT` rides under `manual`
// because the v1.4.20 CSV importer ingests user-supplied data — it's
// not a wearable stream.
const windowEndAt = now.toISOString();
const weightSourcesIn30d = uniqueComponentSources(
weightRows
.filter((r) => r.measuredAt >= since30d)
.map((r) => r.source),
);
const latestWeightInWindow = weightRows
.filter((r) => r.measuredAt >= since30d)
.at(-1);
const bpSourceTokens = uniqueComponentSources(
bpSysRowsForSource.map((r) => r.source),
);
const latestBpInWindow = bpSysRowsForSource.at(-1);
// Mood doesn't yet have a non-manual ingest (v1.5 will introduce
// Apple Health mood) so the source list is always `["manual"]` when
// there are entries in window. Keep the lookup explicit so the
// v1.5 ingest path slot just drops in.
const moodSourceTokens = moodSeriesLast30d.length > 0
? (["manual"] as const)
: [];
const latestMoodInWindow = moodRows
.filter((r) => r.moodLoggedAt >= since30d)
.at(-1);
// Medication compliance always derives from logged intake events —
// user-driven manual logging today.
const complianceSourceTokens = medicationCompliance30.length > 0
? (["manual"] as const)
: [];
const current: HealthScoreInput = {
bpInTargetRate: input.bpInTargetPct,
weightSeriesLast30d,
weightTargetKg: fallbackTarget,
moodEntriesLast30d: moodSeriesLast30d,
medicationCompliance30,
attribution: {
bpSources: bpSourceTokens,
asOfBp: latestBpInWindow?.measuredAt.toISOString() ?? null,
weightSources: weightSourcesIn30d,
asOfWeight: latestWeightInWindow?.measuredAt.toISOString() ?? null,
moodSources: moodSourceTokens,
asOfMood: latestMoodInWindow?.moodLoggedAt.toISOString() ?? null,
complianceSources: complianceSourceTokens,
asOfCompliance: complianceSourceTokens.length > 0 ? windowEndAt : null,
windowEndAt,
},
};
// The all-time `bpInTargetPct` is a slow-moving aggregate and would
// need a full historical re-pair to "rewind" by a week. We pass the
@@ -0,0 +1,191 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { DEFAULT_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function mkPut(body: unknown): Request {
return new Request("http://localhost/api/auth/me/doctor-report-prefs", {
method: "PUT",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/doctor-report-prefs", () => {
it("returns defaults when the column is null", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
doctorReportPrefsJson: null,
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/doctor-report-prefs"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: typeof DEFAULT_DOCTOR_REPORT_PREFS;
};
expect(env.data).toEqual(DEFAULT_DOCTOR_REPORT_PREFS);
expect(env.data.mood).toBe(false); // privacy default per Marc
});
});
describe("PUT /api/auth/me/doctor-report-prefs", () => {
// ── Case 1 — happy path ─────────────────────────────────────────────
it("persists the full shape and returns the canonical form", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
doctorReportPrefsJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const body = {
bp: true,
weight: true,
pulse: false,
bmi: true,
mood: true,
compliance: true,
sleep: false,
};
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
expect(res.status).toBe(200);
const env = (await res.json()) as { data: typeof body };
expect(env.data).toEqual(body);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { doctorReportPrefsJson: body },
});
});
// ── Case 2 — partial update layers over current persisted row ───────
it("merges a partial update over the persisted row", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const current = {
bp: false,
weight: false,
pulse: false,
bmi: false,
mood: true,
compliance: false,
sleep: false,
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
doctorReportPrefsJson: current,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
// User only flips mood off — every other key keeps its persisted value.
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ mood: false }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as { data: typeof current };
expect(env.data).toEqual({ ...current, mood: false });
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { doctorReportPrefsJson: { ...current, mood: false } },
});
});
// ── Case 3 — invalid shape ─────────────────────────────────────────
it("rejects a non-boolean value with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ bp: "nope" }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
// ── Case 4 — unauthenticated cross-user request ────────────────────
// Marc's spec mentions "cross-user 404" — the route layer returns 401
// when there is no session at all, which is the only "you are not the
// right user" surface available before requireAuth() resolves an
// identity. `prisma.user.update` must NOT fire in this case.
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ mood: false }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
// ── Case 5 — audit trail (v1.4.25 W10 reconcile security M-3) ──────
it("writes an audit-log entry with the previous and new pref shape", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const current = {
bp: true,
weight: true,
pulse: true,
bmi: true,
mood: false,
compliance: true,
sleep: true,
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
doctorReportPrefsJson: current,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ mood: true }),
);
expect(res.status).toBe(200);
expect(auditLog).toHaveBeenCalledWith(
"user.doctor-report-prefs.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
previous: current,
next: expect.objectContaining({ mood: true }),
}),
}),
);
});
});
@@ -0,0 +1,112 @@
/**
* v1.4.25 W6c — per-user Doctor-Report section toggles.
*
* GET /api/auth/me/doctor-report-prefs — returns the resolved prefs
* (defaults when the row is null).
* PUT /api/auth/me/doctor-report-prefs — merges the supplied partial
* shape over the current row
* and persists the canonical
* fully-resolved object so a
* future schema migration
* doesn't need to back-fill
* missing keys.
*
* Bearer-auth + cookie-auth both work via the shared `requireAuth()`
* helper. The doctor-report aggregator + PDF renderer both read this
* row at generation time so mood data is dropped at the aggregator
* layer when `mood = false` — the data never leaves the DB.
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import {
doctorReportPrefsSchema,
parseDoctorReportPrefs,
resolveDoctorReportPrefs,
} from "@/lib/validations/doctor-report-prefs";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.doctor-report-prefs.get" } });
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { doctorReportPrefsJson: true },
});
return apiSuccess(parseDoctorReportPrefs(row?.doctorReportPrefsJson));
});
export const PUT = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
let body: unknown;
try {
body = await req.json();
} catch {
throw new HttpError(422, "doctor-report-prefs.body.invalid_json");
}
const parsed = doctorReportPrefsSchema.safeParse(body ?? {});
if (!parsed.success) {
annotate({
action: { name: "auth.me.doctor-report-prefs.put.invalid" },
meta: { issues: parsed.error.issues.length },
});
throw new HttpError(422, "doctor-report-prefs.body.invalid_shape");
}
// Merge the partial input over the current row (or defaults) so a
// dialog that only toggles `mood` doesn't have to re-state every other
// flag. Persist the canonical fully-resolved object so the column
// shape stays stable across future schema additions.
const current = await prisma.user.findUnique({
where: { id: user.id },
select: { doctorReportPrefsJson: true },
});
const merged = resolveDoctorReportPrefs(
current?.doctorReportPrefsJson,
parsed.data,
);
await prisma.user.update({
where: { id: user.id },
// `merged` is a typed object; Prisma's `InputJsonValue` insists on an
// index signature so cast through `unknown` rather than widen the
// typed return surface. Shape is identical to the validated input.
data: {
doctorReportPrefsJson: merged as unknown as Record<string, boolean>,
},
});
// v1.4.25 W10 reconcile (security M-3): record the previous + new
// pref shape in the audit log. Doctor-Report pref toggles
// (especially `mood: true`) widen the PDF surface; without an
// audit trail a silent compromise (or unintended client write)
// is invisible. Mirrors the timezone-route audit pattern.
await auditLog("user.doctor-report-prefs.update", {
userId: user.id,
ipAddress: getClientIp(req),
details: {
previous: current?.doctorReportPrefsJson ?? null,
next: merged,
},
});
annotate({
action: { name: "auth.me.doctor-report-prefs.put" },
meta: {
bp: merged.bp,
weight: merged.weight,
pulse: merged.pulse,
bmi: merged.bmi,
mood: merged.mood,
compliance: merged.compliance,
sleep: merged.sleep,
},
});
return apiSuccess(merged);
});
@@ -0,0 +1,304 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 4,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: () => ({}),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { DELETE, GET, POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
import { RESEARCH_MODE_DISCLAIMER_VERSION } from "@/lib/medications/glp1-pk";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function mkPost(body: unknown): Request {
return new Request("http://localhost/api/auth/me/research-mode", {
method: "POST",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
function mkDelete(): Request {
return new Request("http://localhost/api/auth/me/research-mode", {
method: "DELETE",
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/research-mode", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/research-mode"),
);
expect(res.status).toBe(401);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("returns the disabled default for a fresh user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
researchModeEnabled: false,
researchModeAcknowledgedAt: null,
researchModeAcknowledgedVersion: null,
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/research-mode"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: {
enabled: boolean;
acknowledgedAt: string | null;
acknowledgedVersion: string | null;
currentDisclaimerVersion: string;
};
};
expect(env.data.enabled).toBe(false);
expect(env.data.acknowledgedAt).toBeNull();
expect(env.data.acknowledgedVersion).toBeNull();
expect(env.data.currentDisclaimerVersion).toBe(
RESEARCH_MODE_DISCLAIMER_VERSION,
);
});
it("returns the enabled state with the persisted version", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const ackAt = new Date("2026-05-14T10:00:00Z");
vi.mocked(prisma.user.findUnique).mockResolvedValue({
researchModeEnabled: true,
researchModeAcknowledgedAt: ackAt,
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
} as never);
const res = await (GET as (r: Request) => Promise<Response>)(
new Request("http://localhost/api/auth/me/research-mode"),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: {
enabled: boolean;
acknowledgedAt: string;
acknowledgedVersion: string;
currentDisclaimerVersion: string;
};
};
expect(env.data.enabled).toBe(true);
expect(env.data.acknowledgedAt).toBe(ackAt.toISOString());
expect(env.data.acknowledgedVersion).toBe(
RESEARCH_MODE_DISCLAIMER_VERSION,
);
});
});
describe("POST /api/auth/me/research-mode", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (POST as (r: Request) => Promise<Response>)(
mkPost({ acknowledged: true, version: RESEARCH_MODE_DISCLAIMER_VERSION }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("enables Research Mode and writes the audit log on a valid ack", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
researchModeEnabled: false,
researchModeAcknowledgedAt: null,
researchModeAcknowledgedVersion: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (POST as (r: Request) => Promise<Response>)(
mkPost({ acknowledged: true, version: RESEARCH_MODE_DISCLAIMER_VERSION }),
);
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: { enabled: boolean; acknowledgedVersion: string };
};
expect(env.data.enabled).toBe(true);
expect(env.data.acknowledgedVersion).toBe(
RESEARCH_MODE_DISCLAIMER_VERSION,
);
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "user-1" },
data: expect.objectContaining({
researchModeEnabled: true,
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
}),
}),
);
expect(auditLog).toHaveBeenCalledWith(
"user.research-mode.enable",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
next: expect.objectContaining({
researchModeEnabled: true,
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
}),
}),
}),
);
});
it("rejects a stale disclaimer version with 400", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (POST as (r: Request) => Promise<Response>)(
mkPost({ acknowledged: true, version: "1970-01-01.0" }),
);
expect(res.status).toBe(400);
expect(prisma.user.update).not.toHaveBeenCalled();
expect(auditLog).not.toHaveBeenCalled();
});
it("rejects a body without acknowledged=true with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (POST as (r: Request) => Promise<Response>)(
mkPost({ version: RESEARCH_MODE_DISCLAIMER_VERSION }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("returns 429 when the per-user rate-limit fires", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 30_000,
});
const res = await (POST as (r: Request) => Promise<Response>)(
mkPost({ acknowledged: true, version: RESEARCH_MODE_DISCLAIMER_VERSION }),
);
expect(res.status).toBe(429);
expect(prisma.user.update).not.toHaveBeenCalled();
});
});
describe("DELETE /api/auth/me/research-mode", () => {
it("rejects an unauthenticated request with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (DELETE as (r: Request) => Promise<Response>)(mkDelete());
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("disables Research Mode and clears the acknowledgment", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
researchModeEnabled: true,
researchModeAcknowledgedAt: new Date(),
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (DELETE as (r: Request) => Promise<Response>)(mkDelete());
expect(res.status).toBe(200);
const env = (await res.json()) as {
data: {
enabled: boolean;
acknowledgedAt: string | null;
acknowledgedVersion: string | null;
};
};
expect(env.data.enabled).toBe(false);
expect(env.data.acknowledgedAt).toBeNull();
expect(env.data.acknowledgedVersion).toBeNull();
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: {
researchModeEnabled: false,
researchModeAcknowledgedAt: null,
researchModeAcknowledgedVersion: null,
},
});
expect(auditLog).toHaveBeenCalledWith(
"user.research-mode.disable",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
next: {
researchModeEnabled: false,
researchModeAcknowledgedAt: null,
researchModeAcknowledgedVersion: null,
},
}),
}),
);
});
it("is idempotent when Research Mode is already off", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
researchModeEnabled: false,
researchModeAcknowledgedAt: null,
researchModeAcknowledgedVersion: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const res = await (DELETE as (r: Request) => Promise<Response>)(mkDelete());
expect(res.status).toBe(200);
// The route writes the canonical off-state even when the
// column is already off — guarantees audit trail symmetry.
expect(prisma.user.update).toHaveBeenCalledTimes(1);
expect(auditLog).toHaveBeenCalledTimes(1);
});
});
+218
View File
@@ -0,0 +1,218 @@
/**
* v1.4.25 W19c — Research Mode acknowledgment endpoint.
*
* GET /api/auth/me/research-mode — returns the current flag,
* the timestamp + version of
* the last acknowledgment, and
* the live disclaimer version
* so the Settings UI can
* compare and re-prompt.
* POST /api/auth/me/research-mode — body { acknowledged: true,
* version }. Flips the flag
* on. Rejects 400 if `version`
* is not the live version.
* DELETE /api/auth/me/research-mode — disables Research Mode and
* clears the acknowledgment.
* Idempotent.
*
* Per-user POST rate-limit is intentionally low (5/min) — this is a
* one-tap acknowledgment dialog, not a hot path. Anything above that
* traffic looks like a malformed client retry-loop or an exploit.
*
* Prompt-injection surface — the API accepts the disclaimer
* `version` string and nothing else from the client. The version
* value never reaches the LLM; it is matched against the
* server-side constant `RESEARCH_MODE_DISCLAIMER_VERSION` and then
* persisted as the stamp on the user row. No free-text body.
*
* Audit-log entries fire on every state-changing call (POST, DELETE)
* with the previous + next state, mirroring the doctor-report-prefs
* route's reconcile pattern (W10 security M-3).
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { RESEARCH_MODE_DISCLAIMER_VERSION } from "@/lib/medications/glp1-pk";
export const dynamic = "force-dynamic";
const POST_RATE_LIMIT = 5;
const POST_WINDOW_MS = 60_000;
type ResearchModeResponse = {
enabled: boolean;
acknowledgedAt: string | null;
acknowledgedVersion: string | null;
currentDisclaimerVersion: string;
};
function shape(row: {
researchModeEnabled: boolean;
researchModeAcknowledgedAt: Date | null;
researchModeAcknowledgedVersion: string | null;
}): ResearchModeResponse {
return {
enabled: row.researchModeEnabled,
acknowledgedAt: row.researchModeAcknowledgedAt
? row.researchModeAcknowledgedAt.toISOString()
: null,
acknowledgedVersion: row.researchModeAcknowledgedVersion,
currentDisclaimerVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
};
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.research-mode.get" } });
const row = await prisma.user.findUnique({
where: { id: user.id },
select: {
researchModeEnabled: true,
researchModeAcknowledgedAt: true,
researchModeAcknowledgedVersion: true,
},
});
// `requireAuth` already proved the user exists; defensive default
// here only matters for the (impossible-in-practice) race where
// the user is deleted mid-request.
return apiSuccess(
shape(
row ?? {
researchModeEnabled: false,
researchModeAcknowledgedAt: null,
researchModeAcknowledgedVersion: null,
},
),
);
});
export const POST = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
// Per-user rate-limit. 5/min is generous for a once-per-update
// acknowledgment dialog; anything more looks like a retry loop.
const rl = await checkRateLimit(
`research-mode:post:${user.id}`,
POST_RATE_LIMIT,
POST_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many requests", 429);
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
let body: unknown;
try {
body = await req.json();
} catch {
throw new HttpError(422, "research-mode.body.invalid_json");
}
// Hand-rolled validation — the body is tiny (two fields), and
// staying off Zod keeps the prompt-injection surface visibly
// simple in a code review.
if (
!body ||
typeof body !== "object" ||
(body as { acknowledged?: unknown }).acknowledged !== true ||
typeof (body as { version?: unknown }).version !== "string"
) {
annotate({ action: { name: "auth.me.research-mode.post.invalid_shape" } });
throw new HttpError(422, "research-mode.body.invalid_shape");
}
const submittedVersion = (body as { version: string }).version;
// Stale-version guard — the dialog must show the *live* copy at
// the moment of acknowledgment. A stale version means the user
// is acknowledging older wording; we refuse and force the
// Settings UI to re-render with the new disclaimer.
if (submittedVersion !== RESEARCH_MODE_DISCLAIMER_VERSION) {
annotate({
action: { name: "auth.me.research-mode.post.stale_version" },
meta: {
submitted: submittedVersion,
current: RESEARCH_MODE_DISCLAIMER_VERSION,
},
});
throw new HttpError(400, "research-mode.version.stale");
}
const previous = await prisma.user.findUnique({
where: { id: user.id },
select: {
researchModeEnabled: true,
researchModeAcknowledgedAt: true,
researchModeAcknowledgedVersion: true,
},
});
const next = {
researchModeEnabled: true,
researchModeAcknowledgedAt: new Date(),
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
};
await prisma.user.update({
where: { id: user.id },
data: next,
});
await auditLog("user.research-mode.enable", {
userId: user.id,
ipAddress: getClientIp(req),
details: {
previous: previous ?? null,
next: {
...next,
researchModeAcknowledgedAt: next.researchModeAcknowledgedAt.toISOString(),
},
},
});
annotate({
action: { name: "auth.me.research-mode.post" },
meta: { version: RESEARCH_MODE_DISCLAIMER_VERSION },
});
return apiSuccess(shape(next));
});
export const DELETE = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
const previous = await prisma.user.findUnique({
where: { id: user.id },
select: {
researchModeEnabled: true,
researchModeAcknowledgedAt: true,
researchModeAcknowledgedVersion: true,
},
});
// Idempotent — if Research Mode is already off, we still write
// the canonical "off" state so the audit row exists.
const next = {
researchModeEnabled: false,
researchModeAcknowledgedAt: null,
researchModeAcknowledgedVersion: null,
};
await prisma.user.update({
where: { id: user.id },
data: next,
});
await auditLog("user.research-mode.disable", {
userId: user.id,
ipAddress: getClientIp(req),
details: { previous: previous ?? null, next },
});
annotate({ action: { name: "auth.me.research-mode.delete" } });
return apiSuccess(shape(next));
});
@@ -0,0 +1,138 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function mkPut(body: unknown): Request {
return new Request("http://localhost/api/auth/me/source-priority", {
method: "PUT",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("GET /api/auth/me/source-priority", () => {
it("rejects unauthenticated requests with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (GET as () => Promise<Response>)();
expect(res.status).toBe(401);
});
it("returns the resolved shape with defaults when the column is null", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
} as never);
const res = await (GET as () => Promise<Response>)();
expect(res.status).toBe(200);
const env = (await res.json()) as { data: Record<string, unknown> };
// Resolver always emits the keys the analytics layer needs.
expect(env.data).toHaveProperty("weight");
expect(env.data).toHaveProperty("metricPriority");
expect(env.data).toHaveProperty("deviceTypePriority");
});
});
describe("PUT /api/auth/me/source-priority", () => {
it("rejects unauthenticated requests with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"] }),
);
expect(res.status).toBe(401);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("persists the partial shape", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: null,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const body = { weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"] };
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { sourcePriorityJson: body },
});
});
it("rejects an invalid shape with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await (PUT as (r: Request) => Promise<Response>)(
mkPut({ weight: "not-an-array" }),
);
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
// ── v1.4.25 W10 reconcile (security M-3) ──
it("writes an audit-log entry with the previous and new shape", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const previousShape = {
weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"],
};
vi.mocked(prisma.user.findUnique).mockResolvedValue({
sourcePriorityJson: previousShape,
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
const body = { weight: ["APPLE_HEALTH", "WITHINGS", "MANUAL"] };
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
expect(res.status).toBe(200);
expect(auditLog).toHaveBeenCalledWith(
"user.source-priority.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
previous: previousShape,
next: body,
}),
}),
);
});
});
@@ -0,0 +1,94 @@
/**
* v1.4.25 W5e — per-user, per-metric-class source priority.
*
* GET /api/auth/me/source-priority — returns the fully-defaulted
* shape (missing keys filled
* from `DEFAULT_SOURCE_PRIORITY`).
* PUT /api/auth/me/source-priority — replaces the persisted shape;
* body is validated against
* `sourcePrioritySchema` and
* persisted as the partial form
* (missing keys keep the default
* at read time).
*
* Bearer-auth + cookie-auth both work via the shared `requireAuth()`
* helper. The analytics aggregator reads this row on every call and
* the read path is cheap (one column) — no caching layer to flush.
*/
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { apiSuccess, getClientIp } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import {
parseSourcePriority,
sourcePrioritySchema,
} from "@/lib/validations/source-priority";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "auth.me.source-priority.get" } });
const row = await prisma.user.findUnique({
where: { id: user.id },
select: { sourcePriorityJson: true },
});
return apiSuccess(parseSourcePriority(row?.sourcePriorityJson));
});
export const PUT = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
let body: unknown;
try {
body = await req.json();
} catch {
throw new HttpError(422, "source-priority.body.invalid_json");
}
const parsed = sourcePrioritySchema.safeParse(body ?? {});
if (!parsed.success) {
annotate({
action: { name: "auth.me.source-priority.put.invalid" },
meta: { issues: parsed.error.issues.length },
});
throw new HttpError(422, "source-priority.body.invalid_shape");
}
// v1.4.25 W10 reconcile (security M-3): capture the previous shape
// before overwriting so the audit-log entry records the actual
// delta. Source-priority drives every aggregator's source pick,
// so a silent compromise (or accidental client write) was
// previously invisible — mirroring the timezone-route audit
// pattern closes the gap.
const before = await prisma.user.findUnique({
where: { id: user.id },
select: { sourcePriorityJson: true },
});
// Persist the partial form — missing keys read as defaults via
// `parseSourcePriority`. Storing only the user-edited subset keeps
// the Json blob narrow and future-proofs the shape: when a new
// metric class is added, every existing row reads the new key's
// default until the user explicitly changes it.
await prisma.user.update({
where: { id: user.id },
data: { sourcePriorityJson: parsed.data },
});
await auditLog("user.source-priority.update", {
userId: user.id,
ipAddress: getClientIp(req),
details: {
previous: before?.sourcePriorityJson ?? null,
next: parsed.data,
},
});
annotate({
action: { name: "auth.me.source-priority.put" },
meta: { keys: Object.keys(parsed.data).length },
});
return apiSuccess(parseSourcePriority(parsed.data));
});
@@ -0,0 +1,114 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
update: vi.fn(),
},
appSettings: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { PUT } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function mkReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/auth/me/timezone", {
method: "PUT",
body: JSON.stringify(body),
headers: { "Content-Type": "application/json" },
});
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("PUT /api/auth/me/timezone", () => {
it("rejects unauthenticated requests with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await PUT(mkReq({ timezone: "Europe/Berlin" }));
expect(res.status).toBe(401);
});
it("accepts a valid IANA zone and writes it to the user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({
timezone: "Pacific/Auckland",
} as never);
const res = await PUT(mkReq({ timezone: "Pacific/Auckland" }));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { timezone: "Pacific/Auckland" },
});
});
it("rejects an invalid timezone with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await PUT(mkReq({ timezone: "Mars/Olympus" }));
expect(res.status).toBe(422);
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("rejects an empty timezone with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await PUT(mkReq({ timezone: "" }));
expect(res.status).toBe(422);
});
it("rejects a missing timezone field with 422", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await PUT(mkReq({}));
expect(res.status).toBe(422);
});
it("trims whitespace before validating", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
timezone: "Europe/Berlin",
} as never);
vi.mocked(prisma.user.update).mockResolvedValue({
timezone: "Asia/Tokyo",
} as never);
const res = await PUT(mkReq({ timezone: " Asia/Tokyo " }));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { timezone: "Asia/Tokyo" },
});
});
});
+78
View File
@@ -0,0 +1,78 @@
/**
* PUT /api/auth/me/timezone
*
* v1.4.25 W7 — per-user timezone setter. Lifts the timezone write
* out of `PUT /api/auth/profile` so the picker UI can post the
* single field without round-tripping email/heightCm/etc, and so
* the change pipeline (validate → write → invalidate-cache) is
* isolated from the heavier profile patch.
*
* Auth: cookie session OR Bearer token.
* Audit: `user.timezone.update` with the old + new zone.
*
* Validation: the body is `{ "timezone": "Europe/Berlin" }`. We
* accept any string the runtime's `Intl.DateTimeFormat` constructor
* accepts — i.e. every IANA zone the active Node engine knows about.
* Rejecting unknown zones at the API surface stops a malformed
* client from poisoning the column with a value the resolver
* would later have to defend against.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { invalidateUserTimezone, isValidTimezone } from "@/lib/tz/resolver";
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "user.timezone.update" } });
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const tz =
body && typeof (body as { timezone?: unknown }).timezone === "string"
? ((body as { timezone: string }).timezone || "").trim()
: "";
if (!isValidTimezone(tz)) {
return apiError("Not a valid IANA timezone.", 422);
}
const before = await prisma.user.findUnique({
where: { id: user.id },
select: { timezone: true },
});
await prisma.user.update({
where: { id: user.id },
data: { timezone: tz },
});
invalidateUserTimezone(user.id);
await auditLog("user.timezone.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
previous: before?.timezone ?? null,
next: tz,
},
});
annotate({
meta: {
timezone_previous: before?.timezone ?? null,
timezone_next: tz,
},
});
return apiSuccess({ timezone: tz });
});
+21 -2
View File
@@ -15,6 +15,10 @@ import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import {
isValidTimezone,
resolveServerDefaultTimezone,
} from "@/lib/tz/resolver";
export const POST = apiHandler(async (request: NextRequest) => {
const ip = getClientIp(request) ?? "unknown";
@@ -59,7 +63,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError(parsed.error.issues[0].message, 422);
}
const { email, username, password } = parsed.data;
const { email, username, password, timezone: timezoneInput } = parsed.data;
// v1.4.25 W7 — accept a browser-detected timezone from the
// registration form. Validate against the runtime IANA list; on
// any invalid value (or absence), fall back to the admin-
// configured server default. The `User.timezone` column has a
// hard-coded "Europe/Berlin" default at the schema layer, so the
// worst-case chain still produces a usable string.
let timezone: string;
const trimmedTimezone = timezoneInput?.trim() ?? "";
if (trimmedTimezone && isValidTimezone(trimmedTimezone)) {
timezone = trimmedTimezone;
} else {
timezone = await resolveServerDefaultTimezone();
}
// Check if email or username already taken (unified message to prevent enumeration)
const [existingEmail, existingUsername] = await Promise.all([
@@ -91,6 +109,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
username,
passwordHash,
role,
timezone,
},
});
@@ -104,7 +123,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
await auditLog("auth.register", {
userId: user.id,
ipAddress: ip,
details: { method: "password" },
details: { method: "password", timezone },
});
annotate({ action: { name: "auth.register" } });
+203
View File
@@ -0,0 +1,203 @@
/**
* GET /api/dashboard/glp1 — v1.4.25 W6
*
* Powers the dashboard's `<Glp1Tile>`. Returns the user's active GLP-1
* medications (treatmentClass = "GLP1"), each enriched with a tiny
* weight series + the dates the user logged an injection. Returns
* `null` data when the user has no active GLP-1 medication — the tile
* then suppresses itself per Marc's empty-state hide rule.
*
* Bridge layer: the heavy lifting (current dose / last + next injection
* / titration history / inventory math) is delegated to
* `buildGlp1SnapshotBlock` so the Coach's GLP-1-aware reply path and
* this dashboard tile read from the exact same shape. The extras we
* add on top here are purely UI-facing:
* - weightSeries[] — daily aggregates since the first dose change
* (or 90 days back, whichever is later) for
* the mini-chart on the tile
* - weightDeltaKg — convenience caption value (current − start);
* the tile renders "−4.2 kg seit Beginn" off
* this number, with `null` when either side
* is missing
* - injectionDates[] — `YYYY-MM-DD` keys for every recorded intake
* event so the chart can paint the vertical
* injection-day markers Marc wants
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { buildGlp1SnapshotBlock } from "@/lib/ai/coach/glp1-snapshot";
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
const WEIGHT_LOOKBACK_DAYS = 90;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
function isoDate(d: Date): string {
return d.toISOString().slice(0, 10);
}
/**
* Bucket raw weight measurements into one daily average so the mini-
* chart doesn't render the noisy raw stream. Same `YYYY-MM-DD` keying
* the dashboard chart layer uses (UTC-noon anchor — DST-safe).
*/
function bucketDaily(
rows: Array<{ measuredAt: Date; value: number }>,
): Array<{ date: string; weight: number }> {
const sums = new Map<string, { sum: number; count: number }>();
for (const row of rows) {
const key = isoDate(row.measuredAt);
const slot = sums.get(key) ?? { sum: 0, count: 0 };
slot.sum += row.value;
slot.count += 1;
sums.set(key, slot);
}
return Array.from(sums.entries())
.map(([date, { sum, count }]) => ({
date,
weight: Math.round((sum / count) * 100) / 100,
}))
.sort((a, b) => (a.date < b.date ? -1 : 1));
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
const now = new Date();
const snapshot = await buildGlp1SnapshotBlock(user.id, now);
// No active GLP-1 medication → the tile suppresses itself. We still
// return 200 + `data: null` so the client doesn't have to special-
// case the 404 path.
if (!snapshot) {
annotate({
action: { name: "dashboard.glp1" },
meta: { active: 0 },
});
return apiSuccess(null);
}
// Anchor the weight series to the earliest meaningful date: the user
// wants to see the trend SINCE THEY STARTED the GLP-1, not just the
// last 90 days. We pick the earlier of:
// - the first dose-change `effectiveFrom` across all GLP-1 meds
// - 90 days back from today (so brand-new accounts still get a
// reasonable window when they only just logged their first dose)
let anchor = new Date(now.getTime() - WEIGHT_LOOKBACK_DAYS * MS_PER_DAY);
for (const med of snapshot.medications) {
if (med.doseHistory.length === 0) continue;
const firstDose = med.doseHistory[0].effectiveFrom;
const firstDoseMs = Date.parse(`${firstDose}T00:00:00Z`);
if (Number.isFinite(firstDoseMs) && firstDoseMs < anchor.getTime()) {
anchor = new Date(firstDoseMs);
}
}
const [weights, intakeRows] = await Promise.all([
prisma.measurement.findMany({
where: {
userId: user.id,
type: "WEIGHT",
measuredAt: { gte: anchor },
},
orderBy: { measuredAt: "asc" },
select: { measuredAt: true, value: true },
}),
prisma.medicationIntakeEvent.findMany({
where: {
userId: user.id,
takenAt: { not: null, gte: anchor },
medication: { treatmentClass: "GLP1" },
},
orderBy: { takenAt: "asc" },
select: {
medicationId: true,
takenAt: true,
medication: { select: { name: true } },
},
}),
]);
const weightSeries = bucketDaily(weights);
// Per-med injection date set — used by the chart's vertical markers.
// Multiple injection events on the same day collapse to a single
// marker so the chart doesn't paint stacked overlapping lines.
const injectionDatesByMed = new Map<string, Set<string>>();
for (const ev of intakeRows) {
if (!ev.takenAt) continue;
const set = injectionDatesByMed.get(ev.medicationId) ?? new Set<string>();
set.add(isoDate(ev.takenAt));
injectionDatesByMed.set(ev.medicationId, set);
}
// We also need to know the medication id per snapshot entry. The
// snapshot exposes name + genericName but not the id; re-fetch the
// id list ordered by `createdAt: desc` (matches the snapshot
// ordering) and zip the two lists. Cheap re-query — already in cache
// from the snapshot's first hit.
const idsForOrder = await prisma.medication.findMany({
where: { userId: user.id, treatmentClass: "GLP1", active: true },
orderBy: { createdAt: "desc" },
select: { id: true, name: true },
});
// Build the enriched per-med payload. `startWeight` = the first daily
// bucket on/after the med's first dose-change date; `currentWeight`
// = the most recent daily bucket. Both `null` when no weight data
// exists in the window — the tile suppresses the delta caption then.
const medications = snapshot.medications.map((med, idx) => {
const medId = idsForOrder[idx]?.id ?? null;
let startWeight: number | null = null;
let currentWeight: number | null = null;
let medSeries: Array<{ date: string; weight: number }> = [];
if (weightSeries.length > 0) {
const firstDoseDate =
med.doseHistory.length > 0 ? med.doseHistory[0].effectiveFrom : null;
// Filter the series to "since the user started this medication".
// If we don't have a dose-change row (legacy data) we fall back
// to the whole 90-day window so the chart isn't empty.
medSeries = firstDoseDate
? weightSeries.filter((p) => p.date >= firstDoseDate)
: weightSeries.slice();
startWeight = medSeries[0]?.weight ?? null;
currentWeight = medSeries[medSeries.length - 1]?.weight ?? null;
}
const weightDeltaKg =
startWeight !== null && currentWeight !== null
? Math.round((currentWeight - startWeight) * 100) / 100
: null;
const injectionDates = medId
? Array.from(injectionDatesByMed.get(medId) ?? []).sort()
: [];
return {
...med,
medicationId: medId,
startWeight,
currentWeight,
weightDeltaKg,
weightSeries: medSeries,
injectionDates,
};
});
annotate({
action: { name: "dashboard.glp1" },
meta: {
active: medications.length,
tz: userTz,
},
});
return apiSuccess({
active: snapshot.active,
medications,
});
});
+32 -25
View File
@@ -17,6 +17,7 @@ import type { MeasurementType } from "@/generated/prisma/client";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { defaultLocale, type Locale } from "@/lib/i18n/config";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
const SPARK_DAYS = 7;
const STREAK_WINDOW_DAYS = 365;
@@ -81,35 +82,35 @@ function trendOf(values: number[]): MetricCard["trend"] {
return delta > 0 ? "up" : "down";
}
function startOfDayBerlin(date: Date): Date {
// Compute midnight in Europe/Berlin → UTC ms.
const berlin = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
function startOfDayInTz(date: Date, tz: string): Date {
// Compute midnight in the user's tz → UTC ms.
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
}).formatToParts(date);
const y = berlin.find((p) => p.type === "year")?.value ?? "1970";
const m = berlin.find((p) => p.type === "month")?.value ?? "01";
const d = berlin.find((p) => p.type === "day")?.value ?? "01";
const y = parts.find((p) => p.type === "year")?.value ?? "1970";
const m = parts.find((p) => p.type === "month")?.value ?? "01";
const d = parts.find((p) => p.type === "day")?.value ?? "01";
return new Date(`${y}-${m}-${d}T00:00:00.000Z`);
}
function berlinDayKey(date: Date): string {
return new Intl.DateTimeFormat("sv-SE", {
timeZone: "Europe/Berlin",
}).format(date);
}
interface StreakInfo {
currentDays: number;
longest: number;
}
/** Compute the current logging-day streak (days where any measurement or
* intake event was recorded, in Berlin time) plus the longest streak in
* the last `STREAK_WINDOW_DAYS` days. */
function computeStreak(activityDays: Set<string>): StreakInfo {
* intake event was recorded, in the user's display timezone) plus the
* longest streak in the last `STREAK_WINDOW_DAYS` days.
*
* v1.4.25 W7b — `userTz` parameterises the "today" pivot so a Pacific/
* Auckland user gets their Auckland-day streak rather than the Berlin
* one. The activity-day Set entries are already produced in the same
* zone by the caller (via `userDayKey`), so the cursor walk only needs
* the same zone here to align. */
function computeStreak(activityDays: Set<string>, userTz: string): StreakInfo {
if (activityDays.size === 0) return { currentDays: 0, longest: 0 };
const sorted = [...activityDays].sort();
@@ -126,15 +127,15 @@ function computeStreak(activityDays: Set<string>): StreakInfo {
}
}
// Current streak: walk back from today (Berlin).
const todayKey = berlinDayKey(new Date());
// Current streak: walk back from today (user's tz).
const todayKey = userDayKey(new Date(), userTz);
let currentDays = 0;
let cursor = new Date(`${todayKey}T00:00:00.000Z`);
// Allow yesterday's last day to count if today not yet logged.
if (!activityDays.has(berlinDayKey(cursor))) {
if (!activityDays.has(userDayKey(cursor, userTz))) {
cursor = new Date(cursor.getTime() - 86_400_000);
}
while (activityDays.has(berlinDayKey(cursor))) {
while (activityDays.has(userDayKey(cursor, userTz))) {
currentDays += 1;
cursor = new Date(cursor.getTime() - 86_400_000);
}
@@ -146,12 +147,18 @@ export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "dashboard.summary" } });
// v1.4.25 W7b — anchor every day-bucket call to the user's display
// timezone. Falls back to Europe/Berlin when the column is somehow
// missing (defensive — the schema's NOT NULL default normally pins
// it).
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
const now = new Date();
const sevenDaysAgo = new Date(now.getTime() - SPARK_DAYS * 86_400_000);
const streakWindowStart = new Date(
now.getTime() - STREAK_WINDOW_DAYS * 86_400_000,
);
const todayStart = startOfDayBerlin(now);
const todayStart = startOfDayInTz(now, userTz);
const todayEnd = new Date(todayStart.getTime() + 86_400_000);
// Derived from canonical enum so a new measurement type is auto-included
@@ -192,7 +199,7 @@ export const GET = apiHandler(async () => {
const activityDays = new Set<string>();
for (const m of recentMeasurements)
activityDays.add(berlinDayKey(m.measuredAt));
activityDays.add(userDayKey(m.measuredAt, userTz));
// Pull a wider measurement window for the streak so it isn't capped by
// SPARK_DAYS — but only if the user has any data at all.
if (recentMeasurements.length > 0) {
@@ -203,13 +210,13 @@ export const GET = apiHandler(async () => {
},
select: { measuredAt: true },
});
for (const m of wider) activityDays.add(berlinDayKey(m.measuredAt));
for (const m of wider) activityDays.add(userDayKey(m.measuredAt, userTz));
}
for (const e of streakActivity) {
activityDays.add(berlinDayKey(e.takenAt ?? e.scheduledFor));
activityDays.add(userDayKey(e.takenAt ?? e.scheduledFor, userTz));
}
const streak = computeStreak(activityDays);
const streak = computeStreak(activityDays, userTz);
// Per-type latest + sparkline.
const byType = new Map<MeasurementType, { value: number; at: Date }[]>();
+24 -1
View File
@@ -58,13 +58,36 @@ const layoutSchema = z.object({
// v1.4.18 — per-chart overlay prefs (3 toggles per chart card).
// Optional so older clients that don't know the field can still PUT;
// the resolver coerces malformed values away from the layout blob.
//
// v1.4.25 W6 — switched from `z.record(enum, …)` to `z.partialRecord(…)`
// because Zod v4 changed the semantics of `z.record(enum, …)`: it now
// requires every enum key to be present (a breaking change from
// Zod v3 which behaved like a partial record). With the strict variant
// any PUT that carried `chartOverlayPrefs` with fewer than ALL nine
// chart keys (i.e. every real-world Save click once a user had touched
// a per-chart overlay popover) 422'd with
// `expected: object, path: ["chartOverlayPrefs", "<missing-key>"]` and
// surfaced as the toast `Layout konnte nicht gespeichert werden`.
// Partial-record matches the original intent — overlay prefs are
// per-chart opt-in, the resolver fills in defaults for missing keys.
//
// The inner object also documents `comparisonBaseline` so the
// per-chart `<ChartOverlayControls>` popover (which calls
// `/api/dashboard/chart-overlay-prefs`) and a full-layout PUT from
// Settings → Dashboard can both round-trip the field. Without it Zod
// would silently strip the per-chart `comparisonBaseline` on every
// Save click in Settings, wiping any per-chart toggle the user had
// set via the chart-card popover.
chartOverlayPrefs: z
.record(
.partialRecord(
z.enum(CHART_OVERLAY_KEYS),
z.object({
showTrendIndicator: z.boolean(),
showTrendArrow: z.boolean(),
showTargetRange: z.boolean(),
comparisonBaseline: z
.enum(["none", "lastMonth", "lastYear"])
.optional(),
}),
)
.optional(),
@@ -0,0 +1,121 @@
/**
* v1.4.25 W6c — doctor-report section availability probe.
*
* Returns one boolean per toggleable section so the dialog can hide
* checkboxes for data types that have zero rows in the user-selected
* date range (Marc's "hide-when-empty" rule). Pure presence check —
* `count` queries on small indexed tables — so this is cheap enough to
* run on every date-range change in the dialog without driving cost.
*
* Body shape (mirrors `/api/doctor-report`):
* { startDate?: ISO, endDate?: ISO, days?: number }
*/
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, safeJson } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { normaliseDateRange } from "@/lib/doctor-report-data";
export const dynamic = "force-dynamic";
export interface DoctorReportAvailability {
/** Section flags — `true` when at least one row exists in the range. */
bp: boolean;
weight: boolean;
pulse: boolean;
/** BMI requires both a weight reading AND a configured height. */
bmi: boolean;
mood: boolean;
/** Medication compliance — derived from `MedicationIntakeEvent` rows. */
compliance: boolean;
sleep: boolean;
}
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "doctor-report.availability" } });
const { data: body, error } = await safeJson(request);
if (error) return error;
const range = normaliseDateRange(body ?? undefined);
const { start, end } = range;
// `count` queries are cheaper than `findMany` for a presence check —
// we never read the rows themselves. The user has indexes on
// `(userId, measuredAt)`, `(userId, moodLoggedAt)`, and
// `(userId, scheduledFor)` so these stay sub-millisecond even with
// multi-year ranges.
const [
bpCount,
weightCount,
pulseCount,
moodCount,
complianceCount,
sleepCount,
profile,
] = await Promise.all([
prisma.measurement.count({
where: {
userId: user.id,
measuredAt: { gte: start, lte: end },
type: { in: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"] },
},
}),
prisma.measurement.count({
where: {
userId: user.id,
measuredAt: { gte: start, lte: end },
type: "WEIGHT",
},
}),
prisma.measurement.count({
where: {
userId: user.id,
measuredAt: { gte: start, lte: end },
type: "PULSE",
},
}),
prisma.moodEntry.count({
where: { userId: user.id, moodLoggedAt: { gte: start, lte: end } },
}),
prisma.medicationIntakeEvent.count({
where: { userId: user.id, scheduledFor: { gte: start, lte: end } },
}),
prisma.measurement.count({
where: {
userId: user.id,
measuredAt: { gte: start, lte: end },
type: "SLEEP_DURATION",
},
}),
prisma.user.findUnique({
where: { id: user.id },
select: { heightCm: true },
}),
]);
// BMI is derived (latest weight × profile height) so it only shows up
// when BOTH inputs are available — otherwise the user would tick a
// box that produces a silently-empty section.
const bmi = weightCount > 0 && (profile?.heightCm ?? null) !== null;
const availability: DoctorReportAvailability = {
bp: bpCount > 0,
weight: weightCount > 0,
pulse: pulseCount > 0,
bmi,
mood: moodCount > 0,
compliance: complianceCount > 0,
sleep: sleepCount > 0,
};
annotate({
meta: {
report_days: range.days,
sections_available: Object.values(availability).filter(Boolean).length,
},
});
return apiSuccess(availability);
});
+29 -2
View File
@@ -11,9 +11,15 @@ import {
} from "@/lib/doctor-report-data";
import { prisma } from "@/lib/db";
import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
import {
DEFAULT_DOCTOR_REPORT_PREFS,
doctorReportPrefsSchema,
type DoctorReportPrefs,
} from "@/lib/validations/doctor-report-prefs";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
import { locales, type Locale } from "@/lib/i18n/config";
import { resolveUserTimezone } from "@/lib/tz/resolver";
/**
* Server-rendered PDF doctor report.
@@ -47,6 +53,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
const rawPracticeName = body?.practiceName;
const practiceName = sanitisePracticeName(rawPracticeName);
// v1.4.25 W6c — section toggles. Malformed shapes silently fall back
// to documented defaults so a forward-compat client never lands a
// 422 on a benign drift.
const sectionsParsed = doctorReportPrefsSchema.safeParse(
body?.sections ?? {},
);
const sectionsInput = sectionsParsed.success ? sectionsParsed.data : {};
const sections: DoctorReportPrefs = {
...DEFAULT_DOCTOR_REPORT_PREFS,
...sectionsInput,
};
if (typeof rawPracticeName === "string" && practiceName !== null) {
try {
await prisma.user.update({
@@ -58,10 +76,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
const data = await collectDoctorReportData(user.id, range, { practiceName });
const [data, userTz] = await Promise.all([
collectDoctorReportData(user.id, range, { practiceName, sections }),
resolveUserTimezone(user.id),
]);
const { t } = getServerTranslator(locale);
const pdfBytes = renderDoctorReportPdfBytes(data, { t, locale });
const pdfBytes = renderDoctorReportPdfBytes(data, { t, locale, userTz });
await auditLog("doctor-report.pdf.generate", {
userId: user.id,
@@ -72,6 +93,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
endDate: range.end.toISOString(),
locale,
practiceNameProvided: practiceName !== null,
// v1.4.25 W6c — record exactly which sections this report
// rendered, so a future privacy review can confirm mood was
// never rendered when the user opted out.
sections,
},
});
@@ -107,6 +132,8 @@ interface PdfRequestBody {
endDate?: unknown;
locale?: unknown;
practiceName?: unknown;
/** v1.4.25 W6c — per-section visibility toggles. */
sections?: unknown;
}
/**
+16 -1
View File
@@ -15,6 +15,7 @@ import {
sanitisePracticeName,
} from "@/lib/doctor-report-data";
import { prisma } from "@/lib/db";
import { doctorReportPrefsSchema } from "@/lib/validations/doctor-report-prefs";
/**
* Collect data for doctor report PDF generation (client-side).
@@ -51,6 +52,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
?.practiceName;
const practiceName = sanitisePracticeName(rawPracticeName);
// v1.4.25 W6c — section toggles. Malformed shapes silently fall
// through to the documented defaults so a forward-compat client
// never lands a 422 on a benign drift.
const rawSections = (body as Record<string, unknown> | null)?.sections;
const sectionsParsed = doctorReportPrefsSchema.safeParse(rawSections ?? {});
const sections = sectionsParsed.success ? sectionsParsed.data : {};
// Persist the most-recent practice name as a user preference so the
// dialog can pre-fill it next time. We only write when the caller
// actually supplied a non-empty string — passing `null`/empty does NOT
@@ -67,7 +75,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
const data = await collectDoctorReportData(user.id, range, { practiceName });
const data = await collectDoctorReportData(user.id, range, {
practiceName,
sections,
});
await auditLog("doctor-report.generate", {
userId: user.id,
@@ -77,6 +88,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
startDate: range.start.toISOString(),
endDate: range.end.toISOString(),
practiceNameProvided: practiceName !== null,
// v1.4.25 W6c — record which sections were included. The audit
// trail proves mood data was never aggregated when the toggle
// was off (privacy contract).
sections,
},
});
+9 -5
View File
@@ -23,6 +23,7 @@ import { auditLog } from "@/lib/auth/audit";
import { apiError, getClientIp } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { toCSV, formatMeasurementsForExport } from "@/lib/export";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { NextRequest, NextResponse } from "next/server";
export const GET = apiHandler(async (request: NextRequest) => {
@@ -37,12 +38,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { since, until } = parseRange(request.url);
const where = buildWhere(user.id, { since, until });
const measurements = await prisma.measurement.findMany({
where,
orderBy: { measuredAt: "desc" },
});
const [measurements, userTz] = await Promise.all([
prisma.measurement.findMany({
where,
orderBy: { measuredAt: "desc" },
}),
resolveUserTimezone(user.id),
]);
const csv = toCSV(formatMeasurementsForExport(measurements));
const csv = toCSV(formatMeasurementsForExport(measurements, userTz));
await auditLog("user.export.measurements", {
userId: user.id,
+20 -16
View File
@@ -24,6 +24,7 @@ import {
formatMedicationsForExport,
formatIntakeEventsForExport,
} from "@/lib/export";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { NextRequest, NextResponse } from "next/server";
export const GET = apiHandler(async (request: NextRequest) => {
@@ -48,23 +49,26 @@ export const GET = apiHandler(async (request: NextRequest) => {
// without a matching migration); a wildcard `include: { schedules: true }`
// would 500 on the missing column. Pin the columns instead — the export
// shape doesn't need every future field anyway.
const medications = await prisma.medication.findMany({
where: { userId: user.id },
select: {
name: true,
dose: true,
active: true,
schedules: {
select: {
windowStart: true,
windowEnd: true,
label: true,
dose: true,
const [medications, userTz] = await Promise.all([
prisma.medication.findMany({
where: { userId: user.id },
select: {
name: true,
dose: true,
active: true,
schedules: {
select: {
windowStart: true,
windowEnd: true,
label: true,
dose: true,
},
},
},
},
orderBy: { createdAt: "desc" },
});
orderBy: { createdAt: "desc" },
}),
resolveUserTimezone(user.id),
]);
const sections: string[] = [
"# Medications",
@@ -91,7 +95,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
intakeCount = events.length;
sections.push("# Intake history");
sections.push(toCSV(formatIntakeEventsForExport(events)));
sections.push(toCSV(formatIntakeEventsForExport(events, userTz)));
}
const body = sections.join("\n\n");
+9 -5
View File
@@ -19,6 +19,7 @@ import { auditLog } from "@/lib/auth/audit";
import { apiError, getClientIp } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { toCSV, formatMoodEntriesForExport } from "@/lib/export";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { NextRequest, NextResponse } from "next/server";
export const GET = apiHandler(async (request: NextRequest) => {
@@ -50,12 +51,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
if (until) where.moodLoggedAt.lte = until;
}
const entries = await prisma.moodEntry.findMany({
where,
orderBy: { moodLoggedAt: "desc" },
});
const [entries, userTz] = await Promise.all([
prisma.moodEntry.findMany({
where,
orderBy: { moodLoggedAt: "desc" },
}),
resolveUserTimezone(user.id),
]);
const csv = toCSV(formatMoodEntriesForExport(entries));
const csv = toCSV(formatMoodEntriesForExport(entries, userTz));
await auditLog("user.export.mood", {
userId: user.id,
+8 -6
View File
@@ -11,6 +11,7 @@ import {
formatIntakeEventsForExport,
formatMoodEntriesForExport,
} from "@/lib/export";
import { resolveUserTimezone } from "@/lib/tz/resolver";
import { NextRequest, NextResponse } from "next/server";
/**
@@ -48,6 +49,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
}
const userId = user.id;
const userTz = await resolveUserTimezone(userId);
const data: Record<string, unknown> = {};
@@ -58,8 +60,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
data.measurements =
format === "csv"
? toCSV(formatMeasurementsForExport(measurements))
: formatMeasurementsForExport(measurements);
? toCSV(formatMeasurementsForExport(measurements, userTz))
: formatMeasurementsForExport(measurements, userTz);
}
if (type === "medications" || type === "all") {
@@ -82,8 +84,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
data.intakeEvents =
format === "csv"
? toCSV(formatIntakeEventsForExport(events))
: formatIntakeEventsForExport(events);
? toCSV(formatIntakeEventsForExport(events, userTz))
: formatIntakeEventsForExport(events, userTz);
}
if (type === "mood" || type === "all") {
@@ -93,8 +95,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
data.moodEntries =
format === "csv"
? toCSV(formatMoodEntriesForExport(moodEntries))
: formatMoodEntriesForExport(moodEntries);
? toCSV(formatMoodEntriesForExport(moodEntries, userTz))
: formatMoodEntriesForExport(moodEntries, userTz);
}
await auditLog("export.download", {
+24 -2
View File
@@ -206,12 +206,22 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
// the new user message. v1.4.23 H4 — fold per-user prefs into the
// system-prompt prefix; the snapshot builder reads the same prefs
// separately so excluded metrics never even leave the DB.
//
// v1.4.25 W5 — `coachPrefs.defaultWindow` is the user's saved
// analysis-window preference. Merge it into the snapshot scope when
// the client didn't supply a per-conversation override; the override
// (header pill / sources rail) always wins. Keep the merge cheap so
// we don't accidentally widen narrow per-call scopes.
const prefsRow = await prisma.user.findUnique({
where: { id: userId },
select: { coachPrefsJson: true },
});
const coachPrefs = parseCoachPrefs(prefsRow?.coachPrefsJson);
const snapshot = await buildCoachSnapshot(userId, scope);
const effectiveScope =
scope?.window === undefined && coachPrefs.defaultWindow
? { ...(scope ?? {}), window: coachPrefs.defaultWindow }
: scope;
const snapshot = await buildCoachSnapshot(userId, effectiveScope);
const systemPrompt = getCoachSystemPrompt(locale, coachPrefs);
const window = buildHistoryWindow([
...priorTurns,
@@ -265,7 +275,19 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
firstStatus: err.attempts[0]?.httpStatus ?? null,
},
});
return streamProviderError({ code: "coach.provider.unavailable" });
// v1.4.25 W5 — distinguish provider rate-limit (every attempt
// landed on 429) from generic unavailability. The drawer's
// error-decoder surfaces the rate-limit copy with a warning
// toast instead of the generic provider-down message, so the
// user understands the limit is transient.
const allRateLimited =
err.attempts.length > 0 &&
err.attempts.every((a) => a.httpStatus === 429);
return streamProviderError({
code: allRateLimited
? "coach.provider.rate_limited"
: "coach.provider.unavailable",
});
}
throw err;
}
@@ -1,32 +0,0 @@
import { NextRequest } from "next/server";
import { apiSuccess } from "@/lib/api-response";
import {
generateGeneralStatusForUser,
resolveGeneralStatusLocale,
} from "@/lib/insights/general-status";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const localeParam = request.nextUrl.searchParams.get("locale");
const resolved = await resolveServerLocale({
request,
userLocale: user.locale ?? null,
override: localeParam,
});
const locale = resolveGeneralStatusLocale(resolved);
const result = await generateGeneralStatusForUser(user.id, {
locale,
force: false,
});
annotate({ action: { name: "insights.general-status" } });
return apiSuccess(result);
});
+16 -1
View File
@@ -2,6 +2,10 @@ import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { extractFeatures } from "@/lib/insights/features";
import {
detectGlp1Plateau,
buildGlp1PlateauPrompt,
} from "@/lib/insights/glp1-plateau";
import {
buildUserPrompt,
type ComparisonSnapshot,
@@ -266,12 +270,23 @@ export const POST = apiHandler(async (request: NextRequest) => {
// narrative is default-on whenever the toggle is on per research §7
// Q4 — the pulldown is the single affordance.
const comparisonSnapshot = await buildComparisonSnapshotForUser(userId);
const userPrompt = buildUserPrompt(
// v1.4.25 W4d — GLP-1 plateau detector. Appends a SYSTEM CONTEXT
// block to the user prompt when the active GLP-1 medication has been
// on a stable dose for ≥21 days with weight delta within ±0.5 kg.
// The block instructs the model to emit a glp1_plateau keyFinding
// framed observationally (no dose recommendation per GROUND RULE 14).
// Returns null when no GLP-1 is active OR no plateau condition;
// 99% of users land in the null branch and pay zero token cost.
const plateauContext = await detectGlp1Plateau(userId);
let userPrompt = buildUserPrompt(
featuresJson,
dbUser?.insightsPrivacyMode ?? "aggregated",
locale,
comparisonSnapshot ?? undefined,
);
if (plateauContext) {
userPrompt += buildGlp1PlateauPrompt(plateauContext, locale);
}
let result;
let workingProviderType: string;
+169
View File
@@ -0,0 +1,169 @@
/**
* v1.4.25 W4d — GLP-1 therapy-timeline aggregator.
*
* Returns a chronological merge of every GLP-1-relevant event the user
* has logged: dose changes, injections (with site if recorded),
* inventory events, and side-effect-tagged mood entries from the
* trailing 90 days. Used by /insights/medikamente's TherapyTimeline
* component. Web-only users without a GLP-1 medication get
* `hasGlp1: false` so the component hides cleanly.
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { NextRequest } from "next/server";
const DEFAULT_LIMIT = 60;
const SIDE_EFFECT_TAGS = new Set([
"nausea",
"constipation",
"diarrhea",
"fatigue",
"appetite-loss",
"heartburn",
"headache",
"übelkeit",
"verstopfung",
"durchfall",
"müdigkeit",
"appetitlosigkeit",
"sodbrennen",
"kopfschmerzen",
]);
interface TimelineEntry {
date: string;
kind: "dose-change" | "injection" | "inventory" | "side-effect";
medicationName?: string;
doseValue?: number;
doseUnit?: string;
doseDelta?: "up" | "down" | null;
note?: string | null;
injectionSite?: string | null;
inventoryDelta?: number;
reason?: string;
tags?: string[];
}
function parseTagList(raw: string | null): string[] {
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed)) {
return parsed
.map((v) => (typeof v === "string" ? v.trim() : ""))
.filter(Boolean);
}
} catch {
/* fall through */
}
return raw
.split(",")
.map((p) => p.trim())
.filter(Boolean);
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const url = new URL(request.url);
const rawLimit = Number(url.searchParams.get("limit"));
const limit =
Number.isInteger(rawLimit) && rawLimit > 0 && rawLimit <= 200
? rawLimit
: DEFAULT_LIMIT;
const meds = await prisma.medication.findMany({
where: { userId: user.id, treatmentClass: "GLP1" },
include: {
doseChanges: { orderBy: { effectiveFrom: "asc" } },
intakeEvents: {
where: { takenAt: { not: null } },
orderBy: { takenAt: "desc" },
take: 50,
},
inventoryEvents: { orderBy: { occurredAt: "desc" }, take: 20 },
},
});
if (meds.length === 0) {
return apiSuccess({ hasGlp1: false, entries: [] });
}
const ninetyDaysAgo = new Date();
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);
const moods = await prisma.moodEntry.findMany({
where: { userId: user.id, moodLoggedAt: { gte: ninetyDaysAgo } },
select: { moodLoggedAt: true, tags: true },
orderBy: { moodLoggedAt: "desc" },
});
const entries: TimelineEntry[] = [];
for (const med of meds) {
const sortedChanges = [...med.doseChanges];
for (let i = 0; i < sortedChanges.length; i += 1) {
const dc = sortedChanges[i];
const prev = i > 0 ? sortedChanges[i - 1] : null;
let delta: "up" | "down" | null = null;
if (prev) {
if (dc.doseValue > prev.doseValue) delta = "up";
else if (dc.doseValue < prev.doseValue) delta = "down";
}
entries.push({
date: dc.effectiveFrom.toISOString(),
kind: "dose-change",
medicationName: med.name,
doseValue: dc.doseValue,
doseUnit: dc.doseUnit,
doseDelta: delta,
note: dc.note,
});
}
for (const intake of med.intakeEvents) {
if (!intake.takenAt) continue;
entries.push({
date: intake.takenAt.toISOString(),
kind: "injection",
medicationName: med.name,
injectionSite: intake.injectionSite,
});
}
for (const inv of med.inventoryEvents) {
entries.push({
date: inv.occurredAt.toISOString(),
kind: "inventory",
medicationName: med.name,
inventoryDelta: inv.delta,
reason: inv.reason,
});
}
}
// Side-effect days — collapse to one entry per day (most recent
// first) listing every recognised tag picked up that day.
const sideEffectByDay = new Map<string, string[]>();
for (const mood of moods) {
const tags = parseTagList(mood.tags).filter((t) =>
SIDE_EFFECT_TAGS.has(t.toLowerCase()),
);
if (tags.length === 0) continue;
const dayKey = mood.moodLoggedAt.toISOString().slice(0, 10);
const existing = sideEffectByDay.get(dayKey) ?? [];
for (const t of tags) {
if (!existing.includes(t)) existing.push(t);
}
sideEffectByDay.set(dayKey, existing);
}
for (const [day, tags] of sideEffectByDay) {
entries.push({
date: `${day}T12:00:00Z`,
kind: "side-effect",
tags,
});
}
entries.sort((a, b) => b.date.localeCompare(a.date));
return apiSuccess({ hasGlp1: true, entries: entries.slice(0, limit) });
});
+580 -162
View File
@@ -14,7 +14,7 @@ import {
import { calculateCompliance } from "@/lib/analytics/compliance";
import { pairByTimestamp } from "@/lib/analytics/correlations";
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
import { berlinDayKey } from "@/lib/analytics/berlin-day";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
import {
classifyPulseByTarget,
getPersonalizedPulseTarget,
@@ -58,18 +58,38 @@ interface TargetItem {
classification: { category: string; color: string } | null;
source: string;
/**
* v1.4.22 C1 — sparkline support on the Zielwerte page. `points30d`
* is the last 30 days of values (chronological, oldest → newest) for
* the target's primary metric, rounded to one decimal. Derived metrics
* (BMI, BP-in-target, mood stability, glucose-by-context) skip the
* field when the page already renders a richer status surface.
* `deltaVsLastMonth` is the signed difference between the current
* 30-day average and the prior 30-day average (current − prior),
* null when either window has fewer than 3 points to keep the
* comparison honest.
* v1.4.25 W3e — consistency strip support. Berlin-tz day buckets over
* the last 7 days. `daysInRange7d` is the count of days whose mean
* reading landed inside the target's green band; `daysLogged7d` is
* how many of those days had at least one reading (so the strip can
* render filled / hollow / dimmed dots without ambiguity).
*
* `lastMetGoalAt` is the most recent Berlin-tz day whose mean reading
* was in the green band (ISO date YYYY-MM-DD), or null if the user
* has not hit goal in the last 30 days.
*
* `streakDays` is the count of consecutive Berlin days ending today
* where the day's mean was in the green band. Capped at 365.
*
* `insufficientData` is true when the target has fewer than 3
* readings over the last 30 days OR fewer than 1 day of data in the
* last 7 days. The page hides the strip + percentage when this is
* true.
*/
points30d?: number[] | null;
deltaVsLastMonth?: number | null;
daysInRange7d: number;
daysLogged7d: number;
daysInRange30d: number;
daysLogged30d: number;
lastMetGoalAt: string | null;
streakDays: number;
insufficientData: boolean;
/**
* v1.4.25 W3e — per-day classification of the last 7 Berlin-tz days
* for the consistency strip. Index 0 is six days ago, index 6 is
* today. `null` slots represent days with no readings; non-null
* slots represent days whose mean reading landed in the named band.
*/
consistency7d: ReadonlyArray<"in" | "near" | "out" | null>;
details?: {
medications?: Array<{
name: string;
@@ -79,11 +99,26 @@ interface TargetItem {
};
}
interface TargetPageSummary {
targetsMetThisWeek: number;
totalTargets: number;
streakHighlight: { metric: string; days: number } | null;
}
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const userId = user.id;
// v1.4.25 W7b — every per-day bucket key in this route resolves
// against the user's display timezone so a Pacific/Auckland user gets
// their Auckland-day streaks, their Auckland-day "last met goal" date,
// and their Auckland-day consistency strip. Falls back to
// Europe/Berlin when the column is somehow missing (defensive — the
// schema's NOT NULL default normally pins it).
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
const dayKey = (d: Date): string => userDayKey(d, userTz);
// Fetch user profile
const dbUser = await prisma.user.findUnique({
where: { id: userId },
@@ -112,28 +147,17 @@ export const GET = apiHandler(async () => {
];
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
// v1.4.22 C1 — second 30-day window (days 31–60) so each target card
// can render a "Δ vs. last month" caption alongside its sparkline.
// We fetch 60 days in one query to keep the route round-trip count
// identical to the previous version.
const sixtyDaysAgo = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000);
// Fetch all measurements in the last 60 days + the latest for each type
const windowedMeasurements = await prisma.measurement.findMany({
// Fetch all measurements in the last 30 days + the latest for each type
const recentMeasurements = await prisma.measurement.findMany({
where: {
userId,
type: { in: types },
measuredAt: { gte: sixtyDaysAgo },
measuredAt: { gte: thirtyDaysAgo },
},
orderBy: { measuredAt: "desc" },
select: { type: true, value: true, measuredAt: true },
});
const recentMeasurements = windowedMeasurements.filter(
(m) => m.measuredAt >= thirtyDaysAgo,
);
const priorMeasurements = windowedMeasurements.filter(
(m) => m.measuredAt < thirtyDaysAgo,
);
// Also get the absolute latest measurement per type (even if older than 30 days).
// Single grouped query replaces the previous N×findFirst loop — DISTINCT ON
@@ -188,50 +212,321 @@ export const GET = apiHandler(async () => {
return "stable";
}
// --------------------------------------------------------------------
// v1.4.25 W3e — consistency helper. Buckets readings by Berlin-tz day
// and classifies each day's mean against the target's green band.
// Single helper shared by every target type so the rule stays stable
// (mean reading per day → in-range / near-range / out-of-range).
// --------------------------------------------------------------------
const last7DayKeys: string[] = [];
for (let i = 6; i >= 0; i--) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
last7DayKeys.push(dayKey(d));
}
type DayBand = "in" | "near" | "out";
interface ConsistencyOutput {
daysInRange7d: number;
daysLogged7d: number;
daysInRange30d: number;
daysLogged30d: number;
lastMetGoalAt: string | null;
streakDays: number;
insufficientData: boolean;
consistency7d: ReadonlyArray<DayBand | null>;
}
const EMPTY_CONSISTENCY: ConsistencyOutput = {
daysInRange7d: 0,
daysLogged7d: 0,
daysInRange30d: 0,
daysLogged30d: 0,
lastMetGoalAt: null,
streakDays: 0,
insufficientData: true,
consistency7d: [null, null, null, null, null, null, null] as const,
};
/**
* v1.4.22 C1 — sparkline + delta helpers. The sparkline collapses
* the last 30 days of values for a measurement type into a daily
* mean series (chronological, oldest → newest). The delta is the
* signed difference between the current 30-day window's average
* and the prior 30-day window's average. Both helpers skip the
* computation when there are fewer than 3 points in a window so
* the comparison stays honest on cold-start accounts.
* Bucket a series of `{ measuredAt, value }` events by Berlin-tz day,
* classify each day's mean against the target band, and roll up the
* 7-day strip + 30-day stats + recency + streak.
*
* `classify(meanValue)` returns the band the day's mean falls into.
* Pass `null` to mean "no band — skip this day for classification".
*/
function sparklinePoints(type: MeasurementType): number[] | null {
const data = recentMeasurements
.filter((m) => m.type === type)
.sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime());
if (data.length < 3) return null;
function rollupConsistency(
events: ReadonlyArray<{ measuredAt: Date; value: number }>,
classify: (mean: number) => DayBand | null,
totalReadingsThreshold = 3,
): ConsistencyOutput {
if (events.length < totalReadingsThreshold) {
// Cold-start path. Still emit the per-day strip if we have any
// recent readings, but mark insufficient so the UI hides the %.
const partial = computeStrip(events, classify);
return {
...EMPTY_CONSISTENCY,
consistency7d: partial.consistency7d,
daysLogged7d: partial.daysLogged7d,
daysInRange7d: partial.daysInRange7d,
insufficientData: true,
};
}
// v1.4.22 W5 reconcile (Code-MED-3) — bucket by Berlin calendar
// day, not UTC. A 23:30-Berlin reading on Tuesday otherwise
// lands in Wednesday's UTC bucket and the sparkline drifts a
// day on cross-DST boundaries. Aligns with `berlinDayKey()`
// already used by the dashboard analytics route.
const byDay = new Map<string, { sum: number; count: number }>();
for (const m of data) {
const day = berlinDayKey(m.measuredAt);
for (const ev of events) {
const day = dayKey(ev.measuredAt);
const bucket = byDay.get(day) ?? { sum: 0, count: 0 };
bucket.sum += m.value;
bucket.sum += ev.value;
bucket.count += 1;
byDay.set(day, bucket);
}
return Array.from(byDay.values()).map(
(b) => Math.round((b.sum / b.count) * 10) / 10,
);
const stripPart = computeStrip(events, classify);
// 30-day stats: walk the last 30 Berlin-tz day keys.
let daysInRange30d = 0;
let daysLogged30d = 0;
for (let i = 29; i >= 0; i--) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
const key = dayKey(d);
const bucket = byDay.get(key);
if (!bucket) continue;
daysLogged30d += 1;
const mean = bucket.sum / bucket.count;
if (classify(mean) === "in") daysInRange30d += 1;
}
// lastMetGoalAt: walk back from today until a day is in range.
let lastMetGoalAt: string | null = null;
for (let i = 0; i < 30; i++) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
const key = dayKey(d);
const bucket = byDay.get(key);
if (!bucket) continue;
const mean = bucket.sum / bucket.count;
if (classify(mean) === "in") {
lastMetGoalAt = key;
break;
}
}
// streakDays: consecutive days ending today that are in-range. A
// day with no reading breaks the streak (the user did not meet the
// goal that day, even if today is in-range).
let streakDays = 0;
for (let i = 0; i < 365; i++) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
const key = dayKey(d);
const bucket = byDay.get(key);
if (!bucket) break;
const mean = bucket.sum / bucket.count;
if (classify(mean) !== "in") break;
streakDays += 1;
}
// insufficientData: < 3 readings total OR < 1 day in the last 7.
const insufficientData =
events.length < totalReadingsThreshold || stripPart.daysLogged7d < 1;
return {
daysInRange7d: stripPart.daysInRange7d,
daysLogged7d: stripPart.daysLogged7d,
daysInRange30d,
daysLogged30d,
lastMetGoalAt,
streakDays,
insufficientData,
consistency7d: stripPart.consistency7d,
};
}
function deltaVsLastMonth(type: MeasurementType): number | null {
const recentOfType = recentMeasurements.filter((m) => m.type === type);
const priorOfType = priorMeasurements.filter((m) => m.type === type);
if (recentOfType.length < 3 || priorOfType.length < 3) return null;
const recentAvg =
recentOfType.reduce((s, m) => s + m.value, 0) / recentOfType.length;
const priorAvg =
priorOfType.reduce((s, m) => s + m.value, 0) / priorOfType.length;
return Math.round((recentAvg - priorAvg) * 10) / 10;
function computeStrip(
events: ReadonlyArray<{ measuredAt: Date; value: number }>,
classify: (mean: number) => DayBand | null,
): {
consistency7d: ReadonlyArray<DayBand | null>;
daysInRange7d: number;
daysLogged7d: number;
} {
const byDay = new Map<string, { sum: number; count: number }>();
for (const ev of events) {
const day = dayKey(ev.measuredAt);
const bucket = byDay.get(day) ?? { sum: 0, count: 0 };
bucket.sum += ev.value;
bucket.count += 1;
byDay.set(day, bucket);
}
const strip: Array<DayBand | null> = [];
let daysInRange7d = 0;
let daysLogged7d = 0;
for (const key of last7DayKeys) {
const bucket = byDay.get(key);
if (!bucket) {
strip.push(null);
continue;
}
daysLogged7d += 1;
const mean = bucket.sum / bucket.count;
const band = classify(mean);
strip.push(band);
if (band === "in") daysInRange7d += 1;
}
return { consistency7d: strip, daysInRange7d, daysLogged7d };
}
/**
* Helper: classify a value against a target range with an orange band
* symmetrical to the green band (the same heuristic RangeBar uses
* client-side). When `min`/`max` is null we treat the input as
* unclassifiable and return null.
*/
function makeRangeClassifier(
range: { min: number; max: number } | null,
options?: { orangeMin?: number; orangeMax?: number },
): (mean: number) => DayBand | null {
if (!range) return () => null;
const span = range.max - range.min;
const orangeMin = options?.orangeMin ?? range.min - span * 0.3;
const orangeMax = options?.orangeMax ?? range.max + span * 0.3;
return (mean: number) => {
if (mean >= range.min && mean <= range.max) return "in";
if (mean >= orangeMin && mean <= orangeMax) return "near";
return "out";
};
}
/**
* Per-day BP-in-target classifier: a day is "in" if every BP pair on
* that day (sys+dia matched within 5 min) passes `isBpReadingInTarget`.
* "near" if at least one pair passes; "out" otherwise.
*
* Returns a function (day-key, dayEvents) → band. We pre-compute the
* by-day map once because the route already paginates BP twice (sys
* + dia) and we want to avoid quadratic work.
*/
function buildBpPairsByDay(): Map<string, DayBand | null> {
if (!bpRange) return new Map();
const sysByDay = new Map<string, Array<{ date: Date; value: number }>>();
const diaByDay = new Map<string, Array<{ date: Date; value: number }>>();
for (const m of recentMeasurements) {
if (m.type === "BLOOD_PRESSURE_SYS") {
const k = dayKey(m.measuredAt);
const arr = sysByDay.get(k) ?? [];
arr.push({ date: m.measuredAt, value: m.value });
sysByDay.set(k, arr);
} else if (m.type === "BLOOD_PRESSURE_DIA") {
const k = dayKey(m.measuredAt);
const arr = diaByDay.get(k) ?? [];
arr.push({ date: m.measuredAt, value: m.value });
diaByDay.set(k, arr);
}
}
const out = new Map<string, DayBand | null>();
const allKeys = new Set([...sysByDay.keys(), ...diaByDay.keys()]);
for (const key of allKeys) {
const sys = sysByDay.get(key) ?? [];
const dia = diaByDay.get(key) ?? [];
const pairs = pairByTimestamp(sys, dia, 5 * 60 * 1000);
if (pairs.length === 0) {
out.set(key, null);
continue;
}
const inCount = pairs.filter((p) =>
isBpReadingInTarget(p.a, p.b, bpRange),
).length;
if (inCount === pairs.length) out.set(key, "in");
else if (inCount > 0) out.set(key, "near");
else out.set(key, "out");
}
return out;
}
// We need bpRange before the per-target loop runs so the helpers above
// can use it; reorder the local so it's available.
const bpRange = age != null ? getBpTargetsByAge(age, gender) : null;
/**
* Roll up consistency for a target whose bands come from a pre-mapped
* `dayBandByKey` map (used for BP-in-target and medication compliance
* which have day-level classification rather than mean-classification).
*/
function rollupFromDayMap(
dayBandByKey: Map<string, DayBand | null>,
totalDaysThreshold = 3,
): ConsistencyOutput {
const loggedKeys = Array.from(dayBandByKey.entries()).filter(
([, v]) => v !== null,
);
if (loggedKeys.length < totalDaysThreshold) {
// Cold-start: still emit the strip.
const strip: Array<DayBand | null> = last7DayKeys.map(
(k) => dayBandByKey.get(k) ?? null,
);
const daysLogged7d = strip.filter((b) => b !== null).length;
const daysInRange7d = strip.filter((b) => b === "in").length;
return {
...EMPTY_CONSISTENCY,
consistency7d: strip,
daysLogged7d,
daysInRange7d,
insufficientData: true,
};
}
const strip: Array<DayBand | null> = last7DayKeys.map(
(k) => dayBandByKey.get(k) ?? null,
);
const daysLogged7d = strip.filter((b) => b !== null).length;
const daysInRange7d = strip.filter((b) => b === "in").length;
let daysInRange30d = 0;
let daysLogged30d = 0;
for (let i = 29; i >= 0; i--) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
const key = dayKey(d);
const band = dayBandByKey.get(key);
if (!band) continue;
daysLogged30d += 1;
if (band === "in") daysInRange30d += 1;
}
let lastMetGoalAt: string | null = null;
for (let i = 0; i < 30; i++) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
const key = dayKey(d);
if (dayBandByKey.get(key) === "in") {
lastMetGoalAt = key;
break;
}
}
let streakDays = 0;
for (let i = 0; i < 365; i++) {
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
const key = dayKey(d);
const band = dayBandByKey.get(key);
if (band === undefined || band === null) break;
if (band !== "in") break;
streakDays += 1;
}
return {
daysInRange7d,
daysLogged7d,
daysInRange30d,
daysLogged30d,
lastMetGoalAt,
streakDays,
insufficientData: loggedKeys.length < totalDaysThreshold,
consistency7d: strip,
};
}
// Memo: pre-compute the BP-pairs day map once.
const bpPairsByDay = buildBpPairsByDay();
// Build target items
const targets: TargetItem[] = [];
@@ -244,22 +539,28 @@ export const GET = apiHandler(async () => {
const cls = classifyBMI(bmi);
weightClassification = { category: cls.category, color: cls.color };
}
targets.push({
type: "WEIGHT",
label: "Weight",
current: latestByType.WEIGHT ?? null,
average30: avg30ByType.WEIGHT ?? null,
trend: computeTrend("WEIGHT"),
unit: "kg",
range: weightRange,
classification: weightClassification,
source: "WHO BMI",
points30d: sparklinePoints("WEIGHT"),
deltaVsLastMonth: deltaVsLastMonth("WEIGHT"),
});
{
const consistency = rollupConsistency(
recentMeasurements.filter((m) => m.type === "WEIGHT"),
makeRangeClassifier(weightRange),
);
targets.push({
type: "WEIGHT",
label: "Weight",
current: latestByType.WEIGHT ?? null,
average30: avg30ByType.WEIGHT ?? null,
trend: computeTrend("WEIGHT"),
unit: "kg",
range: weightRange,
classification: weightClassification,
source: "WHO BMI",
...consistency,
});
}
// 2. Blood Pressure (sys/dia combined)
const bpRange = age != null ? getBpTargetsByAge(age, gender) : null;
// `bpRange` is hoisted to the helpers block above so the BP-pairs
// day-map can use it; do not redeclare here.
let bpClassification: { category: string; color: string } | null = null;
if (
latestByType.BLOOD_PRESSURE_SYS != null &&
@@ -271,20 +572,26 @@ export const GET = apiHandler(async () => {
);
bpClassification = { category: cls.category, color: cls.color };
}
targets.push({
type: "BLOOD_PRESSURE",
label: "Blood pressure",
current: latestByType.BLOOD_PRESSURE_SYS ?? null,
average30: avg30ByType.BLOOD_PRESSURE_SYS ?? null,
trend: computeTrend("BLOOD_PRESSURE_SYS"),
unit: "mmHg",
range: bpRange ? { min: bpRange.sysLow, max: bpRange.sysHigh } : null,
classification: bpClassification,
source: "ESH 2023",
points30d: sparklinePoints("BLOOD_PRESSURE_SYS"),
deltaVsLastMonth: deltaVsLastMonth("BLOOD_PRESSURE_SYS"),
// Extra fields for diastolic
} as TargetItem);
{
// Per-day BP classifier reuses the sys+dia paired classifier from
// `bpPairsByDay`. This is the right semantic for "in target band"
// because a systolic in range with a diastolic out of range is
// still an out-of-target reading.
const consistency = rollupFromDayMap(bpPairsByDay);
targets.push({
type: "BLOOD_PRESSURE",
label: "Blood pressure",
current: latestByType.BLOOD_PRESSURE_SYS ?? null,
average30: avg30ByType.BLOOD_PRESSURE_SYS ?? null,
trend: computeTrend("BLOOD_PRESSURE_SYS"),
unit: "mmHg",
range: bpRange ? { min: bpRange.sysLow, max: bpRange.sysHigh } : null,
classification: bpClassification,
source: "ESH 2023",
...consistency,
// Extra fields for diastolic
} as TargetItem);
}
// 2b. Blood pressure target-hit rate over 30 days
if (bpRange) {
@@ -312,6 +619,9 @@ export const GET = apiHandler(async () => {
? Math.round((bpInTargetCount / bpPairs.length) * 100 * 10) / 10
: null;
// BP-in-target consistency mirrors the BP pair-by-day map above:
// a day is "in" when every pair on that day clears the BP target.
const inTargetConsistency = rollupFromDayMap(bpPairsByDay);
targets.push({
type: "BLOOD_PRESSURE_IN_TARGET",
label: "Blood pressure on target",
@@ -329,6 +639,7 @@ export const GET = apiHandler(async () => {
: { category: "Low", color: "#ff5555" }
: null,
source: "ESH 2023",
...inTargetConsistency,
});
}
@@ -339,19 +650,31 @@ export const GET = apiHandler(async () => {
const cls = classifyPulseByTarget(latestByType.PULSE, pulseTarget);
pulseClassification = { category: cls.category, color: cls.color };
}
targets.push({
type: "PULSE",
label: "Resting pulse",
current: latestByType.PULSE ?? null,
average30: avg30ByType.PULSE ?? null,
trend: computeTrend("PULSE"),
unit: "bpm",
range: { min: pulseTarget.greenMin, max: pulseTarget.greenMax },
classification: pulseClassification,
source: pulseTarget.source,
points30d: sparklinePoints("PULSE"),
deltaVsLastMonth: deltaVsLastMonth("PULSE"),
});
{
const pulseRange = {
min: pulseTarget.greenMin,
max: pulseTarget.greenMax,
};
const consistency = rollupConsistency(
recentMeasurements.filter((m) => m.type === "PULSE"),
makeRangeClassifier(pulseRange, {
orangeMin: pulseTarget.orangeMin,
orangeMax: pulseTarget.orangeMax,
}),
);
targets.push({
type: "PULSE",
label: "Resting pulse",
current: latestByType.PULSE ?? null,
average30: avg30ByType.PULSE ?? null,
trend: computeTrend("PULSE"),
unit: "bpm",
range: pulseRange,
classification: pulseClassification,
source: pulseTarget.source,
...consistency,
});
}
// 4. Sleep Duration
const sleepRange = getSleepDurationRange();
@@ -360,19 +683,24 @@ export const GET = apiHandler(async () => {
const cls = classifySleepDuration(latestByType.SLEEP_DURATION);
sleepClassification = { category: cls.category, color: cls.color };
}
targets.push({
type: "SLEEP_DURATION",
label: "Sleep duration",
current: latestByType.SLEEP_DURATION ?? null,
average30: avg30ByType.SLEEP_DURATION ?? null,
trend: computeTrend("SLEEP_DURATION"),
unit: "h",
range: sleepRange,
classification: sleepClassification,
source: "AASM/SRS",
points30d: sparklinePoints("SLEEP_DURATION"),
deltaVsLastMonth: deltaVsLastMonth("SLEEP_DURATION"),
});
{
const consistency = rollupConsistency(
recentMeasurements.filter((m) => m.type === "SLEEP_DURATION"),
makeRangeClassifier(sleepRange),
);
targets.push({
type: "SLEEP_DURATION",
label: "Sleep duration",
current: latestByType.SLEEP_DURATION ?? null,
average30: avg30ByType.SLEEP_DURATION ?? null,
trend: computeTrend("SLEEP_DURATION"),
unit: "h",
range: sleepRange,
classification: sleepClassification,
source: "AASM/SRS",
...consistency,
});
}
// 5. BMI (derived from weight + height)
if (heightCm) {
@@ -395,19 +723,17 @@ export const GET = apiHandler(async () => {
bmiClassification = { category: cls.category, color: cls.color };
}
// BMI sparkline / delta derive from the weight series \u2014 divide each
// point by height\u00B2 so the y-axis matches the BMI range bar above.
const weightPoints = sparklinePoints("WEIGHT");
const bmiPoints =
weightPoints != null
? weightPoints.map((v) => Math.round((v / heightSq) * 10) / 10)
: null;
const weightDelta = deltaVsLastMonth("WEIGHT");
const bmiDelta =
weightDelta != null
? Math.round((weightDelta / heightSq) * 10) / 10
: null;
// BMI consistency derives from the weight series: divide each
// bucket by height\u00B2 so the day's mean BMI is classified against
// the BMI range bar (18.5 \u2014 24.9).
const bmiRange = { min: 18.5, max: 24.9 };
const bmiEvents = recentMeasurements
.filter((m) => m.type === "WEIGHT")
.map((m) => ({ measuredAt: m.measuredAt, value: m.value / heightSq }));
const consistency = rollupConsistency(
bmiEvents,
makeRangeClassifier(bmiRange),
);
targets.push({
type: "BMI",
label: "BMI",
@@ -415,11 +741,10 @@ export const GET = apiHandler(async () => {
average30: avgBmi,
trend: computeTrend("WEIGHT"), // BMI trend follows weight trend
unit: "kg/m\u00B2",
range: { min: 18.5, max: 24.9 },
range: bmiRange,
classification: bmiClassification,
source: "WHO",
points30d: bmiPoints,
deltaVsLastMonth: bmiDelta,
...consistency,
});
}
@@ -435,19 +760,24 @@ export const GET = apiHandler(async () => {
// marathon consolidated them onto `getBodyFatTargetRange` (ACE
// fitness + acceptable bands).
const bodyFatRange = age != null ? getBodyFatTargetRange(gender) : null;
targets.push({
type: "BODY_FAT",
label: "Body fat",
current: latestByType.BODY_FAT ?? null,
average30: avg30ByType.BODY_FAT ?? null,
trend: computeTrend("BODY_FAT"),
unit: "%",
range: bodyFatRange,
classification: bodyFatClassification,
source: "ACE",
points30d: sparklinePoints("BODY_FAT"),
deltaVsLastMonth: deltaVsLastMonth("BODY_FAT"),
});
{
const consistency = rollupConsistency(
recentMeasurements.filter((m) => m.type === "BODY_FAT"),
makeRangeClassifier(bodyFatRange),
);
targets.push({
type: "BODY_FAT",
label: "Body fat",
current: latestByType.BODY_FAT ?? null,
average30: avg30ByType.BODY_FAT ?? null,
trend: computeTrend("BODY_FAT"),
unit: "%",
range: bodyFatRange,
classification: bodyFatClassification,
source: "ACE",
...consistency,
});
}
// 7. Activity Steps
const stepsRange = getStepsRange();
@@ -456,25 +786,31 @@ export const GET = apiHandler(async () => {
const cls = classifySteps(avg30ByType.ACTIVITY_STEPS);
stepsClassification = { category: cls.category, color: cls.color };
}
targets.push({
type: "ACTIVITY_STEPS",
label: "Steps/day",
current: latestByType.ACTIVITY_STEPS ?? null,
average30: avg30ByType.ACTIVITY_STEPS ?? null,
trend: computeTrend("ACTIVITY_STEPS"),
unit: "steps",
range: stepsRange,
classification: stepsClassification,
points30d: sparklinePoints("ACTIVITY_STEPS"),
deltaVsLastMonth: deltaVsLastMonth("ACTIVITY_STEPS"),
// WHO publishes activity *time* (150–300 min/wk moderate),
// not a step quota. The closest peer-reviewed dose-response
// for the 8 000–15 000 band is Saint-Maurice JAMA 2020. The
// AI prompts at src/lib/insights/prompts/{base-system,
// general-status}.ts already enforce this attribution; this
// surface label was the last "WHO" mislabel in the codebase.
source: "Saint-Maurice JAMA 2020",
});
{
const consistency = rollupConsistency(
recentMeasurements.filter((m) => m.type === "ACTIVITY_STEPS"),
makeRangeClassifier(stepsRange),
);
targets.push({
type: "ACTIVITY_STEPS",
label: "Steps/day",
current: latestByType.ACTIVITY_STEPS ?? null,
average30: avg30ByType.ACTIVITY_STEPS ?? null,
trend: computeTrend("ACTIVITY_STEPS"),
unit: "steps",
range: stepsRange,
classification: stepsClassification,
// WHO publishes activity *time* (150–300 min/wk moderate),
// not a step quota. The closest peer-reviewed dose-response
// for the 8 000–15 000 band is Saint-Maurice JAMA 2020. The
// AI prompts at src/lib/ai/prompts/base-system.ts and
// src/lib/ai/prompts/general-status.ts already enforce this
// attribution; this surface label was the last "WHO" mislabel
// in the codebase.
source: "Saint-Maurice JAMA 2020",
...consistency,
});
}
// 8. Medication Compliance (average across active medications)
const activeMedications = await prisma.medication.findMany({
@@ -563,6 +899,33 @@ export const GET = apiHandler(async () => {
) / 10
: complianceRate7;
// Per-day compliance: for each Berlin-tz day, classify as "in" if
// every scheduled dose that day was taken; "near" if at least one
// was taken; "out" if all were skipped/missed; null if there were
// no scheduled doses (the user's regimen excluded that day).
const dayCountsByKey = new Map<
string,
{ taken: number; expected: number }
>();
for (const event of intakeEvents) {
const key = dayKey(event.scheduledFor);
const cur = dayCountsByKey.get(key) ?? { taken: 0, expected: 0 };
cur.expected += 1;
if (event.takenAt && !event.skipped) cur.taken += 1;
dayCountsByKey.set(key, cur);
}
const dayBandByKey = new Map<string, DayBand | null>();
for (const [key, counts] of dayCountsByKey.entries()) {
if (counts.expected === 0) {
dayBandByKey.set(key, null);
continue;
}
const ratio = counts.taken / counts.expected;
if (ratio >= 0.99) dayBandByKey.set(key, "in");
else if (ratio >= 0.5) dayBandByKey.set(key, "near");
else dayBandByKey.set(key, "out");
}
const consistency = rollupFromDayMap(dayBandByKey);
targets.push({
type: "MEDICATION_COMPLIANCE",
label: "Medication compliance",
@@ -587,6 +950,7 @@ export const GET = apiHandler(async () => {
compliance30: medication.compliance30,
})),
},
...consistency,
});
}
@@ -641,6 +1005,15 @@ export const GET = apiHandler(async () => {
: { category: "Low", color: "#ff5555" }
: null;
const moodRange = { min: 3.5, max: 5 };
const moodEventsForConsistency = recentMood.map((entry) => ({
measuredAt: entry.moodLoggedAt,
value: entry.score,
}));
const moodConsistency = rollupConsistency(
moodEventsForConsistency,
makeRangeClassifier(moodRange, { orangeMin: 2, orangeMax: 5 }),
);
targets.push({
type: "MOOD_SCORE",
label: "Mood",
@@ -648,9 +1021,10 @@ export const GET = apiHandler(async () => {
average30: moodAvg30,
trend: moodTrend,
unit: "/ 5",
range: { min: 3.5, max: 5 },
range: moodRange,
classification: moodClassification,
source: "moodLog",
...moodConsistency,
});
// Mood stability: standard deviation of recent scores (lower = more stable)
@@ -668,6 +1042,10 @@ export const GET = apiHandler(async () => {
? { category: "Stable", color: "#f1fa8c" }
: { category: "Fluctuating", color: "#ff5555" };
// Mood stability is computed from the same recent-mood window;
// consistency mirrors MOOD_SCORE so the strip aligns with the
// user's logging cadence rather than introducing a synthetic
// "\u03C3 in range per day" rule that would be hard to reason about.
targets.push({
type: "MOOD_STABILITY",
label: "Mood stability",
@@ -678,6 +1056,7 @@ export const GET = apiHandler(async () => {
range: { min: 0, max: 0.5 },
classification: stabilityClassification,
source: "moodLog",
...moodConsistency,
});
}
}
@@ -744,6 +1123,15 @@ export const GET = apiHandler(async () => {
}
}
const consistency = rollupConsistency(
recent.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
makeRangeClassifier(
range,
eff.range
? { orangeMin: eff.range.orangeMin, orangeMax: eff.range.orangeMax }
: undefined,
),
);
targets.push({
type: `BLOOD_GLUCOSE_${ctx}`,
label: labelKeyByContext[ctx],
@@ -754,16 +1142,46 @@ export const GET = apiHandler(async () => {
range,
classification,
source: eff.isOverride ? "Custom" : "ADA 2024 / DDG",
...consistency,
});
}
// --------------------------------------------------------------------
// v1.4.25 W3e — page summary. "X of Y targets met this week" is the
// single most-asked-about glanceable answer. We define "met this
// week" as the target having `daysInRange7d >= 4` AND not flagged as
// `insufficientData`. The streak highlight surfaces the metric with
// the longest current ≥ 3 day streak; nothing renders client-side
// when no target hits the bar.
// --------------------------------------------------------------------
const targetsMetThisWeek = targets.filter(
(target) => !target.insufficientData && target.daysInRange7d >= 4,
).length;
let streakHighlight: TargetPageSummary["streakHighlight"] = null;
for (const target of targets) {
if (target.streakDays < 3) continue;
if (!streakHighlight || target.streakDays > streakHighlight.days) {
streakHighlight = { metric: target.type, days: target.streakDays };
}
}
const pageSummary: TargetPageSummary = {
targetsMetThisWeek,
totalTargets: targets.length,
streakHighlight,
};
annotate({
action: { name: "insights.targets" },
meta: { targetCount: targets.length },
meta: {
targetCount: targets.length,
targetsMetThisWeek,
streakHighlightMetric: streakHighlight?.metric ?? null,
},
});
return apiSuccess({
targets,
pageSummary,
// Extra diastolic data for BP display
bpDiastolic: {
current: latestByType.BLOOD_PRESSURE_DIA ?? null,
@@ -0,0 +1,119 @@
/**
* v1.4.25 W16c — PR detection enqueue coverage for
* `POST /api/measurements/batch`. The full per-entry envelope is
* exercised by the integration suite; this file checks the hook fires
* with the right `silent` flag for the small / large batch cases and
* stays out of the way when nothing landed.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
measurement: {
findMany: vi.fn(),
createMany: vi.fn(),
},
$transaction: vi.fn(async (fn: unknown) => {
if (typeof fn === "function") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (fn as any)(prisma as unknown as { measurement: unknown });
}
}),
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/jobs/pr-detection", () => ({
enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function makeRequest(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/measurements/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
function validStepEntry(externalId: string) {
return {
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
value: 1200,
unit: "count",
startDate: "2026-05-14T06:30:00.000Z",
endDate: "2026-05-14T06:45:00.000Z",
externalId,
};
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 60,
resetAt: Date.now() + 60_000,
});
vi.mocked(prisma.measurement.findMany).mockResolvedValue([]);
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 0 });
});
describe("POST /api/measurements/batch — PR detection enqueue (v1.4.25 W16c)", () => {
it("enqueues with silent=false for a small healthy batch", async () => {
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
const res = await POST(makeRequest({ entries: [validStepEntry("ext-a")] }));
expect(res.status).toBe(200);
expect(enqueuePrDetection).toHaveBeenCalledTimes(1);
expect(enqueuePrDetection).toHaveBeenCalledWith("user-1", {
silent: false,
});
});
it("sets silent=true once the batch crosses the 50-entry threshold", async () => {
const big = Array.from({ length: 51 }, (_, i) =>
validStepEntry(`ext-${i}`),
);
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 51 });
const res = await POST(makeRequest({ entries: big }));
expect(res.status).toBe(200);
expect(enqueuePrDetection).toHaveBeenCalledWith("user-1", {
silent: true,
});
});
});
+106 -30
View File
@@ -34,10 +34,30 @@ import {
import { withIdempotency } from "@/lib/idempotency";
import { mapAppleHealthEntry } from "@/lib/measurements/apple-health-mapping";
import { validateMeasurementRange } from "@/lib/validations/measurement";
import { deviceTypeEnum } from "@/lib/validations/source-priority";
import { checkRateLimit } from "@/lib/rate-limit";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
import { Prisma } from "@/generated/prisma/client";
// v1.4.25 W16c — historical-backfill threshold for PR push
// suppression. A batch larger than this fires the detection job with
// `silent: true` so a multi-year Apple Health backfill writes records
// without spamming the user with hundreds of pushes. Tuned generously
// — a healthy daily-sync batch is typically well under 50 entries.
const PR_DETECTION_SILENT_THRESHOLD = 50;
const MAX_BATCH_ENTRIES = 500;
// v1.4.25 W10 reconcile (security H-2): cap batch ingest at 60
// batches per user per minute. Healthy iOS sync drains its
// HealthKit observer queue in well under one batch per minute (the
// observer pattern coalesces), so 60/min × 500 entries/batch =
// 30 000 rows/min headroom — generous for legitimate use, and a
// hard stop for a leaked wildcard token trying to saturate the
// write pipeline.
const BATCH_RATE_LIMIT_MAX = 60;
const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
const batchEntrySchema = z.object({
hkIdentifier: z.string().min(1).max(120),
value: z.number().finite(),
@@ -47,6 +67,14 @@ const batchEntrySchema = z.object({
sleepStage: z.number().int().min(0).max(20).optional(),
externalId: z.string().min(1).max(120),
externalSourceVersion: z.string().min(1).max(120).optional(),
// v1.4.25 W8c — optional device-type tag. The iOS client maps the
// `HKDevice.model` of each sample to one of the canonical device
// classes (watch | band | ring | phone | scale | other | unknown).
// Backward-compatible: every pre-W8c iOS build skips the field and
// the row is stored with `deviceType = null`; the canonical picker
// treats null as `unknown` and only uses it as a tiebreaker when a
// ranked device-type coexists in the same daily bucket.
deviceType: deviceTypeEnum.nullable().optional(),
});
const batchPayloadSchema = z.object({
@@ -75,6 +103,25 @@ export const POST = apiHandler(withIdempotency<[NextRequest]>(postBatch));
async function postBatch(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
// v1.4.25 W10 reconcile (security H-2): per-user rate limit. Without
// it, a leaked wildcard iOS token can sustain unbounded writes
// (500 rows/batch × N batches/sec) and degrade the database for
// every other user on the host. 60 batches/min/user is generous
// for healthy iOS sync and tight enough that a misbehaving client
// bottoms out within a minute.
const rl = await checkRateLimit(
`measurements:batch:${user.id}`,
BATCH_RATE_LIMIT_MAX,
BATCH_RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
annotate({
action: { name: "measurement.batch.ingest" },
meta: { outcome: "rate_limited" },
});
return apiError("Too many batch submissions, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson<unknown>(request);
if (jsonError) return jsonError;
@@ -90,7 +137,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
(rawBody as { entries: unknown[] }).entries.length > MAX_BATCH_ENTRIES
) {
return apiError(`Batch exceeds the ${MAX_BATCH_ENTRIES}-entry limit`, 422, {
errorCode: "coach.batch.too_large",
errorCode: "measurement.batch.too_large",
});
}
@@ -156,6 +203,11 @@ async function postBatch(request: NextRequest): Promise<Response> {
externalId: entry.externalId,
externalSourceVersion: entry.externalSourceVersion ?? null,
sleepStage: mapped.sleepStage ?? null,
// v1.4.25 W8c — pass the iOS-supplied device-type through to
// the row. Stays null for pre-W8c iOS builds; the canonical
// picker treats null as `unknown` so legacy ingest keeps
// working without a server-side default.
deviceType: entry.deviceType ?? null,
},
});
}
@@ -223,44 +275,39 @@ async function postBatch(request: NextRequest): Promise<Response> {
}
});
// If `skipDuplicates` quietly absorbed a row (race with another
// batch), reconcile the per-entry status so the inserted +
// duplicate counts still equal `prepared.length`.
// v1.4.25 W10 reconcile (senior-dev H-1): the previous "stored
// vs not-stored" check was an effective no-op. Under standard
// Postgres semantics, `skipDuplicates` absorbs duplicate-key
// conflicts but the row is STILL present in the table (written
// by the other batch that won the race). So every row we
// attempted is in the DB after the call, and the
// `!stored.has(...)` branch never fired — leaving the aggregate
// `inserted` / `duplicate` counts inconsistent with the per-
// entry statuses under contention.
//
// Pragmatic fix: trust the `createMany` return count. The DB
// round-trip cannot distinguish a row this request wrote from
// a row the racing request wrote (the unique index sees both
// as the same key), so we cannot identify the SPECIFIC raced
// rows. What we CAN do is preserve count integrity for the
// iOS sync cursor: `insertedCount` is already the truth (it
// came from `createMany.count`); we only need to downgrade
// enough per-entry "inserted" statuses to "duplicate" so the
// per-entry envelope sums match. Order doesn't matter — the
// client checkpoints past both statuses, and the DB state for
// either outcome is identical (the row is now stored,
// single-copy).
const racedDuplicates = toInsert.length - insertedCount;
if (racedDuplicates > 0) {
// We can't tell *which* rows raced from the createMany return
// value — recheck the DB and downgrade matching `inserted`
// rows to `duplicate`. This is a rare path so the extra
// round-trip is acceptable.
const recheck = await prisma.measurement.findMany({
where: {
userId: user.id,
source: "APPLE_HEALTH",
OR: toInsert.map((row) => ({
type: row.type,
externalId: row.externalId as string,
})),
},
select: { type: true, externalId: true },
});
const stored = new Set(
recheck.map((row) => `${row.type}::${row.externalId}`),
);
// Anything `prepared` flagged as `inserted` but isn't in
// `stored` was never written and was raced by another batch —
// surface as duplicate.
let downgraded = 0;
for (const p of prepared) {
if (
results[p.index]?.status === "inserted" &&
!stored.has(`${p.row.type}::${p.row.externalId}`)
) {
if (downgraded >= racedDuplicates) break;
if (results[p.index]?.status === "inserted") {
results[p.index] = { index: p.index, status: "duplicate" };
duplicateCount += 1;
downgraded += 1;
}
}
insertedCount -= downgraded;
}
}
}
@@ -280,6 +327,35 @@ async function postBatch(request: NextRequest): Promise<Response> {
},
});
// v1.4.25 W16c — kick off PR detection for this user. We always
// enqueue when at least one row was written (or the batch had any
// measurements to consider) so a single off-day reading still gets
// evaluated; the warm-up gate inside the detector decides whether
// it's a record. Suppress push notifications for historical
// backfills above the silent threshold.
if (insertedCount > 0 || duplicateCount > 0) {
const silent = entries.length > PR_DETECTION_SILENT_THRESHOLD;
try {
await enqueuePrDetection(user.id, { silent });
await auditLog("personal_records.detection_enqueued", {
userId: user.id,
details: {
source: "measurement.batch",
batchSize: entries.length,
silent,
},
});
} catch (err) {
// Enqueue failure is non-fatal — the 30-minute fallback cron
// picks the user up in the next slot. Log so the operator
// notices repeated failures in Wide-Event traffic.
annotate({
action: { name: "personal_records.detection_enqueue_failed" },
meta: { error: err instanceof Error ? err.message : String(err) },
});
}
}
annotate({
action: { name: "measurement.batch.ingest" },
meta: {
@@ -0,0 +1,107 @@
/**
* `DELETE /api/measurements/by-external-ids` — iOS deletion reconciliation.
*
* HealthKit's API does not surface a stream of deleted UUIDs, so the
* iOS client performs a periodic 30-day window reconciliation: it pulls
* the current set of HealthKit sample UUIDs in the window and posts the
* externalIds the server holds that are no longer present. This endpoint
* accepts that batch-delete list and removes every matching row owned by
* the calling user.
*
* Idempotency contract:
* - Cross-user safety: rows owned by another user are silently skipped
* (Prisma's `deleteMany` with `userId` in the where-clause guarantees
* this), so a duplicate / replayed delete is a no-op rather than a
* 401.
* - Empty arrays return `{ deletedCount: 0 }` with 200 — the iOS
* client should not consider that a failure.
* - The batch cap mirrors the ingest path (`/api/measurements/batch`)
* so the two surfaces share the same operational ceiling.
*/
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
const MAX_BATCH_ENTRIES = 500;
const payloadSchema = z.object({
externalIds: z
.array(z.string().min(1).max(120))
.min(0)
.max(MAX_BATCH_ENTRIES),
});
export const DELETE = apiHandler(deleteByExternalIds);
async function deleteByExternalIds(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const { data: rawBody, error: jsonError } = await safeJson<unknown>(request);
if (jsonError) return jsonError;
// Distinguish "too many entries" from "validation failed" so the iOS
// client surfaces a useful diagnostic — same shape the ingest endpoint
// uses for its symmetric cap.
if (
typeof rawBody === "object" &&
rawBody !== null &&
"externalIds" in rawBody &&
Array.isArray((rawBody as { externalIds: unknown }).externalIds) &&
(rawBody as { externalIds: unknown[] }).externalIds.length >
MAX_BATCH_ENTRIES
) {
return apiError(`Batch exceeds the ${MAX_BATCH_ENTRIES}-entry limit`, 422, {
errorCode: "measurement.delete.too_large",
});
}
const parsed = payloadSchema.safeParse(rawBody);
if (!parsed.success) {
return apiError(parsed.error.issues[0]?.message ?? "Invalid batch", 422);
}
const { externalIds } = parsed.data;
if (externalIds.length === 0) {
annotate({
action: { name: "measurement.delete.by_external_ids" },
meta: { processed: 0, deleted: 0 },
});
return apiSuccess({ deletedCount: 0 });
}
// Prisma `deleteMany` with the userId predicate is the cross-user
// 404 guard: rows belonging to other users are simply not matched.
// No need for a pre-flight findMany — the unique index on
// `(userId, type, source, externalId)` keeps the matching set small
// even when the user has overlapping HealthKit history.
const result = await prisma.measurement.deleteMany({
where: {
userId: user.id,
externalId: { in: externalIds },
},
});
await auditLog("measurement.delete.by_external_ids", {
userId: user.id,
ipAddress: getClientIp(request),
details: { processed: externalIds.length, deleted: result.count },
});
annotate({
action: { name: "measurement.delete.by_external_ids" },
meta: { processed: externalIds.length, deleted: result.count },
});
return apiSuccess({ deletedCount: result.count });
}
+10 -1
View File
@@ -96,6 +96,10 @@ async function postMeasurement(request: NextRequest) {
notes: m.notes ?? null,
glucoseContext:
(m.glucoseContext as GlucoseContext | undefined) ?? null,
// v1.4.25 W10 reconcile (code-review M4): mirror the
// single-entry path so the multi-entry batch persists
// `deviceType` instead of silently dropping it.
deviceType: m.deviceType ?? null,
},
}),
),
@@ -127,7 +131,7 @@ async function postMeasurement(request: NextRequest) {
return apiError(parsed.error.issues[0].message, 422);
}
const { type, value, measuredAt, notes, source, glucoseContext } =
const { type, value, measuredAt, notes, source, glucoseContext, deviceType } =
parsed.data;
// Handle unique constraint violation
@@ -143,6 +147,11 @@ async function postMeasurement(request: NextRequest) {
measuredAt,
notes: notes ?? null,
glucoseContext: (glucoseContext as GlucoseContext | undefined) ?? null,
// v1.4.25 W10 reconcile (code-review M4): the deviceType column
// already accepts client metadata from the batch route. Mirror
// the behaviour here so single-entry POST persists the tag
// instead of silently dropping it.
deviceType: deviceType ?? null,
},
});
} catch (err) {
@@ -6,6 +6,7 @@ import { auditLog } from "@/lib/auth/audit";
import { hashToken } from "@/lib/auth/hmac";
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
@@ -22,10 +23,9 @@ export const GET = apiHandler(
}
const { id } = await params;
const medication = await prisma.medication.findUnique({ where: { id } });
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404);
}
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const scope = medicationScope(id);
const now = new Date();
@@ -63,8 +63,12 @@ export const PUT = apiHandler(
}
const { id } = await params;
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const medication = await prisma.medication.findUnique({ where: { id } });
if (!medication || medication.userId !== user.id) {
if (!medication) {
return apiError("Medication not found", 404);
}
@@ -0,0 +1,168 @@
/**
* v1.4.25 W19e — cadence + compliance API route tests.
*
* Mirrors the W19d side-effect test fixture pattern: external
* dependencies are mocked at module boundaries (Prisma, session,
* logging transport), and each test pins one contract case.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findUnique: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
beforeEach(() => {
vi.resetAllMocks();
});
describe("GET /api/medications/[id]/cadence", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/cadence"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 404 when the medication is not owned by the caller", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "OTHER",
createdAt: new Date(),
schedules: [],
} as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/cadence"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(404);
});
it("returns 422 for an out-of-range days parameter", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
createdAt: new Date(),
schedules: [],
} as never);
const res = await GET(
new NextRequest(
"http://localhost/api/medications/med-1/cadence?days=9999",
),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("returns the timeline + chips for an owned medication", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
createdAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
schedules: [
{
id: "sch-1",
medicationId: "med-1",
windowStart: "08:00",
windowEnd: "09:00",
label: null,
dose: null,
daysOfWeek: null,
},
],
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([]);
const res = await GET(
new NextRequest(
"http://localhost/api/medications/med-1/cadence?days=14",
),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.windowDays).toBe(14);
expect(Array.isArray(json.data.timeline)).toBe(true);
expect(json.data.chips).toMatchObject({
adherenceRate: expect.anything(),
currentStreak: expect.any(Number),
longestStreak: expect.any(Number),
missedLast30: expect.any(Number),
});
});
it("defaults to a 30-day window when no `days` query param is supplied", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
createdAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
schedules: [],
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([]);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/cadence"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.windowDays).toBe(30);
});
it("returns a `null` next-dose when no schedule expands in the lookahead", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "user-1",
createdAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
schedules: [],
} as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([]);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/cadence"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.next).toBeNull();
});
});
@@ -0,0 +1,144 @@
/**
* v1.4.25 W19e — GLP-1 cadence + compliance read endpoint.
*
* GET /api/medications/[id]/cadence?days=30
* - returns the schedule's 30-day (configurable) expected-vs-actual
* dose timeline plus the four compliance chip values that drive
* the detail-page section.
*
* Pure computation — no writes. The route delegates math to the
* `src/lib/medications/scheduling/{cadence,compliance}` pure modules
* so the chart, the chips, and the server agree dose-for-dose.
*
* Auth: cookie-session via requireAuth(); the medication is verified
* to belong to the caller before any read.
*/
import { NextRequest } from "next/server";
import { z } from "zod";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiError, apiSuccess } from "@/lib/api-response";
import {
buildCadenceTimeline,
computeNextDose,
} from "@/lib/medications/scheduling/cadence";
import { complianceChips } from "@/lib/medications/scheduling/compliance";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { resolveUserTimezone } from "@/lib/tz/resolver";
type RouteParams = { params: Promise<{ id: string }> };
const querySchema = z.object({
days: z.coerce.number().int().min(1).max(180).optional(),
});
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const med = await prisma.medication.findUnique({
where: { id },
include: { schedules: true },
});
if (!med) {
return apiError("Medication not found", 404);
}
const url = new URL(request.url);
const parsed = querySchema.safeParse({
days: url.searchParams.get("days") ?? undefined,
});
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const windowDays = parsed.data.days ?? 30;
const asOf = new Date();
// Pull intake events covering the window + a small buffer so a dose
// logged at the edge can still pair with its slot.
const bufferMs = 24 * 60 * 60 * 1000;
const from = new Date(asOf.getTime() - windowDays * 24 * 60 * 60 * 1000 - bufferMs);
const events = await prisma.medicationIntakeEvent.findMany({
where: {
medicationId: id,
userId: user.id,
scheduledFor: { gte: from },
},
select: { scheduledFor: true, takenAt: true, skipped: true },
orderBy: { scheduledFor: "asc" },
});
// The cadence-anchor is the medication's createdAt for stable
// intervalWeeks=N phasing. Falling back to `from` would re-anchor
// every window-size change, which would make a 30-day vs 90-day
// chart disagree on the bi-weekly grid.
const anchor = med.createdAt;
// v1.4.25 W21 Fix-O — resolve the user's IANA zone so the cadence
// helpers compute every local-day boundary, window-time application,
// and streak day-key in the user's clock instead of the host's
// system time. A Tokyo user reading the same medication gets the
// same chip values as a Berlin user — the host-relative day flip
// was a sneaky bug at the 08:00-Berlin / 16:00-Tokyo intersection.
const userTz = await resolveUserTimezone(user.id);
const timeline = buildCadenceTimeline(
med.schedules,
events,
asOf,
windowDays,
anchor,
userTz,
);
const chips = complianceChips(
med.schedules,
events,
asOf,
windowDays,
anchor,
userTz,
);
const next = computeNextDose(med.schedules, asOf, 14, anchor, userTz);
annotate({
action: {
name: "medication.cadence",
entity_type: "medication",
entity_id: id,
},
meta: {
window_days: windowDays,
slots: timeline.length,
adherence: chips.adherenceRate,
},
});
return apiSuccess({
windowDays,
anchorIso: anchor.toISOString(),
next: next
? {
windowStart: next.windowStart.toISOString(),
windowEnd: next.windowEnd.toISOString(),
scheduleIndex: next.scheduleIndex,
}
: null,
chips,
timeline: timeline.map((p) => ({
day: p.day.toISOString(),
windowStart: p.windowStart.toISOString(),
windowEnd: p.windowEnd.toISOString(),
scheduleIndex: p.scheduleIndex,
status: p.status,
})),
});
},
);
@@ -7,6 +7,7 @@ import {
classifyIntakeTiming,
} from "@/lib/analytics/compliance";
import type { DailyComplianceEntry } from "@/lib/analytics/compliance";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
type RouteParams = { params: Promise<{ id: string }> };
@@ -15,12 +16,16 @@ export const GET = apiHandler(
const { user } = await requireAuth();
const { id } = await params;
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const medication = await prisma.medication.findUnique({
where: { id },
include: { schedules: true },
});
if (!medication || medication.userId !== user.id) {
if (!medication) {
return apiError("Medication not found", 404);
}
@@ -0,0 +1,337 @@
/**
* v1.4.25 W21 Fix-K — GLP-1 convenience-route POST tests.
*
* Pins the sec-H1 hardening pass: Zod parse, 30/min/user rate-limit,
* `medication.glp1.update` audit row, bounded `doseValue` / `note` /
* `effectiveFrom`, XOR refinement between the doseChange / inventory
* branches. Mirrors the sibling inventory + side-effect fixture
* pattern so the four routes share one test shape.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findUnique: vi.fn() },
medicationDoseChange: { create: vi.fn() },
medicationInventoryEvent: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: vi.fn(() => ({ "X-RateLimit-Remaining": "0" })),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
const MED_OK = { id: "med-1", userId: "user-1" };
function jsonReq(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/medications/med-1/glp1", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 29,
resetAt: Date.now() + 60_000,
});
});
describe("POST /api/medications/[id]/glp1", () => {
it("rejects unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await POST(
jsonReq({
doseChange: {
effectiveFrom: "2026-05-14T08:00:00Z",
doseValue: 0.5,
doseUnit: "mg",
},
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 404 when the medication belongs to a different user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "OTHER",
} as never);
const res = await POST(
jsonReq({
doseChange: {
effectiveFrom: "2026-05-14T08:00:00Z",
doseValue: 0.5,
doseUnit: "mg",
},
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(404);
});
it("returns 422 when neither doseChange nor inventory is provided", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(jsonReq({}), {
params: Promise.resolve({ id: "med-1" }),
});
expect(res.status).toBe(422);
});
it("returns 422 when both doseChange and inventory are provided", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq({
doseChange: {
effectiveFrom: "2026-05-14T08:00:00Z",
doseValue: 0.5,
doseUnit: "mg",
},
inventory: { delta: 1, reason: "refill" },
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("returns 422 when doseValue is negative", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq({
doseChange: {
effectiveFrom: "2026-05-14T08:00:00Z",
doseValue: -1,
doseUnit: "mg",
},
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("returns 422 when doseValue is NaN (not a finite number)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
// JSON cannot encode NaN, but Number.NaN reaches the schema if the
// client base64-encodes or hand-crafts the body. Simulate the
// post-decode shape by passing `null` (which fails the finite gate)
// and `1e400` (which JSON renders as null too) via the same path.
const res = await POST(
jsonReq({
doseChange: {
effectiveFrom: "2026-05-14T08:00:00Z",
doseValue: null,
doseUnit: "mg",
},
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("returns 422 when doseValue exceeds 100 mg cap", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq({
doseChange: {
effectiveFrom: "2026-05-14T08:00:00Z",
doseValue: 250,
doseUnit: "mg",
},
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("returns 422 when note exceeds 500 chars", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq({
doseChange: {
effectiveFrom: "2026-05-14T08:00:00Z",
doseValue: 0.5,
doseUnit: "mg",
note: "x".repeat(501),
},
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("returns 422 when effectiveFrom predates 2020", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq({
doseChange: {
effectiveFrom: "1995-01-01T00:00:00Z",
doseValue: 0.5,
doseUnit: "mg",
},
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("returns 422 when inventory delta is zero", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq({ inventory: { delta: 0, reason: "noop" } }),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("returns 429 when the rate limit is exceeded", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await POST(
jsonReq({
doseChange: {
effectiveFrom: "2026-05-14T08:00:00Z",
doseValue: 0.5,
doseUnit: "mg",
},
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(429);
expect(res.headers.get("X-RateLimit-Remaining")).toBe("0");
});
it("creates a doseChange row and writes a medication.glp1.update audit entry", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationDoseChange.create).mockResolvedValue({
id: "dc-new",
effectiveFrom: new Date("2026-05-14T08:00:00Z"),
doseValue: 0.5,
doseUnit: "mg",
note: null,
} as never);
const res = await POST(
jsonReq({
doseChange: {
effectiveFrom: "2026-05-14T08:00:00Z",
doseValue: 0.5,
doseUnit: "mg",
},
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(201);
expect(prisma.medicationDoseChange.create).toHaveBeenCalledWith({
data: expect.objectContaining({
medicationId: "med-1",
effectiveFrom: new Date("2026-05-14T08:00:00Z"),
doseValue: 0.5,
doseUnit: "mg",
note: null,
}),
});
expect(auditLog).toHaveBeenCalledWith(
"medication.glp1.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
medicationId: "med-1",
kind: "doseChange",
doseChangeId: "dc-new",
doseValue: 0.5,
doseUnit: "mg",
}),
}),
);
});
it("creates an inventory row and writes a medication.glp1.update audit entry", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationInventoryEvent.create).mockResolvedValue({
id: "iv-new",
delta: 1,
reason: "refill",
} as never);
const res = await POST(
jsonReq({ inventory: { delta: 1, reason: "refill" } }),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(201);
expect(prisma.medicationInventoryEvent.create).toHaveBeenCalledWith({
data: { medicationId: "med-1", delta: 1, reason: "refill" },
});
expect(auditLog).toHaveBeenCalledWith(
"medication.glp1.update",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({
medicationId: "med-1",
kind: "inventory",
inventoryEventId: "iv-new",
delta: 1,
reason: "refill",
}),
}),
);
});
});
+220
View File
@@ -0,0 +1,220 @@
/**
* v1.4.25 W4d — GLP-1 medication details endpoint.
*
* Returns per-medication extras the GLP-1 card variant + the dashboard
* tile + the doctor-report PDF section read: chronological dose-change
* history, the last 12 injection events (with optional site
* rotation data), and the running pen-inventory math. The base
* /api/medications/[id] GET is unchanged so v1.4.24 consumers keep
* working untouched.
*
* v1.4.25 W21 Fix-K — POST hardened with Zod parse, audit-log, 30/min
* rate-limit, finite-number guard on `doseValue`, length cap on `note`,
* and sane bounds on `effectiveFrom`. Mirrors the sibling
* `/inventory` + `/side-effects` route shape.
*/
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { glp1PostBodySchema } from "@/lib/validations/medication";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
const LOW_STOCK_DOSE_THRESHOLD = 4;
const POST_RATE_LIMIT = 30;
const POST_WINDOW_MS = 60_000;
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const medication = await prisma.medication.findUnique({
where: { id },
include: {
doseChanges: { orderBy: { effectiveFrom: "asc" } },
inventoryEvents: { orderBy: { occurredAt: "asc" } },
intakeEvents: {
where: { takenAt: { not: null } },
orderBy: { takenAt: "desc" },
take: 12,
select: { takenAt: true, injectionSite: true },
},
schedules: true,
},
});
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404);
}
// Inventory math: running sum of every inventory event. Negative
// when more doses were consumed than purchased — surfaces as a
// low-stock warning rather than a hard error so the UI keeps
// working when the user backdates an inventory event.
let inventory: {
pensRemaining: number | null;
dosesRemaining: number | null;
weeksOfSupply: number | null;
lowStock: boolean;
} | null = null;
if (medication.dosesPerUnit && medication.inventoryEvents.length > 0) {
const pens = medication.inventoryEvents.reduce(
(sum, ev) => sum + ev.delta,
0,
);
const pensRemaining = Math.max(0, pens);
const dosesRemaining = pensRemaining * medication.dosesPerUnit;
// Approximate weeks-of-supply assuming weekly cadence (the
// canonical GLP-1 case). The Coach snapshot does the same.
const weeksOfSupply = dosesRemaining;
const lowStock = dosesRemaining < LOW_STOCK_DOSE_THRESHOLD;
inventory = {
pensRemaining,
dosesRemaining,
weeksOfSupply,
lowStock,
};
}
return apiSuccess({
doseChanges: medication.doseChanges.map((dc) => ({
id: dc.id,
effectiveFrom: dc.effectiveFrom.toISOString(),
doseValue: dc.doseValue,
doseUnit: dc.doseUnit,
note: dc.note,
})),
recentIntakes: medication.intakeEvents.map((iv) => ({
takenAt: iv.takenAt ? iv.takenAt.toISOString() : null,
injectionSite: iv.injectionSite,
})),
inventory,
});
},
);
/**
* POST creates a new dose change OR inventory event (the body picks
* one — caller specifies which). Convenience endpoint so the
* medication-card disclosure can write rows without dispatching to
* /api/medications/[id]/dose-change + /api/medications/[id]/inventory
* separately for the v1.4.25 cut.
*
* Validation: `glp1PostBodySchema` (XOR of `doseChange` / `inventory`),
* with finite-number guards on `doseValue` + `delta`, bounded `note`
* + `reason`, and a ±5-year window on `effectiveFrom`.
* Rate-limit: 30/min/user (same as sibling POST routes).
* Audit: `medication.glp1.update` for every successful mutation.
*/
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
// Per-user POST rate-limit — matches the 30/min sibling routes
// (inventory, side-effects). Generous for a hand-driven session,
// tight enough to cut off the spam case.
const rl = await checkRateLimit(
`medication-glp1:post:${user.id}`,
POST_RATE_LIMIT,
POST_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
});
}
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = glp1PostBodySchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const ip = getClientIp(request);
if (parsed.data.doseChange) {
const { effectiveFrom, doseValue, doseUnit, note } =
parsed.data.doseChange;
const created = await prisma.medicationDoseChange.create({
data: {
medicationId: id,
effectiveFrom,
doseValue,
doseUnit,
note: note ?? null,
},
});
await auditLog("medication.glp1.update", {
userId: user.id,
ipAddress: ip,
details: {
medicationId: id,
kind: "doseChange",
doseChangeId: created.id,
doseValue,
doseUnit,
},
});
annotate({
action: {
name: "medication.glp1.doseChange.create",
entity_type: "medication_dose_change",
entity_id: created.id,
},
meta: { medication_id: id, doseValue, doseUnit },
});
return apiSuccess({ doseChange: created }, 201);
}
// The schema's XOR refinement guarantees `inventory` is present
// when `doseChange` is not, so the non-null assertion is safe.
const { delta, reason } = parsed.data.inventory!;
const created = await prisma.medicationInventoryEvent.create({
data: { medicationId: id, delta, reason },
});
await auditLog("medication.glp1.update", {
userId: user.id,
ipAddress: ip,
details: {
medicationId: id,
kind: "inventory",
inventoryEventId: created.id,
delta,
reason,
},
});
annotate({
action: {
name: "medication.glp1.inventory.create",
entity_type: "medication_inventory_event",
entity_id: created.id,
},
meta: { medication_id: id, delta },
});
return apiSuccess({ inventory: created }, 201);
},
);
+54 -10
View File
@@ -13,6 +13,8 @@ import {
listIntakeEventsSchema,
} from "@/lib/validations/medication";
import { withIdempotency } from "@/lib/idempotency";
import { consumeOneDose } from "@/lib/medications/inventory/service";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
@@ -25,10 +27,9 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
const { user } = await requireAuth();
const { id } = await params;
const medication = await prisma.medication.findUnique({ where: { id } });
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404);
}
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const { data: body, error: jsonError } = await safeJson(request);
@@ -94,10 +95,45 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
: []),
]);
// v1.4.25 W19b — pen-inventory dose decrement. Only fires for
// non-skipped intakes; a skipped event is not a consumption event.
// No-op when the medication has no tracked pens (most non-GLP-1
// meds). Failures here must never block the intake write, so
// errors are swallowed and logged — the intake is the source of
// truth, the inventory is an opt-in companion.
let inventoryOutcome: Awaited<ReturnType<typeof consumeOneDose>> = null;
if (!skipped) {
try {
inventoryOutcome = await consumeOneDose({
userId: user.id,
medicationId: id,
intakeAt: event.takenAt ?? event.scheduledFor,
});
} catch (err) {
annotate({
action: { name: "medication.inventory.consume_error" },
meta: {
medication_id: id,
message: err instanceof Error ? err.message : String(err),
},
});
}
}
await auditLog("medication.intake", {
userId: user.id,
ipAddress: getClientIp(request),
details: { medicationId: id, eventId: event.id, skipped },
details: {
medicationId: id,
eventId: event.id,
skipped,
...(inventoryOutcome
? {
inventoryItemId: inventoryOutcome.itemId,
inventoryChange: inventoryOutcome.change,
}
: {}),
},
});
annotate({
@@ -106,7 +142,16 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
entity_type: "intake_event",
entity_id: event.id,
},
meta: { medication_id: id, skipped },
meta: {
medication_id: id,
skipped,
...(inventoryOutcome
? {
inventory_item_id: inventoryOutcome.itemId,
inventory_change: inventoryOutcome.change,
}
: {}),
},
});
return apiSuccess(event, 201);
@@ -117,10 +162,9 @@ export const GET = apiHandler(
const { user } = await requireAuth();
const { id } = await params;
const medication = await prisma.medication.findUnique({ where: { id } });
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404);
}
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const searchParams = Object.fromEntries(request.nextUrl.searchParams);
const parsed = listIntakeEventsSchema.safeParse(searchParams);
@@ -0,0 +1,183 @@
/**
* v1.4.25 W19b — per-pen inventory item operations.
*
* PATCH /api/medications/[id]/inventory/[itemId]
* - mark-as-first-use (starts the 30-day clock manually)
* - mark-as-used-up (terminal — operator override when a pen is
* physically discarded but the dose ledger hasn't reached zero)
* - update printed expiry (e.g. carton label correction)
* - update notes
*
* DELETE /api/medications/[id]/inventory/[itemId]
* - hard delete; admin / cleanup path. The audit log captures
* the before-state so a row can be reconstructed if needed.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { updateInventoryItemSchema } from "@/lib/validations/medication";
import {
computeExpiresAt,
computeInventoryState,
} from "@/lib/medications/inventory/state-machine";
type RouteParams = { params: Promise<{ id: string; itemId: string }> };
async function loadOwnedItem(
medicationId: string,
itemId: string,
userId: string,
) {
const item = await prisma.medicationInventoryItem.findUnique({
where: { id: itemId },
});
if (!item || item.userId !== userId || item.medicationId !== medicationId) {
return null;
}
return item;
}
export const PATCH = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id, itemId } = await params;
const existing = await loadOwnedItem(id, itemId, user.id);
if (!existing) return apiError("Inventory item not found", 404);
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = updateInventoryItemSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const { markAsFirstUseAt, markAsUsedUp, printedExpiry, notes } =
parsed.data;
// Compose the next-row shape. Each mutation field is optional and
// commutative — applying them in any order produces the same row.
let nextFirstUseAt = existing.firstUseAt;
let nextState = existing.state;
let nextDosesRemaining = existing.dosesRemaining;
let nextPrintedExpiry = existing.printedExpiry;
if (markAsFirstUseAt) {
// The clock can be set manually if the user opened the pen
// without logging an intake event.
nextFirstUseAt = markAsFirstUseAt;
if (nextState === "ACTIVE") nextState = "IN_USE";
}
if (printedExpiry !== undefined) {
nextPrintedExpiry = printedExpiry;
}
if (markAsUsedUp === true) {
nextDosesRemaining = 0;
nextState = "USED_UP";
}
const nextExpiresAt = computeExpiresAt(nextFirstUseAt, nextPrintedExpiry);
// Re-run the canonical state machine over the composed next-row
// view. The clause-by-clause updates above set the state ad-hoc
// (`ACTIVE → IN_USE` on first use, `* → USED_UP` on mark-as-used),
// but a back-dated `markAsFirstUseAt` whose 30-day window already
// elapsed should land at EXPIRED, not IN_USE. The state machine is
// pure and idempotent — running it once more with the composed view
// collapses every edge case onto the same decision tree the intake
// hook and the daily expire cron already share. USED_UP is terminal
// (dosesRemaining === 0 ⇒ USED_UP wins at clause 1), so the manual
// override remains sticky.
nextState = computeInventoryState(
{
state: nextState,
dosesTotal: existing.dosesTotal,
dosesRemaining: nextDosesRemaining,
firstUseAt: nextFirstUseAt,
printedExpiry: nextPrintedExpiry,
},
Date.now(),
);
const updated = await prisma.medicationInventoryItem.update({
where: { id: itemId },
data: {
state: nextState,
firstUseAt: nextFirstUseAt,
dosesRemaining: nextDosesRemaining,
printedExpiry: nextPrintedExpiry,
expiresAt: nextExpiresAt,
notes: notes === undefined ? existing.notes : notes,
},
});
await auditLog("medication.inventory.update", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
itemId,
prevState: existing.state,
nextState: updated.state,
},
});
annotate({
action: {
name: "medication.inventory.update",
entity_type: "inventory_item",
entity_id: itemId,
},
meta: { medication_id: id, prev: existing.state, next: updated.state },
});
return apiSuccess(updated);
},
);
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id, itemId } = await params;
const existing = await loadOwnedItem(id, itemId, user.id);
if (!existing) return apiError("Inventory item not found", 404);
await prisma.medicationInventoryItem.delete({ where: { id: itemId } });
await auditLog("medication.inventory.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
itemId,
finalState: existing.state,
dosesRemaining: existing.dosesRemaining,
},
});
annotate({
action: {
name: "medication.inventory.delete",
entity_type: "inventory_item",
entity_id: itemId,
},
meta: { medication_id: id },
});
return apiSuccess({ id: itemId, deleted: true });
},
);
@@ -0,0 +1,351 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findUnique: vi.fn() },
medicationInventoryItem: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, POST } from "../route";
import { PATCH, DELETE } from "../[itemId]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
const MED_OK = { id: "med-1", userId: "user-1" };
function jsonReq(url: string, body: unknown, method = "POST"): NextRequest {
return new NextRequest(url, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 29,
resetAt: Date.now() + 60_000,
});
});
describe("GET /api/medications/[id]/inventory", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/inventory"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 404 when the medication is not owned by the caller", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "OTHER",
} as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/inventory"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(404);
});
it("returns the inventory list for the owned medication", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationInventoryItem.findMany).mockResolvedValue([
{ id: "inv-1", state: "ACTIVE" },
{ id: "inv-2", state: "IN_USE" },
] as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/inventory"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { items: unknown[]; meta: { total: number } };
};
expect(body.data.items).toHaveLength(2);
expect(body.data.meta.total).toBe(2);
});
});
describe("POST /api/medications/[id]/inventory", () => {
it("rejects unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
dosesTotal: 4,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 422 when dosesTotal is missing or invalid", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
dosesTotal: 0,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("creates an ACTIVE pen with computed expiresAt", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationInventoryItem.create).mockResolvedValue({
id: "inv-new",
state: "ACTIVE",
} as never);
const printed = "2027-06-01T00:00:00Z";
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
dosesTotal: 4,
printedExpiry: printed,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(201);
expect(prisma.medicationInventoryItem.create).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: "user-1",
medicationId: "med-1",
state: "ACTIVE",
dosesTotal: 4,
dosesRemaining: 4,
firstUseAt: null,
printedExpiry: new Date(printed),
expiresAt: new Date(printed),
}),
});
});
it("returns 429 when the rate limit is exceeded", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/inventory", {
dosesTotal: 4,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(429);
});
});
describe("PATCH /api/medications/[id]/inventory/[itemId]", () => {
const existingActive = {
id: "inv-1",
userId: "user-1",
medicationId: "med-1",
state: "ACTIVE" as const,
dosesTotal: 4,
dosesRemaining: 4,
firstUseAt: null,
printedExpiry: null,
purchasedAt: null,
notes: null,
expiresAt: null,
createdAt: new Date(),
updatedAt: new Date(),
};
it("flips ACTIVE → IN_USE on markAsFirstUseAt", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue(
existingActive as never,
);
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
...existingActive,
state: "IN_USE",
} as never);
const intake = "2026-05-14T12:00:00Z";
const res = await PATCH(
jsonReq(
"http://localhost/api/medications/med-1/inventory/inv-1",
{ markAsFirstUseAt: intake },
"PATCH",
),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(200);
const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0];
expect(updateCall.data).toMatchObject({
state: "IN_USE",
firstUseAt: new Date(intake),
});
});
it("flips state to USED_UP and zeros remaining on markAsUsedUp", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
...existingActive,
state: "IN_USE",
firstUseAt: new Date("2026-05-01"),
dosesRemaining: 2,
} as never);
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
...existingActive,
state: "USED_UP",
dosesRemaining: 0,
} as never);
const res = await PATCH(
jsonReq(
"http://localhost/api/medications/med-1/inventory/inv-1",
{ markAsUsedUp: true },
"PATCH",
),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(200);
const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0];
expect(updateCall.data).toMatchObject({
state: "USED_UP",
dosesRemaining: 0,
});
});
it("flips ACTIVE → EXPIRED when markAsFirstUseAt is more than 30 days back-dated", async () => {
// Regression for the W19b state-machine bypass: composing the next
// state by hand sent ACTIVE → IN_USE on any first-use stamp, but a
// back-dated stamp whose 30-day window already lapsed must land at
// EXPIRED. Re-running `computeInventoryState` after the composed
// view closes the gap.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue(
existingActive as never,
);
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
...existingActive,
state: "EXPIRED",
} as never);
// 45 days in the past — well past the 30-day in-use window.
const backdated = new Date(
Date.now() - 45 * 24 * 60 * 60 * 1000,
).toISOString();
const res = await PATCH(
jsonReq(
"http://localhost/api/medications/med-1/inventory/inv-1",
{ markAsFirstUseAt: backdated },
"PATCH",
),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(200);
const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock
.calls[0][0];
expect(updateCall.data).toMatchObject({
state: "EXPIRED",
firstUseAt: new Date(backdated),
});
});
it("rejects 404 when the item belongs to another user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
...existingActive,
userId: "OTHER",
} as never);
const res = await PATCH(
jsonReq(
"http://localhost/api/medications/med-1/inventory/inv-1",
{ markAsUsedUp: true },
"PATCH",
),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(404);
});
});
describe("DELETE /api/medications/[id]/inventory/[itemId]", () => {
it("hard-deletes the row and audit-logs the final state", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
id: "inv-1",
userId: "user-1",
medicationId: "med-1",
state: "EXPIRED",
dosesRemaining: 2,
} as never);
vi.mocked(prisma.medicationInventoryItem.delete).mockResolvedValue({
id: "inv-1",
} as never);
const res = await DELETE(
new NextRequest("http://localhost/api/medications/med-1/inventory/inv-1", {
method: "DELETE",
}),
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
);
expect(res.status).toBe(200);
expect(prisma.medicationInventoryItem.delete).toHaveBeenCalledWith({
where: { id: "inv-1" },
});
});
});
@@ -0,0 +1,123 @@
/**
* v1.4.25 W19b — pen / vial inventory CRUD.
*
* GET /api/medications/[id]/inventory — list (user-scoped, all states)
* POST /api/medications/[id]/inventory — register a new pen / vial
*
* Per-item operations live on the `[itemId]` sub-route.
*
* Auth: cookie-session via requireAuth(); the medication is verified
* to belong to the caller before any read/write touches inventory.
* Rate-limit 30/min/user on the POST path (creation is the only
* surface that can spam writes; GET is read-only and benign).
* Audit-log every mutation with the affected itemId.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { createInventoryItemSchema } from "@/lib/validations/medication";
import { buildCreateInventoryInput } from "@/lib/medications/inventory/service";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
type RouteParams = { params: Promise<{ id: string }> };
const POST_RATE_LIMIT = 30;
const POST_WINDOW_MS = 60_000;
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const items = await prisma.medicationInventoryItem.findMany({
where: { userId: user.id, medicationId: id },
orderBy: [{ state: "asc" }, { expiresAt: "asc" }, { createdAt: "asc" }],
});
annotate({
action: { name: "medication.inventory.list" },
meta: { medication_id: id, total: items.length },
});
return apiSuccess({ items, meta: { total: items.length } });
},
);
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
// Per-user POST rate-limit — 30/min is generous for normal pen
// registrations but cuts off the spam case.
const rl = await checkRateLimit(
`medication-inventory:post:${user.id}`,
POST_RATE_LIMIT,
POST_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
});
}
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = createInventoryItemSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const { dosesTotal, printedExpiry, purchasedAt, notes } = parsed.data;
const created = await prisma.medicationInventoryItem.create({
data: buildCreateInventoryInput({
userId: user.id,
medicationId: id,
dosesTotal,
printedExpiry: printedExpiry ?? null,
purchasedAt: purchasedAt ?? null,
notes: notes ?? null,
}),
});
await auditLog("medication.inventory.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
itemId: created.id,
dosesTotal,
},
});
annotate({
action: {
name: "medication.inventory.create",
entity_type: "inventory_item",
entity_id: created.id,
},
meta: { medication_id: id },
});
return apiSuccess(created, 201);
},
);
@@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
import { phaseConfigSchema } from "@/lib/validations/phase-config";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
@@ -12,13 +13,9 @@ export const GET = apiHandler(
const { user } = await requireAuth();
const { id } = await params;
const medication = await prisma.medication.findUnique({
where: { id },
select: { userId: true },
});
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404);
}
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const config = await prisma.reminderPhaseConfig.findUnique({
where: { medicationId: id },
@@ -53,13 +50,9 @@ export const PUT = apiHandler(
const { user } = await requireAuth();
const { id } = await params;
const medication = await prisma.medication.findUnique({
where: { id },
select: { userId: true },
});
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404);
}
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const { data: body, error: jsonError } = await safeJson(request);
@@ -95,13 +88,9 @@ export const DELETE = apiHandler(
const { user } = await requireAuth();
const { id } = await params;
const medication = await prisma.medication.findUnique({
where: { id },
select: { userId: true },
});
if (!medication || medication.userId !== user.id) {
return apiError("Medication not found", 404);
}
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
await prisma.reminderPhaseConfig.deleteMany({
where: { medicationId: id },
+12 -2
View File
@@ -71,8 +71,16 @@ export const PUT = apiHandler(
return apiError(parsed.error.issues[0].message, 422);
}
const { name, dose, category, active, notificationsEnabled, schedules } =
parsed.data;
const {
name,
dose,
category,
treatmentClass,
dosesPerUnit,
active,
notificationsEnabled,
schedules,
} = parsed.data;
const pausedAtPatch =
active === undefined
@@ -93,6 +101,8 @@ export const PUT = apiHandler(
const baseUpdateData = {
...(name !== undefined && { name }),
...(dose !== undefined && { dose }),
...(treatmentClass !== undefined && { treatmentClass }),
...(dosesPerUnit !== undefined && { dosesPerUnit }),
...(active !== undefined && { active }),
...(notificationsEnabled !== undefined && { notificationsEnabled }),
...(schedules && {
@@ -0,0 +1,72 @@
/**
* v1.4.25 W19d — per-row side-effect operations.
*
* DELETE /api/medications/[id]/side-effects/[logId]
* - hard delete; the row is user-owned, non-clinical, and the
* audit log captures enough state to reconstruct it if needed.
*
* Marc-decision (from the W19d brief): allow delete at any time
* rather than a 24-hour retraction window. Side-effect rows are not
* a clinical record; the user owns them and a stale mis-entry should
* never be undeletable. The audit trail preserves "what was deleted
* when" for any forensic reconstruction.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
type RouteParams = { params: Promise<{ id: string; logId: string }> };
async function loadOwnedRow(
medicationId: string,
logId: string,
userId: string,
) {
const row = await prisma.medicationSideEffect.findUnique({
where: { id: logId },
});
if (!row || row.userId !== userId || row.medicationId !== medicationId) {
return null;
}
return row;
}
export const DELETE = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id, logId } = await params;
const existing = await loadOwnedRow(id, logId, user.id);
if (!existing) return apiError("Side-effect log not found", 404);
await prisma.medicationSideEffect.delete({ where: { id: logId } });
await auditLog("medication.sideEffect.delete", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
sideEffectId: logId,
entry: existing.entry,
severity: existing.severity,
occurredAt: existing.occurredAt.toISOString(),
},
});
annotate({
action: {
name: "medication.sideEffect.delete",
entity_type: "medication_side_effect",
entity_id: logId,
},
meta: { medication_id: id, entry: existing.entry },
});
return apiSuccess({ id: logId, deleted: true });
},
);
@@ -0,0 +1,352 @@
/**
* v1.4.25 W19d — side-effect API route tests.
*
* Mirrors the inventory route test fixture pattern: every external
* dependency is mocked at module boundaries (Prisma, session, audit,
* rate-limit, logging transport), and each describe block exercises
* one HTTP verb's contract.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findUnique: vi.fn() },
medicationSideEffect: {
findMany: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET, POST } from "../route";
import { DELETE } from "../[logId]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
const MED_OK = { id: "med-1", userId: "user-1" };
function jsonReq(url: string, body: unknown, method = "POST"): NextRequest {
return new NextRequest(url, {
method,
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 29,
resetAt: Date.now() + 60_000,
});
});
describe("GET /api/medications/[id]/side-effects", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/side-effects"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 404 when the medication is not owned by the caller", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
id: "med-1",
userId: "OTHER",
} as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/side-effects"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(404);
});
it("returns the logs for the owned medication", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationSideEffect.findMany).mockResolvedValue([
{ id: "se-1", entry: "NAUSEA", severity: 2 },
{ id: "se-2", entry: "DIARRHEA", severity: 3 },
] as never);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/side-effects"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { items: unknown[]; meta: { total: number } };
};
expect(body.data.items).toHaveLength(2);
expect(body.data.meta.total).toBe(2);
});
it("applies from / to / limit query params", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationSideEffect.findMany).mockResolvedValue(
[] as never,
);
const from = "2026-05-01T00:00:00Z";
const to = "2026-05-14T00:00:00Z";
await GET(
new NextRequest(
`http://localhost/api/medications/med-1/side-effects?from=${from}&to=${to}&limit=10`,
),
{ params: Promise.resolve({ id: "med-1" }) },
);
const call = vi.mocked(prisma.medicationSideEffect.findMany).mock.calls[0][0];
expect(call).toMatchObject({
where: {
userId: "user-1",
medicationId: "med-1",
occurredAt: { gte: new Date(from), lt: new Date(to) },
},
take: 10,
});
});
});
describe("POST /api/medications/[id]/side-effects", () => {
it("rejects unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "GI",
entry: "NAUSEA",
severity: 2,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 422 when severity is out of range", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "GI",
entry: "NAUSEA",
severity: 7,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(422);
});
it("derives category server-side and ignores any client-supplied category (v1.4.25 W21 Fix-N / code-M6)", async () => {
// Client maliciously sends category=INJECTION_SITE for a
// NAUSEA entry. The server now derives the canonical category
// from `entry`, so the row lands with category="GI" regardless
// of the client claim. Backwards-compatible with older clients
// that still send `category`.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationSideEffect.create).mockResolvedValue({
id: "se-new",
entry: "NAUSEA",
category: "GI",
severity: 2,
} as never);
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "INJECTION_SITE",
entry: "NAUSEA",
severity: 2,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(201);
const callArgs = vi.mocked(prisma.medicationSideEffect.create).mock
.calls[0][0] as { data: { category: string } };
expect(callArgs.data.category).toBe("GI");
});
it("creates a row with the server-derived category", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationSideEffect.create).mockResolvedValue({
id: "se-new",
entry: "NAUSEA",
category: "GI",
severity: 2,
} as never);
const occurredAt = "2026-05-14T08:00:00Z";
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "GI",
entry: "NAUSEA",
severity: 2,
occurredAt,
notes: "after breakfast",
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(201);
expect(prisma.medicationSideEffect.create).toHaveBeenCalledWith({
data: expect.objectContaining({
userId: "user-1",
medicationId: "med-1",
category: "GI",
entry: "NAUSEA",
severity: 2,
occurredAt: new Date(occurredAt),
notes: "after breakfast",
}),
});
});
it("defaults occurredAt to now when omitted", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(prisma.medicationSideEffect.create).mockResolvedValue({
id: "se-new",
} as never);
const before = Date.now();
await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "GLP1_SPECIFIC",
entry: "EARLY_SATIETY",
severity: 3,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
const after = Date.now();
const call = vi.mocked(prisma.medicationSideEffect.create).mock.calls[0][0];
const created = call.data.occurredAt as Date;
expect(created.getTime()).toBeGreaterThanOrEqual(before);
expect(created.getTime()).toBeLessThanOrEqual(after);
});
it("returns 429 when the rate limit is exceeded", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await POST(
jsonReq("http://localhost/api/medications/med-1/side-effects", {
category: "GI",
entry: "NAUSEA",
severity: 2,
}),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(429);
});
});
describe("DELETE /api/medications/[id]/side-effects/[logId]", () => {
it("returns 401 unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await DELETE(
new NextRequest(
"http://localhost/api/medications/med-1/side-effects/se-1",
{ method: "DELETE" },
),
{ params: Promise.resolve({ id: "med-1", logId: "se-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 404 when the row belongs to another user", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationSideEffect.findUnique).mockResolvedValue({
id: "se-1",
userId: "OTHER",
medicationId: "med-1",
entry: "NAUSEA",
severity: 2,
occurredAt: new Date(),
} as never);
const res = await DELETE(
new NextRequest(
"http://localhost/api/medications/med-1/side-effects/se-1",
{ method: "DELETE" },
),
{ params: Promise.resolve({ id: "med-1", logId: "se-1" }) },
);
expect(res.status).toBe(404);
});
it("hard-deletes the row and audit-logs entry + severity", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medicationSideEffect.findUnique).mockResolvedValue({
id: "se-1",
userId: "user-1",
medicationId: "med-1",
entry: "NAUSEA",
severity: 3,
occurredAt: new Date("2026-05-14T08:00:00Z"),
} as never);
vi.mocked(prisma.medicationSideEffect.delete).mockResolvedValue({
id: "se-1",
} as never);
const res = await DELETE(
new NextRequest(
"http://localhost/api/medications/med-1/side-effects/se-1",
{ method: "DELETE" },
),
{ params: Promise.resolve({ id: "med-1", logId: "se-1" }) },
);
expect(res.status).toBe(200);
expect(prisma.medicationSideEffect.delete).toHaveBeenCalledWith({
where: { id: "se-1" },
});
});
});
@@ -0,0 +1,163 @@
/**
* v1.4.25 W19d — GLP-1 side-effect log CRUD (collection).
*
* GET /api/medications/[id]/side-effects?from=ISO&to=ISO&limit=50
* - returns the user's logs for this medication, newest first,
* optionally bounded by an [from, to) window.
*
* POST /api/medications/[id]/side-effects
* - creates a new entry. Body: { category, entry, severity,
* occurredAt?, notes? }. Category is verified against the
* authoritative entry → category mapping; mismatch → 422.
*
* Auth: cookie-session via requireAuth(); the medication is verified
* to belong to the caller before any read/write.
* Rate-limit: 30/min/user on the POST path.
* Audit-log every mutation with the affected row id.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import {
createSideEffectSchema,
listSideEffectsSchema,
} from "@/lib/medications/side-effects/validators";
import { categoryForEntry } from "@/lib/medications/side-effects/taxonomy";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
type RouteParams = { params: Promise<{ id: string }> };
const POST_RATE_LIMIT = 30;
const POST_WINDOW_MS = 60_000;
export const GET = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const url = new URL(request.url);
const parsed = listSideEffectsSchema.safeParse({
from: url.searchParams.get("from") ?? undefined,
to: url.searchParams.get("to") ?? undefined,
limit: url.searchParams.get("limit") ?? undefined,
});
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const { from, to, limit } = parsed.data;
const items = await prisma.medicationSideEffect.findMany({
where: {
userId: user.id,
medicationId: id,
...(from || to
? {
occurredAt: {
...(from ? { gte: from } : {}),
...(to ? { lt: to } : {}),
},
}
: {}),
},
orderBy: { occurredAt: "desc" },
take: limit,
});
annotate({
action: { name: "medication.sideEffect.list" },
meta: { medication_id: id, total: items.length },
});
return apiSuccess({ items, meta: { total: items.length } });
},
);
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
// Per-user POST rate-limit — 30/min comfortably absorbs a session
// of bulk back-fill (e.g. "log yesterday's symptoms") while cutting
// off automated abuse.
const rl = await checkRateLimit(
`medication-side-effect:post:${user.id}`,
POST_RATE_LIMIT,
POST_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
});
}
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = createSideEffectSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
}
const { entry, severity, occurredAt, notes } = parsed.data;
// v1.4.25 W21 Fix-N (code-M6) — category is derived server-side
// from the entry via the authoritative taxonomy mapping. The wire
// schema no longer accepts `category`; older clients that still
// send it now have it ignored by Zod's strict drop, and the row
// lands with the correct (entry-derived) category every time.
const category = categoryForEntry(entry);
const created = await prisma.medicationSideEffect.create({
data: {
userId: user.id,
medicationId: id,
category,
entry,
severity,
occurredAt: occurredAt ?? new Date(),
notes: notes ?? null,
},
});
await auditLog("medication.sideEffect.create", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
medicationId: id,
sideEffectId: created.id,
entry,
severity,
},
});
annotate({
action: {
name: "medication.sideEffect.create",
entity_type: "medication_side_effect",
entity_id: created.id,
},
meta: { medication_id: id, entry, severity },
});
return apiSuccess(created, 201);
},
);
@@ -0,0 +1,227 @@
/**
* v1.4.25 W19f — titration-ladder route tests.
*
* Mirrors the W19e cadence-route test fixture: external dependencies
* are mocked at module boundaries (prisma, session, logging
* transport) so each test pins one contract case. The pure ladder
* helpers are covered separately in
* `src/lib/medications/titration/__tests__/ladder.test.ts`.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
medication: { findUnique: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function makeMed(
overrides: Partial<{
name: string;
dose: string;
treatmentClass: "GLP1" | "GENERIC";
userId: string;
doseChanges: Array<{ effectiveFrom: Date; doseValue: number }>;
}> = {},
) {
return {
id: "med-1",
name: overrides.name ?? "Mounjaro",
dose: overrides.dose ?? "5 mg",
treatmentClass: overrides.treatmentClass ?? ("GLP1" as const),
userId: overrides.userId ?? "user-1",
createdAt: new Date(),
doseChanges: overrides.doseChanges ?? [],
};
}
beforeEach(() => {
vi.resetAllMocks();
});
describe("GET /api/medications/[id]/titration", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(401);
});
it("returns 404 when the medication is not owned by the caller", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
makeMed({ userId: "OTHER" }) as never,
);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(404);
});
it("returns 404 when the medication is not GLP-1", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
makeMed({ treatmentClass: "GENERIC" }) as never,
);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(404);
});
it("returns 404 when the medication brand is not in the GLP-1 catalog", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
makeMed({ name: "MysteryDrug" }) as never,
);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(404);
});
it("returns the ladder + current step for a Mounjaro user on 5 mg", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
makeMed({
name: "Mounjaro",
dose: "5 mg",
doseChanges: [
{
effectiveFrom: new Date(Date.now() - 6 * 7 * 24 * 60 * 60 * 1000),
doseValue: 2.5,
},
{
effectiveFrom: new Date(Date.now() - 2 * 7 * 24 * 60 * 60 * 1000),
doseValue: 5,
},
],
}) as never,
);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.drugId).toBe("tirzepatide");
expect(json.data.ladder.length).toBe(6);
expect(json.data.currentStep.doseMg).toBe(5);
expect(json.data.currentStepIndex).toBe(1);
expect(json.data.weeksOnCurrentStep).toBe(2);
expect(json.data.nextStep.doseMg).toBe(7.5);
expect(json.data.escalationDue).toBe(false);
});
it("returns a null current step when the user dose is outside any ladder bucket", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
makeMed({
name: "Mounjaro",
dose: "6 mg", // outside the ±10 % window of 5 and 7.5
doseChanges: [],
}) as never,
);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.currentStep).toBeNull();
expect(json.data.currentStepIndex).toBeNull();
expect(json.data.nextStep).toBeNull();
expect(json.data.escalationDue).toBe(false);
});
it("returns a null next-step at the ladder ceiling", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
makeMed({
name: "Mounjaro",
dose: "15 mg",
doseChanges: [
{
effectiveFrom: new Date(Date.now() - 5 * 7 * 24 * 60 * 60 * 1000),
doseValue: 15,
},
],
}) as never,
);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.currentStep.doseMg).toBe(15);
expect(json.data.nextStep).toBeNull();
expect(json.data.escalationDue).toBe(false);
});
it("flags escalationDue=true past the EMA dwell-time on a non-ceiling step", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
makeMed({
name: "Mounjaro",
dose: "2.5 mg",
doseChanges: [
{
// Six weeks ago, past tirzepatide's 4-week interval.
effectiveFrom: new Date(Date.now() - 6 * 7 * 24 * 60 * 60 * 1000),
doseValue: 2.5,
},
],
}) as never,
);
const res = await GET(
new NextRequest("http://localhost/api/medications/med-1/titration"),
{ params: Promise.resolve({ id: "med-1" }) },
);
expect(res.status).toBe(200);
const json = await res.json();
expect(json.data.currentStep.doseMg).toBe(2.5);
expect(json.data.weeksOnCurrentStep).toBe(6);
expect(json.data.escalationDue).toBe(true);
});
});
@@ -0,0 +1,136 @@
/**
* v1.4.25 W19f — GLP-1 titration-ladder read endpoint.
*
* GET /api/medications/[id]/titration
* - returns the EMA-reference ladder for the medication's drug,
* the user's current step (matched within ±10 % tolerance to
* their latest dose), how long they've been on that step, the
* immediate-next step (if any), and the observational
* escalation-due flag.
*
* Pure computation — no writes. Delegates math to the
* `src/lib/medications/titration/ladder` module so the route, the
* Coach snapshot (future wave), and the detail-page section all read
* the same numbers.
*
* Auth: cookie-session via requireAuth(); medication is verified to
* belong to the caller before any read. Defence-in-depth: returns
* 404 for non-GLP-1 medications (the UI already gates on
* `treatmentClass === "GLP1"`).
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiError, apiSuccess } from "@/lib/api-response";
import {
findDrugByBrand,
findDrugIdByBrand,
} from "@/lib/medications/glp1-knowledge";
import { parseDoseMgOrNull } from "@/lib/medications/dose-string";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import {
escalationDue,
findCurrentStep,
ladderFromRecord,
nextStep,
weeksOnCurrentStep,
} from "@/lib/medications/titration/ladder";
type RouteParams = { params: Promise<{ id: string }> };
export const GET = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
const { id } = await params;
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper so
// the 404 leak shape stays consistent across every medication
// sub-route.
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const med = await prisma.medication.findUnique({
where: { id },
include: {
doseChanges: { orderBy: { effectiveFrom: "asc" } },
},
});
if (!med) {
return apiError("Medication not found", 404);
}
if (med.treatmentClass !== "GLP1") {
// Defence-in-depth: the UI already gates the section on this
// class, but the route refuses to surface the ladder for a
// non-GLP-1 row so we never leak a misleading "you are here"
// on a generic medication.
return apiError("Medication not found", 404);
}
// Resolve the GLP-1 drug record from the medication name. Same
// path the DrugLevelChart uses; null when the medication name
// doesn't map to a catalog brand (e.g. user typed a generic
// INN instead of a brand). v1.4.25 W21 Fix-N — both lookups go
// through the shared helpers in glp1-knowledge.
const record = findDrugByBrand(med.name);
const drugId = findDrugIdByBrand(med.name);
if (!record || !drugId) {
return apiError("Medication not found", 404);
}
const ladder = ladderFromRecord(record);
// Resolve the user's most-recent dose. Prefer the
// MedicationDoseChange stream (the history of record); fall back
// to the legacy free-text `medication.dose` if the stream is empty.
const latestChange =
med.doseChanges.length > 0
? med.doseChanges[med.doseChanges.length - 1]
: null;
const latestDoseMg = latestChange
? latestChange.doseValue
: parseDoseMgOrNull(med.dose);
const currentStep = findCurrentStep(drugId, latestDoseMg);
const next = nextStep(drugId, currentStep);
const asOf = new Date();
const weeks = weeksOnCurrentStep(
drugId,
currentStep,
med.doseChanges.map((dc) => ({
effectiveFrom: dc.effectiveFrom,
doseValue: dc.doseValue,
})),
asOf,
);
const due = escalationDue(drugId, currentStep, weeks);
annotate({
action: {
name: "medication.titration",
entity_type: "medication",
entity_id: id,
},
meta: {
drug_id: drugId,
step_index: currentStep?.stepIndex ?? null,
weeks_on_step: weeks,
},
});
return apiSuccess({
drugId,
drugInn: record.inn,
ladder,
currentStep,
currentStepIndex: currentStep?.stepIndex ?? null,
weeksOnCurrentStep: weeks,
nextStep: next,
escalationDue: due,
sourceEMA: record.sourceEMA,
});
},
);
+11 -11
View File
@@ -23,6 +23,7 @@ import {
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
const querySchema = z.object({
scope: z.enum(["today", "compliance"]),
@@ -42,9 +43,9 @@ const updateSchema = z.object({
.optional(),
});
function startOfDayBerlin(date: Date): Date {
function startOfDayInTz(date: Date, tz: string): Date {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone: "Europe/Berlin",
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
@@ -55,12 +56,6 @@ function startOfDayBerlin(date: Date): Date {
return new Date(`${y}-${m}-${d}T00:00:00.000Z`);
}
function berlinDayKey(date: Date): string {
return new Intl.DateTimeFormat("sv-SE", {
timeZone: "Europe/Berlin",
}).format(date);
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -73,8 +68,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
const { scope, days } = parsed.data;
// v1.4.25 W7b — anchor "today" and per-day compliance buckets to the
// user's display timezone so a 23:30 reading in Pacific/Auckland lands
// in today's bucket rather than yesterday's Berlin one.
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
if (scope === "today") {
const todayStart = startOfDayBerlin(new Date());
const todayStart = startOfDayInTz(new Date(), userTz);
const todayEnd = new Date(todayStart.getTime() + 86_400_000);
const events = await prisma.medicationIntakeEvent.findMany({
where: {
@@ -119,10 +119,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
const buckets = new Map<string, { scheduled: number; taken: number }>();
for (let i = 0; i < days; i++) {
const d = new Date(Date.now() - i * 86_400_000);
buckets.set(berlinDayKey(d), { scheduled: 0, taken: 0 });
buckets.set(userDayKey(d, userTz), { scheduled: 0, taken: 0 });
}
for (const e of events) {
const key = berlinDayKey(e.scheduledFor);
const key = userDayKey(e.scheduledFor, userTz);
const bucket = buckets.get(key);
if (!bucket) continue;
bucket.scheduled += 1;
+11 -1
View File
@@ -83,6 +83,11 @@ export const GET = apiHandler(async () => {
return apiSuccess(
medications.map((m) => ({
...m,
// v1.4.25 W4d — `category` is the existing clinical taxonomy
// (BLOOD_PRESSURE / VITAMIN / ...) from the side-table; the
// Prisma model has its own `treatmentClass` (GENERIC | GLP1) that
// spreads through `...m` and unlocks the GLP-1 surfaces. Two
// orthogonal concepts, both exposed.
category: categoryMap[m.id] ?? "OTHER",
lastTakenAt: lastTakenAtByMedicationId[m.id] ?? null,
todayEventCount: todayEventCountByMedId[m.id] ?? 0,
@@ -101,13 +106,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError(parsed.error.issues[0].message, 422);
}
const { name, dose, category, schedules } = parsed.data;
const { name, dose, category, treatmentClass, dosesPerUnit, schedules } =
parsed.data;
const medication = await prisma.medication.create({
data: {
userId: user.id,
name,
dose,
// v1.4.25 W4d — treatmentClass + dosesPerUnit are optional in the
// wire schema; Prisma fills the default GENERIC when omitted.
...(treatmentClass !== undefined && { treatmentClass }),
...(dosesPerUnit !== undefined && { dosesPerUnit }),
schedules: {
create: schedules.map((s) => ({
windowStart: s.windowStart,
+9 -7
View File
@@ -13,15 +13,10 @@ import {
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
type RouteParams = { params: Promise<{ id: string }> };
function toBerlinDate(date: Date): string {
return new Intl.DateTimeFormat("sv-SE", {
timeZone: "Europe/Berlin",
}).format(date);
}
function parseTags(tags: string | null): string[] {
if (!tags) return [];
try {
@@ -80,8 +75,15 @@ export const PUT = apiHandler(
updateData.score = getScoreForMood(data.mood);
}
if (data.moodLoggedAt !== undefined) {
// v1.4.25 W7b — re-anchor the row's `date` to the user's current
// displayTimezone. Also refresh the `tz` column so the row's
// attribution stays consistent with the new `date`. Legacy rows
// promoted via this PUT therefore migrate to per-row tz without
// a separate backfill.
const tz = user.timezone ?? DEFAULT_TIMEZONE;
updateData.moodLoggedAt = data.moodLoggedAt;
updateData.date = toBerlinDate(data.moodLoggedAt);
updateData.date = moodDateKey(data.moodLoggedAt, tz);
updateData.tz = tz;
}
if (data.tags !== undefined) {
updateData.tags = data.tags ? JSON.stringify(data.tags) : null;
+8 -7
View File
@@ -16,12 +16,7 @@ import { Prisma } from "@/generated/prisma/client";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { withIdempotency } from "@/lib/idempotency";
function toBerlinDate(date: Date): string {
return new Intl.DateTimeFormat("sv-SE", {
timeZone: "Europe/Berlin",
}).format(date);
}
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
function parseTags(tags: string | null): string[] {
if (!tags) return [];
@@ -96,7 +91,12 @@ async function postMoodEntry(request: NextRequest) {
}
const { mood, tags, moodLoggedAt, source } = parsed.data;
const date = toBerlinDate(moodLoggedAt);
// v1.4.25 W7b (Decision A) — anchor the `date` string to the user's
// current displayTimezone and store the resolved zone on the row.
// Legacy rows with `tz IS NULL` continue to read as Europe/Berlin
// (see `src/lib/mood/date-key.ts`).
const tz = user.timezone ?? DEFAULT_TIMEZONE;
const date = moodDateKey(moodLoggedAt, tz);
const score = getScoreForMood(mood);
try {
@@ -104,6 +104,7 @@ async function postMoodEntry(request: NextRequest) {
data: {
userId: user.id,
date,
tz,
mood,
score,
tags: tags ? JSON.stringify(tags) : null,
@@ -0,0 +1,251 @@
/**
* v1.4.25 W21 Fix-M — concurrency regression for
* `POST /api/onboarding/step`.
*
* The step write was fetch-then-update: two parallel tabs both
* reading `onboardingStep = 2` and both POSTing `step: 3` would both
* succeed because the second write didn't re-check the precondition.
* The route now claims the row via `updateMany` with the precondition
* embedded in the WHERE clause, so exactly one update lands and the
* loser sees `count = 0` and returns 409.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: {
findUnique: vi.fn(),
findUniqueOrThrow: vi.fn(),
update: vi.fn(),
updateMany: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({
getSession: vi.fn(),
setOnboardingPendingCookie: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
remaining: 29,
resetAt: Date.now() + 60_000,
}),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../step/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function req(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/onboarding/step", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 29,
resetAt: Date.now() + 60_000,
});
});
describe("POST /api/onboarding/step — happy path", () => {
it("advances 0 → 1 on the first wizard submission", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
onboardingStep: 0,
onboardingCompletedAt: null,
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
onboardingStep: 1,
onboardingCompletedAt: null,
} as never);
const res = await POST(req({ step: 1 }));
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { step: number } };
expect(body.data.step).toBe(1);
});
it("marks completion on step 4 (sets onboardingCompletedAt)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
onboardingStep: 3,
onboardingCompletedAt: null,
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
onboardingStep: 4,
onboardingCompletedAt: new Date("2026-05-14T12:00:00Z"),
} as never);
const res = await POST(req({ step: 4 }));
expect(res.status).toBe(200);
const updateCall = vi.mocked(prisma.user.updateMany).mock.calls[0][0];
expect(updateCall.data).toMatchObject({
onboardingStep: 4,
});
expect(updateCall.data).toHaveProperty("onboardingCompletedAt");
});
});
describe("POST /api/onboarding/step — guard rails", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await POST(req({ step: 1 }));
expect(res.status).toBe(401);
});
it("returns 422 for an out-of-range step value", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await POST(req({ step: 99 }));
expect(res.status).toBe(422);
});
it("returns 409 when the user has already completed onboarding", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
onboardingStep: 4,
onboardingCompletedAt: new Date("2026-05-01T12:00:00Z"),
} as never);
const res = await POST(req({ step: 1 }));
expect(res.status).toBe(409);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
it("returns 409 when the requested step is out of order", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
onboardingStep: 1,
onboardingCompletedAt: null,
} as never);
// The route sees current = 1; submitting step 3 (skipping 2) must
// be rejected before the write.
const res = await POST(req({ step: 3 }));
expect(res.status).toBe(409);
expect(prisma.user.updateMany).not.toHaveBeenCalled();
});
});
describe("POST /api/onboarding/step — concurrent write race", () => {
it("returns 409 when the conditional update affects zero rows", async () => {
// Regression for Fix-M / code-M7: two parallel tabs both read
// `onboardingStep = 2`, both POST `step: 3`. The first lands;
// when the second runs the conditional updateMany, the WHERE
// clause no longer matches (the column moved to 3) and Prisma
// returns `count = 0`. The route must surface 409 so the client
// can refresh and retry instead of double-advancing the wizard.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
onboardingStep: 2,
onboardingCompletedAt: null,
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
const res = await POST(req({ step: 3 }));
expect(res.status).toBe(409);
// findUniqueOrThrow must NOT be called when the claim fails —
// the route returns before re-reading the row.
expect(prisma.user.findUniqueOrThrow).not.toHaveBeenCalled();
});
it("includes the precondition in the updateMany WHERE clause", async () => {
// The whole point of the conditional update is that the WHERE
// clause re-asserts the precondition we read. Pin the contract
// so a future refactor can't quietly drop the guard.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
onboardingStep: 2,
onboardingCompletedAt: null,
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
onboardingStep: 3,
onboardingCompletedAt: null,
} as never);
await POST(req({ step: 3 }));
const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0][0]
.where as Record<string, unknown>;
expect(whereArg).toMatchObject({
id: "user-1",
onboardingCompletedAt: null,
});
// Strict-equality precondition — Migration 0060 dropped the
// legacy nullable path so every advance uses `{ in: [current] }`.
expect(whereArg.onboardingStep).toEqual({ in: [2] });
});
it("advances a step-0 row to step 1 using the strict-equality precondition", async () => {
// Migration 0060 (v1.4.25 W21 Fix-O) backfilled NULL → 0 and
// flipped `onboarding_step` to NOT NULL, so the conditional
// update always uses the strict-equality form — there is no
// legacy null branch left for the route to widen.
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
onboardingStep: 0,
onboardingCompletedAt: null,
} as never);
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
onboardingStep: 1,
onboardingCompletedAt: null,
} as never);
const res = await POST(req({ step: 1 }));
expect(res.status).toBe(200);
const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0][0]
.where as Record<string, unknown>;
expect(whereArg.onboardingStep).toEqual({ in: [0] });
});
});
describe("POST /api/onboarding/step — rate limit", () => {
it("returns 429 when the per-user rate limit is exceeded", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await POST(req({ step: 1 }));
expect(res.status).toBe(429);
});
});
+178
View File
@@ -0,0 +1,178 @@
import type { NextRequest } from "next/server";
import { z } from "zod/v4";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { auditLog } from "@/lib/auth/audit";
import { setOnboardingPendingCookie } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
/**
* v1.4.25 W14b — POST /api/onboarding/step.
*
* Persists the user's progress through the rebuilt
* `/onboarding/[step]` wizard. Body: `{ step: number }` where `step`
* is 1..4. The endpoint enforces a strict step-by-step contract:
*
* - Submitted step must equal `current + 1` (no skipping ahead).
* - Submitting step 4 marks completion — `onboardingCompletedAt`
* flips from null to NOW() in the same write and the
* `hl_onboarding` proxy cookie is cleared.
* - Already-completed users (`onboardingCompletedAt != null`)
* receive a 409. Replays must call this endpoint only while the
* wizard is in-progress.
*
* Companion to:
* - `GET ` not implemented (the User row is already loaded into
* every server-rendered step page).
* - `POST /api/onboarding/complete` remains the legacy v1.4.20
* completion path used by the old single-file wizard; the new
* flow uses this endpoint with `step: 4` instead.
*
* Rate limit: 30 writes / 10 min / user — generous for a 4-step flow
* but tight enough to defang a stuck retry loop.
*/
const stepBodySchema = z.object({
step: z.number().int().min(1).max(4),
});
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "onboarding.step" } });
const rl = await checkRateLimit(
`onboarding-step:${user.id}`,
30,
10 * 60 * 1000,
);
if (!rl.allowed) {
annotate({
action: { name: "onboarding.step" },
meta: { outcome: "rate_limited" },
});
return apiError("Too many onboarding writes, try again later", 429);
}
const { data: body, error: jsonError } = await safeJson<unknown>(request);
if (jsonError) return jsonError;
const parsed = stepBodySchema.safeParse(body);
if (!parsed.success) {
annotate({
action: { name: "onboarding.step" },
meta: { outcome: "validation_failed" },
});
return apiError("Invalid step payload", 422);
}
const { step } = parsed.data;
// Re-fetch the fresh User row so concurrent step submissions in
// separate tabs can't race past each other — the session.user
// snapshot might be a request-old.
const fresh = await prisma.user.findUnique({
where: { id: user.id },
select: {
onboardingStep: true,
onboardingCompletedAt: true,
},
});
if (!fresh) {
return apiError("User not found", 404);
}
if (fresh.onboardingCompletedAt) {
annotate({
action: { name: "onboarding.step" },
meta: { outcome: "already_completed" },
});
return apiError("Onboarding already completed", 409);
}
const current = fresh.onboardingStep;
if (step !== current + 1) {
annotate({
action: { name: "onboarding.step" },
meta: { outcome: "out_of_order", current, requested: step },
});
return apiError(
`Step out of order — current step is ${current}`,
409,
);
}
const completing = step === 4;
// Conditional update — the WHERE clause re-asserts the
// precondition we read above so the write can only land on a row
// whose state still matches what we validated. Two parallel tabs
// both reading `onboardingStep = 2` and both POSTing `step: 3`
// would otherwise both succeed (the read-then-write race); with
// this guard exactly one update sees `count = 1`, the second sees
// `count = 0` and returns 409. `onboardingCompletedAt: null` also
// catches a race with `POST /api/onboarding/complete`. Migration
// 0060 (v1.4.25 W21) backfilled NULL → 0 and flipped the column
// to NOT NULL, so the legacy null branch this code carried before
// is dropped — every row now matches the strict-equality form.
const claimed = await prisma.user.updateMany({
where: {
id: user.id,
onboardingCompletedAt: null,
onboardingStep: { in: [current] },
},
data: {
onboardingStep: step,
...(completing ? { onboardingCompletedAt: new Date() } : {}),
},
});
if (claimed.count !== 1) {
annotate({
action: { name: "onboarding.step" },
meta: { outcome: "concurrent_write", current, requested: step },
});
return apiError(
"Onboarding step changed concurrently, refresh and retry",
409,
);
}
const updated = await prisma.user.findUniqueOrThrow({
where: { id: user.id },
select: {
onboardingStep: true,
onboardingCompletedAt: true,
},
});
if (completing) {
// Mirror /api/onboarding/complete — clear the proxy-readable
// pending cookie so the next navigation drops the /onboarding
// redirect immediately.
await setOnboardingPendingCookie(false);
}
await auditLog("onboarding.step", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
step,
completed: completing,
},
});
annotate({
action: { name: "onboarding.step" },
meta: { outcome: completing ? "completed" : "advanced", step },
});
return apiSuccess({
step: updated.onboardingStep,
onboardingCompletedAt: updated.onboardingCompletedAt,
});
});
@@ -0,0 +1,161 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
personalRecord: {
findMany: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function req(url: string): NextRequest {
return new NextRequest(url, { method: "GET" });
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.personalRecord.findMany).mockResolvedValue([]);
});
describe("GET /api/personal-records", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(req("http://localhost/api/personal-records"));
expect(res.status).toBe(401);
});
it("returns an empty list for a user with no records", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(req("http://localhost/api/personal-records"));
expect(res.status).toBe(200);
const body = (await res.json()) as { data: unknown[]; error: null };
expect(body.data).toEqual([]);
expect(body.error).toBeNull();
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
orderBy: { achievedAt: "desc" },
take: 100,
});
});
it("passes through a valid metricType filter", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(
req("http://localhost/api/personal-records?metricType=VO2_MAX"),
);
expect(res.status).toBe(200);
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1", metricType: "VO2_MAX" },
orderBy: { achievedAt: "desc" },
take: 100,
});
});
it("drops an unknown metricType silently (loose-typed filter contract)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(
req("http://localhost/api/personal-records?metricType=BOGUS"),
);
expect(res.status).toBe(200);
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
orderBy: { achievedAt: "desc" },
take: 100,
});
});
it("honours an explicit ?limit param", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(req("http://localhost/api/personal-records?limit=25"));
expect(res.status).toBe(200);
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
orderBy: { achievedAt: "desc" },
take: 25,
});
});
it("clamps ?limit to the project-wide ceiling of 500", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(
req("http://localhost/api/personal-records?limit=999999"),
);
expect(res.status).toBe(200);
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
orderBy: { achievedAt: "desc" },
take: 500,
});
});
it("falls back to default on garbage ?limit (defence-in-depth)", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(req("http://localhost/api/personal-records?limit=abc"));
expect(res.status).toBe(200);
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
orderBy: { achievedAt: "desc" },
take: 100,
});
});
it("seeded record round-trips through the response", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const fakeRecord = {
id: "pr-1",
userId: "user-1",
metricType: "VO2_MAX",
metricSlot: null,
direction: "MAX",
value: 47.2,
unit: "mL/(kg·min)",
achievedAt: new Date("2026-05-01T07:00:00.000Z"),
sourceMeasurementId: "m-1",
source: "APPLE_HEALTH",
externalId: null,
createdAt: new Date(),
};
vi.mocked(prisma.personalRecord.findMany).mockResolvedValue([
fakeRecord,
] as never);
const res = await GET(req("http://localhost/api/personal-records"));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: Array<{ value: number; metricType: string }>;
};
expect(body.data).toHaveLength(1);
expect(body.data[0].value).toBe(47.2);
expect(body.data[0].metricType).toBe("VO2_MAX");
});
});
+75
View File
@@ -0,0 +1,75 @@
/**
* GET /api/personal-records
*
* v1.4.25 W8d — schema-only release of the PersonalRecord feature.
* The detection worker that actually populates rows lands in a later
* release (v1.4.26 or v1.5 — TBD). This route exists today so the
* v1.5 iOS-Swift app can build its query path against a stable
* contract from day one.
*
* Query params:
* - metricType: optional MeasurementType filter (e.g. ?metricType=VO2_MAX)
* - limit: optional pagination cap (default 100, max 500)
*
* Response envelope (matches the project-wide `apiSuccess` contract):
* { data: PersonalRecord[], error: null }
*/
import { prisma } from "@/lib/db";
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { measurementTypeEnum } from "@/lib/validations/measurement";
import type { MeasurementType } from "@/generated/prisma/client";
export const dynamic = "force-dynamic";
// v1.4.25 W10 reconcile (Sr-M4) — pagination bounds. Matches the
// `listMeasurementsSchema` ceiling (max 500) so every ingest-and-read
// endpoint on the route surface shares the same upper bound. Today
// the worker hasn't populated any rows so even an uncapped findMany is
// harmless, but a power user with multi-year Apple Health history could
// accumulate 50+ PRs per metric × 14 PR-trackable metrics once the
// worker lands. Add the cap now so the v1.5 iOS app's query path is
// bounded from day one.
const DEFAULT_LIMIT = 100;
const MAX_LIMIT = 500;
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "personalRecords.list" } });
const { searchParams } = new URL(request.url);
const metricTypeParam = searchParams.get("metricType");
const limitParam = searchParams.get("limit");
// Defensive parse — drop unknown values rather than 400 so the
// caller's loosely-typed filter doesn't take the page down.
const metricType: MeasurementType | null =
metricTypeParam && measurementTypeEnum.safeParse(metricTypeParam).success
? (metricTypeParam as MeasurementType)
: null;
// Limit clamp: parse → clamp [1, MAX_LIMIT] → default fallback. A
// garbage value (`?limit=abc`, `?limit=-1`, `?limit=999999`) silently
// clamps to the default rather than 400-ing — same defence-in-depth
// stance the metricType parse takes.
let limit = DEFAULT_LIMIT;
if (limitParam != null) {
const parsed = Number(limitParam);
if (Number.isInteger(parsed) && parsed > 0) {
limit = Math.min(parsed, MAX_LIMIT);
}
}
const records = await prisma.personalRecord.findMany({
where: {
userId: user.id,
...(metricType ? { metricType } : {}),
},
orderBy: { achievedAt: "desc" },
take: limit,
});
return apiSuccess(records);
});
+10 -2
View File
@@ -3,7 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { encrypt } from "@/lib/crypto";
import { exchangeCode } from "@/lib/withings/client";
import { exchangeCode, WITHINGS_OAUTH_SCOPE } from "@/lib/withings/client";
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
import { setupWebhook } from "@/lib/withings/sync";
import { markReconnected } from "@/lib/integrations/status";
@@ -72,7 +72,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
// Upsert connection (user may be reconnecting)
// v1.4.25 W5d — persist the OAuth scope we requested. The token
// response doesn't echo the granted scopes back as a top-level
// field, but Withings honours the scope param verbatim — there's
// no scope downgrade unless the user explicitly revokes one in
// their Health Mate account, which would fail the token exchange
// entirely. Storing the requested set is therefore a safe
// representation of what the connection holds.
await prisma.withingsConnection.upsert({
where: { userId: user.id },
update: {
@@ -80,6 +86,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
tokenExpiresAt: expiresAt,
scope: WITHINGS_OAUTH_SCOPE,
},
create: {
userId: user.id,
@@ -87,6 +94,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
accessToken: encrypt(tokens.access_token),
refreshToken: encrypt(tokens.refresh_token),
tokenExpiresAt: expiresAt,
scope: WITHINGS_OAUTH_SCOPE,
},
});
+15 -4
View File
@@ -5,7 +5,10 @@ import { apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { decrypt } from "@/lib/crypto";
import { unsubscribeWebhook } from "@/lib/withings/client";
import { getWithingsWebhookCallbackUrl } from "@/lib/withings/sync";
import {
WITHINGS_NOTIFY_APPLIS,
getWithingsWebhookCallbackUrl,
} from "@/lib/withings/sync";
import { markDisconnected } from "@/lib/integrations/status";
/**
@@ -23,13 +26,21 @@ export const POST = apiHandler(async () => {
return apiError("No Withings connection", 404);
}
// Try to unsubscribe webhook (best-effort)
// Best-effort unsubscribe across every appli category we subscribed
// to in setupWebhook. A token-expired failure on one category should
// not stop us from cleaning up the others.
try {
const accessToken = decrypt(connection.accessToken);
const callbackUrl = getWithingsWebhookCallbackUrl();
await unsubscribeWebhook(accessToken, callbackUrl);
for (const appli of WITHINGS_NOTIFY_APPLIS) {
try {
await unsubscribeWebhook(accessToken, callbackUrl, appli);
} catch {
// Ignore — token might be expired or the appli was never subscribed.
}
}
} catch {
// Ignore — token might be expired
// Ignore — token might be undecryptable.
}
await prisma.withingsConnection.delete({
+9 -1
View File
@@ -4,7 +4,7 @@ import { annotate, getEvent } from "@/lib/logging/context";
import { apiSuccess } from "@/lib/api-response";
import { decrypt, encrypt } from "@/lib/crypto";
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
import { refreshAccessToken } from "@/lib/withings/client";
import { hasActivityScope, refreshAccessToken } from "@/lib/withings/client";
import { isWithingsRefreshReauthFailure } from "@/lib/withings/sync";
import { markReauthRequired } from "@/lib/integrations/status";
@@ -37,6 +37,7 @@ export const GET = apiHandler(async () => {
lastSyncedAt: true,
tokenExpiresAt: true,
createdAt: true,
scope: true,
},
});
@@ -95,5 +96,12 @@ export const GET = apiHandler(async () => {
tokenExpired,
tokenRefreshFailed,
tokenExpiresAt,
// v1.4.25 W5d — `scope` is the comma-separated OAuth scope string
// returned during the auth flow; `hasActivityScope` is the
// pre-computed convenience flag the Settings reconnect banner
// reads. Null `scope` means a legacy v1.4.24 connection that's
// never re-authed since v1.4.25 — it counts as "missing activity".
scope: connection.scope,
hasActivityScope: hasActivityScope(connection.scope),
});
});
@@ -0,0 +1,214 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { NextRequest } from "next/server";
// --- Module-boundary mocks must come before importing the route. ---
vi.mock("@/lib/api-handler", () => ({
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
}));
vi.mock("@/lib/db", () => ({
prisma: {
withingsConnection: { findFirst: vi.fn() },
},
}));
vi.mock("@/lib/withings/sync", () => ({
syncUserMeasurements: vi.fn(),
}));
// v1.4.25 W17b/c — activity / sleep paths dispatch through pg-boss.
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: vi.fn(),
}));
vi.mock("@/lib/withings/sync-activity", () => ({
syncUserActivity: vi.fn(),
}));
vi.mock("@/lib/withings/sync-sleep", () => ({
syncUserSleep: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
getEvent: vi.fn(() => ({
setAuth: vi.fn(),
addWarning: vi.fn(),
})),
}));
import { POST, GET, HEAD } from "../route";
import { prisma } from "@/lib/db";
import { syncUserMeasurements } from "@/lib/withings/sync";
import { checkRateLimit } from "@/lib/rate-limit";
import { getEvent } from "@/lib/logging/context";
const ORIGINAL_SECRET = process.env.WITHINGS_WEBHOOK_SECRET;
beforeEach(() => {
vi.resetAllMocks();
process.env.WITHINGS_WEBHOOK_SECRET = "test-secret";
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 30,
resetAt: Date.now() + 60_000,
} as never);
vi.mocked(getEvent).mockReturnValue({
setAuth: vi.fn(),
addWarning: vi.fn(),
} as never);
vi.mocked(syncUserMeasurements).mockResolvedValue(undefined as never);
});
afterEach(() => {
if (ORIGINAL_SECRET === undefined) delete process.env.WITHINGS_WEBHOOK_SECRET;
else process.env.WITHINGS_WEBHOOK_SECRET = ORIGINAL_SECRET;
});
function jsonRequest(token: string, body: unknown): NextRequest {
return new NextRequest(
`http://localhost/api/withings/webhook/${encodeURIComponent(token)}`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
},
);
}
function paramsFor(token: string) {
return { params: Promise.resolve({ token }) };
}
describe("POST /api/withings/webhook/[token] (path-segment secret)", () => {
it("returns 200 + triggers sync when the path token matches WITHINGS_WEBHOOK_SECRET", async () => {
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
userId: "user-42",
withingsUserId: "wu-1",
} as never);
const res = await POST(
jsonRequest("test-secret", { userid: "wu-1" }),
paramsFor("test-secret"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as { status: string };
expect(body.status).toBe("ok");
expect(prisma.withingsConnection.findFirst).toHaveBeenCalledWith({
where: { withingsUserId: "wu-1" },
});
expect(syncUserMeasurements).toHaveBeenCalledTimes(1);
expect(syncUserMeasurements).toHaveBeenCalledWith("user-42");
});
it("returns 401 when the path token does NOT match the configured secret", async () => {
const res = await POST(
jsonRequest("WRONG", { userid: "wu-1" }),
paramsFor("WRONG"),
);
expect(res.status).toBe(401);
expect(syncUserMeasurements).not.toHaveBeenCalled();
expect(prisma.withingsConnection.findFirst).not.toHaveBeenCalled();
});
it("returns 401 when the path token is the empty string (missing)", async () => {
// Next.js never routes /api/withings/webhook/ to this handler — that
// path lands on the legacy route. The empty-string case here covers
// direct invocations and adversarial proxies that strip the segment.
const res = await POST(
new NextRequest("http://localhost/api/withings/webhook/", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ userid: "wu-1" }),
}),
paramsFor(""),
);
expect(res.status).toBe(401);
expect(syncUserMeasurements).not.toHaveBeenCalled();
});
it("returns 401 when WITHINGS_WEBHOOK_SECRET is unset, even if the URL carries a token", async () => {
delete process.env.WITHINGS_WEBHOOK_SECRET;
const addWarning = vi.fn();
vi.mocked(getEvent).mockReturnValue({
setAuth: vi.fn(),
addWarning,
} as never);
const res = await POST(
jsonRequest("anything", { userid: "wu-1" }),
paramsFor("anything"),
);
expect(res.status).toBe(401);
expect(addWarning).toHaveBeenCalledWith(
"WITHINGS_WEBHOOK_SECRET not configured",
);
});
it("constant-time compare rejects a token of the wrong length without short-circuiting on prefix match", async () => {
// "test-secret" is the configured value; "test-secret-extra" shares
// the full prefix. A non-timing-safe compare would still return false
// here, so the assertion is on the response only — the security
// property is exercised by the underlying timingSafeEqual call shape.
const res = await POST(
jsonRequest("test-secret-extra", { userid: "wu-1" }),
paramsFor("test-secret-extra"),
);
expect(res.status).toBe(401);
});
it("returns 429 when rate-limited, before the auth check", async () => {
vi.mocked(checkRateLimit).mockResolvedValueOnce({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
} as never);
const res = await POST(
jsonRequest("WRONG", { userid: "wu-1" }),
paramsFor("WRONG"),
);
expect(res.status).toBe(429);
expect(syncUserMeasurements).not.toHaveBeenCalled();
});
});
describe("HEAD /api/withings/webhook/[token]", () => {
it("returns 200 for a valid token, 401 otherwise", async () => {
const okReq = new NextRequest(
"http://localhost/api/withings/webhook/test-secret",
{ method: "HEAD" },
);
const ok = await HEAD(okReq, paramsFor("test-secret"));
expect(ok.status).toBe(200);
const badReq = new NextRequest(
"http://localhost/api/withings/webhook/WRONG",
{ method: "HEAD" },
);
const bad = await HEAD(badReq, paramsFor("WRONG"));
expect(bad.status).toBe(401);
});
});
describe("GET /api/withings/webhook/[token]", () => {
it("returns 200 + ok body for a valid token, 401 otherwise", async () => {
const okReq = new NextRequest(
"http://localhost/api/withings/webhook/test-secret",
);
const ok = await GET(okReq, paramsFor("test-secret"));
expect(ok.status).toBe(200);
const badReq = new NextRequest(
"http://localhost/api/withings/webhook/WRONG",
);
const bad = await GET(badReq, paramsFor("WRONG"));
expect(bad.status).toBe(401);
});
});
@@ -0,0 +1,83 @@
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import {
applyWebhookRateLimit,
processWithingsNotification,
timingSafeStringEqual,
} from "@/lib/withings/webhook-handler";
/**
* Withings webhook entrypoint (v1.4.25 W17a — path-segment secret).
*
* The shared secret travels in the URL path (`/api/withings/webhook/<secret>`)
* rather than the query string. Withings has no facility for adding HTTP
* headers or signing webhook bodies, so the only authenticity surface a
* subscriber controls is the callback URL itself; moving the secret out of
* the query string keeps it out of the `query_string` column most reverse
* proxies log by default (Caddy, nginx, Coolify) and out of GlitchTip's
* URL/breadcrumb capture path which already strips `?…` aggressively.
*
* The legacy `?secret=` form at `/api/withings/webhook` stays alive for one
* release cycle so existing Withings subscriptions keep delivering while
* users re-subscribe.
*/
interface RouteContext {
params: Promise<{ token: string }>;
}
async function verifyTokenSegment(
token: string | undefined,
): Promise<boolean> {
const expected = process.env.WITHINGS_WEBHOOK_SECRET;
if (!expected) {
getEvent()?.addWarning("WITHINGS_WEBHOOK_SECRET not configured");
return false;
}
if (!token) return false;
return timingSafeStringEqual(expected, token);
}
export const POST = apiHandler(
async (request: NextRequest, context: RouteContext) => {
annotate({ action: { name: "withings.webhook" } });
const limited = await applyWebhookRateLimit(request);
if (limited) return limited;
const { token } = await context.params;
if (!(await verifyTokenSegment(token))) {
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
}
return processWithingsNotification(request);
},
);
/**
* Withings sends HEAD/GET to verify the URL when a subscription is
* created.
*/
export const HEAD = apiHandler(
async (_request: NextRequest, context: RouteContext) => {
annotate({ action: { name: "withings.webhook.verify" } });
const { token } = await context.params;
if (!(await verifyTokenSegment(token))) {
return new NextResponse(null, { status: 401 });
}
getEvent()?.setAuth({ auth_method: "webhook_secret" });
return new NextResponse(null, { status: 200 });
},
);
export const GET = apiHandler(
async (_request: NextRequest, context: RouteContext) => {
annotate({ action: { name: "withings.webhook.verify" } });
const { token } = await context.params;
if (!(await verifyTokenSegment(token))) {
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
}
getEvent()?.setAuth({ auth_method: "webhook_secret" });
return NextResponse.json({ status: "ok" }, { status: 200 });
},
);
@@ -17,6 +17,21 @@ vi.mock("@/lib/withings/sync", () => ({
syncUserMeasurements: vi.fn(),
}));
// v1.4.25 W17b/c — activity / sleep paths dispatch through pg-boss.
// Mock the boss-instance accessor to return a fake boss for the
// appli=16 / 44 tests and `null` otherwise.
vi.mock("@/lib/jobs/boss-instance", () => ({
getGlobalBoss: vi.fn(),
}));
vi.mock("@/lib/withings/sync-activity", () => ({
syncUserActivity: vi.fn(),
}));
vi.mock("@/lib/withings/sync-sleep", () => ({
syncUserSleep: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
rateLimitHeaders: vi.fn(() => ({})),
@@ -30,11 +45,20 @@ vi.mock("@/lib/logging/context", () => ({
})),
}));
import { POST, GET, HEAD } from "../route";
import {
POST,
GET,
HEAD,
getLegacyFormTotal,
__resetLegacyFormTotalForTests,
} from "../route";
import { prisma } from "@/lib/db";
import { syncUserMeasurements } from "@/lib/withings/sync";
import { syncUserActivity } from "@/lib/withings/sync-activity";
import { syncUserSleep } from "@/lib/withings/sync-sleep";
import { checkRateLimit } from "@/lib/rate-limit";
import { getEvent } from "@/lib/logging/context";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
const ORIGINAL_SECRET = process.env.WITHINGS_WEBHOOK_SECRET;
@@ -51,6 +75,11 @@ beforeEach(() => {
addWarning: vi.fn(),
} as never);
vi.mocked(syncUserMeasurements).mockResolvedValue(undefined as never);
// Default: pg-boss not available → activity / sleep paths fall back
// to inline sync. Individual tests override with a fake boss.
vi.mocked(getGlobalBoss).mockReturnValue(null);
vi.mocked(syncUserActivity).mockResolvedValue(0 as never);
vi.mocked(syncUserSleep).mockResolvedValue(0 as never);
});
afterEach(() => {
@@ -144,7 +173,8 @@ describe("POST /api/withings/webhook", () => {
expect(syncUserMeasurements).toHaveBeenCalledWith("user-42");
});
it("legacy ?secret=… query path still authorises and emits a Wide Event warning", async () => {
it("legacy ?secret=… query path still authorises and emits a Wide Event warning with the migration URL (Fix-K sec-M2)", async () => {
__resetLegacyFormTotalForTests();
const addWarning = vi.fn();
vi.mocked(getEvent).mockReturnValue({
setAuth: vi.fn(),
@@ -163,6 +193,30 @@ describe("POST /api/withings/webhook", () => {
expect(addWarning).toHaveBeenCalledWith(
expect.stringMatching(/legacy URL query/i),
);
// Fix-K sec-M2: warning text now spells out the migration URL so
// anyone watching the access log knows what to re-subscribe to.
expect(addWarning).toHaveBeenCalledWith(
expect.stringContaining("/api/withings/webhook/[token]"),
);
// Fix-K sec-M2: in-memory counter increments on every legacy
// ?secret=… call so the release-gate can watch usage trend to zero.
expect(getLegacyFormTotal()).toBe(1);
});
it("counter stays at zero when the secret arrives via the header (Fix-K sec-M2)", async () => {
__resetLegacyFormTotalForTests();
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
userId: "user-h",
withingsUserId: "wu-h",
} as never);
await POST(
jsonRequest(
{ userid: "wu-h" },
{ "x-withings-webhook-secret": "test-secret" },
),
);
expect(getLegacyFormTotal()).toBe(0);
});
it("accepts form-encoded payloads (Withings legacy delivery format)", async () => {
@@ -224,6 +278,120 @@ describe("POST /api/withings/webhook", () => {
});
});
describe("POST /api/withings/webhook — appli dispatch (W17b/c)", () => {
it("appli=16 enqueues onto withings-activity-sync (not the measure path)", async () => {
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
userId: "user-act",
withingsUserId: "wu-act",
} as never);
const send = vi.fn().mockResolvedValue(undefined);
vi.mocked(getGlobalBoss).mockReturnValue({ send } as never);
const res = await POST(
formRequest(
{ userid: "wu-act", appli: "16" },
{ "x-withings-webhook-secret": "test-secret" },
),
);
expect(res.status).toBe(200);
expect(send).toHaveBeenCalledTimes(1);
expect(send.mock.calls[0][0]).toBe("withings-activity-sync");
expect(send.mock.calls[0][1]).toMatchObject({ userId: "user-act" });
// Critical: appli=16 must NOT trigger the measure path.
expect(syncUserMeasurements).not.toHaveBeenCalled();
});
it("appli=44 enqueues onto withings-sleep-sync (not the measure path)", async () => {
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
userId: "user-sleep",
withingsUserId: "wu-sleep",
} as never);
const send = vi.fn().mockResolvedValue(undefined);
vi.mocked(getGlobalBoss).mockReturnValue({ send } as never);
const res = await POST(
formRequest(
{ userid: "wu-sleep", appli: "44" },
{ "x-withings-webhook-secret": "test-secret" },
),
);
expect(res.status).toBe(200);
expect(send).toHaveBeenCalledTimes(1);
expect(send.mock.calls[0][0]).toBe("withings-sleep-sync");
expect(send.mock.calls[0][1]).toMatchObject({ userId: "user-sleep" });
expect(syncUserMeasurements).not.toHaveBeenCalled();
});
it("appli=16 falls back to inline activity sync when pg-boss is unavailable", async () => {
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
userId: "user-act-inline",
withingsUserId: "wu-act-inline",
} as never);
// Default beforeEach already sets `getGlobalBoss → null`.
const res = await POST(
formRequest(
{ userid: "wu-act-inline", appli: "16" },
{ "x-withings-webhook-secret": "test-secret" },
),
);
expect(res.status).toBe(200);
expect(syncUserActivity).toHaveBeenCalledWith("user-act-inline");
expect(syncUserMeasurements).not.toHaveBeenCalled();
});
it("appli=44 falls back to inline sleep sync when pg-boss is unavailable", async () => {
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
userId: "user-sleep-inline",
withingsUserId: "wu-sleep-inline",
} as never);
const res = await POST(
formRequest(
{ userid: "wu-sleep-inline", appli: "44" },
{ "x-withings-webhook-secret": "test-secret" },
),
);
expect(res.status).toBe(200);
expect(syncUserSleep).toHaveBeenCalledWith("user-sleep-inline");
expect(syncUserMeasurements).not.toHaveBeenCalled();
});
it("appli=4 (BP) keeps the legacy measure path", async () => {
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
userId: "user-bp",
withingsUserId: "wu-bp",
} as never);
const res = await POST(
formRequest(
{ userid: "wu-bp", appli: "4" },
{ "x-withings-webhook-secret": "test-secret" },
),
);
expect(res.status).toBe(200);
expect(syncUserMeasurements).toHaveBeenCalledWith("user-bp");
expect(syncUserActivity).not.toHaveBeenCalled();
expect(syncUserSleep).not.toHaveBeenCalled();
});
it("a missing appli falls back to the measure path (legacy Withings subscriptions)", async () => {
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
userId: "user-legacy",
withingsUserId: "wu-legacy",
} as never);
const res = await POST(
formRequest(
{ userid: "wu-legacy" },
{ "x-withings-webhook-secret": "test-secret" },
),
);
expect(res.status).toBe(200);
expect(syncUserMeasurements).toHaveBeenCalledWith("user-legacy");
});
});
describe("HEAD /api/withings/webhook (Withings URL verification)", () => {
it("returns 200 with a valid header secret", async () => {
const req = new NextRequest("http://localhost/api/withings/webhook", {
+90 -83
View File
@@ -1,110 +1,117 @@
import { timingSafeEqual } from "node:crypto";
import { prisma } from "@/lib/db";
import { syncUserMeasurements } from "@/lib/withings/sync";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { getClientIp, safeJson } from "@/lib/api-response";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate, getEvent } from "@/lib/logging/context";
import {
applyWebhookRateLimit,
processWithingsNotification,
timingSafeStringEqual,
} from "@/lib/withings/webhook-handler";
/**
* Audit C-3 / phase P2: Withings legacy callback URLs include the shared
* secret as a `?secret=…` query param, which leaks into reverse-proxy
* access logs and any error-tracking pipeline that captures `request.url`
* (see `reportToGlitchtip`). New deployments should configure Withings to
* send the secret via the `X-Withings-Webhook-Secret` header instead.
* Legacy Withings webhook entrypoint.
*
* The query-param fallback stays for backwards compatibility with existing
* Withings configurations; remove once all integrators have migrated.
* Pre-v1.4.25 callback URLs accepted the shared secret in two places:
*
* 1. `?secret=…` — the original form, which Withings preserves on
* every notification and which therefore lands in
* reverse-proxy access logs (the `query_string`
* column) and any error-tracking pipeline that
* captures `request.url`.
* 2. `X-Withings-Webhook-Secret` header — Withings does NOT support
* setting custom headers on notifications, so this
* path only fires for manual replay tests.
*
* v1.4.25 W17a moved the production path to
* `/api/withings/webhook/[token]`, where the secret travels as a path
* segment instead of a query parameter. This route stays alive for
* one release cycle so existing Withings subscriptions keep delivering
* while users re-subscribe (which also happens organically when they
* reconnect for the new `user.activity` OAuth scope shipped in W5d).
*
* Removal target: v1.4.27 (after all live subscriptions have rotated).
*
* v1.4.25 W21 Fix-K — added an in-memory `withings.webhook.legacy_form_total`
* counter the `/api/admin/status` endpoint surfaces so the release-gate
* can read "legacy form usage trending toward zero" before the v1.4.27
* cut. The warning text now includes the re-subscription URL so anyone
* watching the access log knows what to migrate to.
*/
function hasValidWebhookSecret(request: NextRequest): boolean {
const MIGRATION_URL = "/api/withings/webhook/[token]" as const;
// In-memory counter — resets on every deploy / cold-start. That is the
// intended granularity: we want to know "how many legacy-form requests
// did this process serve since it came up", not a forever-running
// cumulative metric (which would belong in Prisma).
const counters = {
legacy_form_total: 0,
};
/**
* Read the legacy-form counter. Exported so `/api/admin/status` can
* surface it without a backchannel.
*/
export function getLegacyFormTotal(): number {
return counters.legacy_form_total;
}
/**
* Reset the legacy-form counter. Test-only utility; production code
* does not call this. Documented so a future smoke test can wire up
* a deterministic "counter was 0 before the call" assertion.
*/
export function __resetLegacyFormTotalForTests(): void {
counters.legacy_form_total = 0;
}
function hasValidWebhookSecret(
request: NextRequest,
): Promise<{ ok: boolean; via: "header" | "query" | "none" }> {
const expected = process.env.WITHINGS_WEBHOOK_SECRET;
if (!expected) {
getEvent()?.addWarning("WITHINGS_WEBHOOK_SECRET not configured");
return false;
return Promise.resolve({ ok: false, via: "none" });
}
const fromHeader = request.headers.get("x-withings-webhook-secret");
const fromQuery = request.nextUrl.searchParams.get("secret");
const received = fromHeader ?? fromQuery;
if (!received) return false;
if (!received) return Promise.resolve({ ok: false, via: "none" });
if (!fromHeader && fromQuery) {
const via: "header" | "query" = fromHeader ? "header" : "query";
return timingSafeStringEqual(expected, received).then((ok) => ({ ok, via }));
}
async function checkAndWarn(
request: NextRequest,
): Promise<NextResponse | null> {
const auth = await hasValidWebhookSecret(request);
if (!auth.ok) {
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
}
if (auth.via === "query") {
counters.legacy_form_total += 1;
getEvent()?.addWarning(
"withings webhook secret received via legacy URL query — migrate to X-Withings-Webhook-Secret header",
`withings webhook secret received via legacy URL query — migrate to ${MIGRATION_URL} (re-subscribe with the path-segment token form)`,
);
}
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(received, "utf8");
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
return null;
}
/**
* Withings webhook notification endpoint.
* Withings webhook notification endpoint (legacy form).
* Withings sends a POST when new measurements are available.
* The webhook sends: userid, startdate, enddate, appli
* The webhook sends: userid, startdate, enddate, appli.
*/
export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "withings.webhook" } });
annotate({ action: { name: "withings.webhook.legacy" } });
const ip = getClientIp(request);
const rl = await checkRateLimit(`withings-webhook:${ip}`, 30, 60 * 1000);
if (!rl.allowed) {
return NextResponse.json(
{ status: "rate_limited" },
{ status: 429, headers: rateLimitHeaders(rl) },
);
}
const limited = await applyWebhookRateLimit(request);
if (limited) return limited;
if (!hasValidWebhookSecret(request)) {
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
}
const unauth = await checkAndWarn(request);
if (unauth) return unauth;
getEvent()?.setAuth({ auth_method: "webhook_secret" });
// Withings sends form-encoded or JSON depending on version
const contentType = request.headers.get("content-type") ?? "";
let withingsUserId: string | null = null;
if (contentType.includes("application/x-www-form-urlencoded")) {
const formData = await request.formData();
withingsUserId = formData.get("userid") as string;
} else {
const { data: body, error: jsonError } = await safeJson<{
userid?: string | number;
}>(request);
if (jsonError) return jsonError;
withingsUserId = body.userid?.toString() ?? null;
}
if (!withingsUserId) {
return NextResponse.json({ status: "ignored" }, { status: 200 });
}
annotate({ meta: { withings_user_id: withingsUserId } });
// Find user by Withings user ID
const connection = await prisma.withingsConnection.findFirst({
where: { withingsUserId },
});
if (!connection) {
getEvent()?.addWarning(
"Webhook for unknown withings user: " + withingsUserId,
);
return NextResponse.json({ status: "unknown_user" }, { status: 200 });
}
// Sync measurements (non-blocking response for Withings)
syncUserMeasurements(connection.userId).catch((err) => {
getEvent()?.addWarning(
"Sync failed for user " + connection.userId + ": " + err,
);
});
return NextResponse.json({ status: "ok" }, { status: 200 });
return processWithingsNotification(request);
});
/**
@@ -112,8 +119,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
*/
export const HEAD = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "withings.webhook.verify" } });
if (!hasValidWebhookSecret(request)) {
const auth = await hasValidWebhookSecret(request);
if (!auth.ok) {
return new NextResponse(null, { status: 401 });
}
getEvent()?.setAuth({ auth_method: "webhook_secret" });
@@ -122,8 +129,8 @@ export const HEAD = apiHandler(async (request: NextRequest) => {
export const GET = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "withings.webhook.verify" } });
if (!hasValidWebhookSecret(request)) {
const auth = await hasValidWebhookSecret(request);
if (!auth.ok) {
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
}
getEvent()?.setAuth({ auth_method: "webhook_secret" });
@@ -0,0 +1,401 @@
/**
* Unit suite for `POST /api/workouts/batch`.
*
* Database-free coverage of the surface that doesn't require a real
* Postgres — the per-entry status envelope, the rate-limit gate, the
* payload-size ceiling, the Zod-parse short-circuits, and the
* race-reconciliation downgrade pass. Full request-lifecycle assertions
* (idempotency replay, real-DB dedup, concurrent-write race) live in
* the matching `tests/integration/workout-batch-create.test.ts`.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
workout: {
findMany: vi.fn(),
createMany: vi.fn(),
findFirst: vi.fn(),
},
workoutRoute: {
createMany: vi.fn(),
},
$transaction: vi.fn(async (fn: unknown) => {
// Same shape as the real Prisma client — invoke the supplied
// callback with the mocked client itself so `tx.workout.createMany`
// calls land on the same vi.fn instances.
if (typeof fn === "function") {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (fn as any)((prisma as unknown as { workout: unknown }));
}
}),
},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/jobs/pr-detection", () => ({
enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { POST } from "../batch/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "marc", role: "USER" as const },
};
function makeRequest(
body: unknown,
opts: {
contentLength?: number | null;
headers?: Record<string, string>;
} = {},
): NextRequest {
const baseHeaders: Record<string, string> = {
"content-type": "application/json",
...(opts.headers ?? {}),
};
const serialized = JSON.stringify(body);
if (opts.contentLength !== null) {
baseHeaders["content-length"] = String(
opts.contentLength ?? serialized.length,
);
}
return new NextRequest("http://localhost/api/workouts/batch", {
method: "POST",
headers: baseHeaders,
body: serialized,
});
}
function validWorkout(externalId: string, overrides: Record<string, unknown> = {}) {
return {
sportType: "running",
startedAt: "2026-05-14T06:30:00.000Z",
endedAt: "2026-05-14T07:15:00.000Z",
source: "APPLE_HEALTH",
externalId,
...overrides,
};
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 60,
resetAt: Date.now() + 60_000,
});
vi.mocked(prisma.workout.findMany).mockResolvedValue([]);
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 0 });
vi.mocked(prisma.workout.findFirst).mockResolvedValue(null);
vi.mocked(prisma.workoutRoute.createMany).mockResolvedValue({ count: 0 });
});
describe("POST /api/workouts/batch — auth + size + rate-limit", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await POST(makeRequest({ workouts: [validWorkout("a")] }));
expect(res.status).toBe(401);
});
it("returns 413 when Content-Length exceeds the 5 MB ceiling", async () => {
const res = await POST(
makeRequest(
{ workouts: [validWorkout("uuid-1")] },
{ contentLength: 6 * 1024 * 1024 },
),
);
expect(res.status).toBe(413);
const body = (await res.json()) as {
error: string;
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe("workout.batch.payload_too_large");
});
it("returns 429 when the rate-limit gate blocks the call", async () => {
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
});
const res = await POST(makeRequest({ workouts: [validWorkout("uuid-1")] }));
expect(res.status).toBe(429);
// We must NOT have queried Prisma after a rate-limit miss — the
// gate's whole point is to short-circuit before the DB.
expect(prisma.workout.createMany).not.toHaveBeenCalled();
});
});
describe("POST /api/workouts/batch — validation", () => {
it("returns 400 when the workouts array exceeds the per-batch cap", async () => {
const workouts = Array.from({ length: 101 }, (_, i) =>
validWorkout(`uuid-${i}`),
);
const res = await POST(makeRequest({ workouts }));
expect(res.status).toBe(400);
const body = (await res.json()) as {
error: string;
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe("workout.batch.too_large");
});
it("returns 400 when the Zod schema rejects the payload", async () => {
const res = await POST(
makeRequest({
workouts: [
{
// Missing required fields — Zod must reject.
sportType: "running",
},
],
}),
);
expect(res.status).toBe(400);
const body = (await res.json()) as {
error: string;
meta?: { errorCode?: string };
};
expect(body.meta?.errorCode).toBe("workout.batch.invalid");
});
it("returns 400 when a route exceeds the 20 000-point cap", async () => {
// 20 001 points — schema must reject before the handler reaches
// its DB layer. We assert no Prisma query fires.
const coordinates: [number, number][] = [];
for (let i = 0; i < 20_001; i++) {
coordinates.push([11 + i * 1e-7, 49 + i * 1e-7]);
}
const res = await POST(
makeRequest(
{
workouts: [
validWorkout("uuid-route-cap", {
route: { geometry: { type: "LineString", coordinates } },
}),
],
},
// Skip the Content-Length check — 20k coordinates serialised
// is well under 5 MB and the request as built has the real
// header anyway. The test asserts that the SCHEMA cap fires
// even when the byte cap doesn't.
{ contentLength: null },
),
);
expect(res.status).toBe(400);
expect(prisma.workout.createMany).not.toHaveBeenCalled();
});
});
describe("POST /api/workouts/batch — per-entry status envelope", () => {
it("marks every fresh entry as inserted", async () => {
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 2 });
const res = await POST(
makeRequest({
workouts: [
validWorkout("uuid-fresh-1"),
validWorkout("uuid-fresh-2"),
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
processed: number;
inserted: number;
duplicates: number;
entries: Array<{ status: string }>;
};
};
expect(body.data.processed).toBe(2);
expect(body.data.inserted).toBe(2);
expect(body.data.duplicates).toBe(0);
expect(body.data.entries.every((e) => e.status === "inserted")).toBe(true);
});
it("flags entries already in the DB as duplicate without re-inserting", async () => {
vi.mocked(prisma.workout.findMany).mockResolvedValue([
{ source: "APPLE_HEALTH", externalId: "uuid-existing" },
] as never);
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
const res = await POST(
makeRequest({
workouts: [
validWorkout("uuid-existing"),
validWorkout("uuid-fresh"),
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
inserted: number;
duplicates: number;
entries: Array<{ index: number; status: string }>;
};
};
expect(body.data.inserted).toBe(1);
expect(body.data.duplicates).toBe(1);
expect(body.data.entries[0]?.status).toBe("duplicate");
expect(body.data.entries[1]?.status).toBe("inserted");
});
it("downgrades inserted statuses to duplicate when createMany.count < attempted (race)", async () => {
// Pre-flight findMany returns empty (no obvious duplicates), so
// every entry is initially marked "inserted". createMany then
// returns count=1 — the other batch already won the race for one
// row. The race-reconciliation block must downgrade exactly one
// "inserted" status to "duplicate" so the envelope sums match the
// aggregate counts (the v1.4.25 W10 fix-C invariant).
vi.mocked(prisma.workout.findMany).mockResolvedValue([]);
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
const res = await POST(
makeRequest({
workouts: [
validWorkout("uuid-race-1"),
validWorkout("uuid-race-2"),
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
inserted: number;
duplicates: number;
entries: Array<{ status: string }>;
};
};
const insertedEntries = body.data.entries.filter(
(e) => e.status === "inserted",
).length;
const duplicateEntries = body.data.entries.filter(
(e) => e.status === "duplicate",
).length;
expect(insertedEntries).toBe(body.data.inserted);
expect(duplicateEntries).toBe(body.data.duplicates);
expect(body.data.inserted).toBe(1);
expect(body.data.duplicates).toBe(1);
});
});
describe("POST /api/workouts/batch — nested route attachment", () => {
it("creates a WorkoutRoute row for a workout that ships a route", async () => {
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
// Post-insert lookup returns the newly-inserted workout id so the
// route can be attached by FK.
vi.mocked(prisma.workout.findMany).mockImplementation((async (args: {
select?: { id?: true; source?: true; externalId?: true };
}) => {
if (args.select?.id) {
return [
{
id: "wkt-fresh-id",
source: "APPLE_HEALTH",
externalId: "uuid-with-route",
},
];
}
return [];
}) as never);
const res = await POST(
makeRequest({
workouts: [
validWorkout("uuid-with-route", {
route: {
geometry: {
type: "LineString",
coordinates: [
[11.077, 49.452],
[11.078, 49.453],
],
},
},
}),
],
}),
);
expect(res.status).toBe(200);
expect(prisma.workoutRoute.createMany).toHaveBeenCalledTimes(1);
const call = vi.mocked(prisma.workoutRoute.createMany).mock.calls[0]?.[0];
const rows = (call as { data: Array<{ workoutId: string }> }).data;
expect(rows[0]?.workoutId).toBe("wkt-fresh-id");
});
});
describe("POST /api/workouts/batch — PR detection enqueue (v1.4.25 W16c)", () => {
it("enqueues PR detection after a successful batch with silent=false", async () => {
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
const res = await POST(
makeRequest({ workouts: [validWorkout("uuid-small")] }),
);
expect(res.status).toBe(200);
expect(enqueuePrDetection).toHaveBeenCalledTimes(1);
expect(enqueuePrDetection).toHaveBeenCalledWith("user-1", {
silent: false,
});
});
it("propagates silent=true when the batch exceeds the historical threshold", async () => {
const big = Array.from({ length: 51 }, (_, i) =>
validWorkout(`uuid-${i}`),
);
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 51 });
const res = await POST(makeRequest({ workouts: big }));
expect(res.status).toBe(200);
expect(enqueuePrDetection).toHaveBeenCalledWith("user-1", {
silent: true,
});
});
it("still enqueues for an all-duplicate batch (cursor cleanup path)", async () => {
// Pre-flight dedup matches all incoming entries, so insertedCount
// stays 0 but duplicateCount becomes 1. The hook fires anyway —
// the detector is cheap and a duplicate batch can still reveal a
// PR if the row was written by another path between dispatches.
vi.mocked(prisma.workout.findMany).mockResolvedValue([
{ source: "APPLE_HEALTH", externalId: "uuid-dup" } as never,
]);
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 0 });
const res = await POST(
makeRequest({ workouts: [validWorkout("uuid-dup")] }),
);
expect(res.status).toBe(200);
expect(enqueuePrDetection).toHaveBeenCalledTimes(1);
expect(enqueuePrDetection).toHaveBeenCalledWith("user-1", {
silent: false,
});
});
});
+503
View File
@@ -0,0 +1,503 @@
/**
* `POST /api/workouts/batch` — typed workout ingest with nested route.
*
* Consumers (v1.5):
* 1. The native iOS app draining its HealthKit observer queue —
* maps `HKWorkout` + `HKWorkoutRoute` samples into the request
* shape locked by `createBatchWorkoutSchema` in
* `src/lib/validations/workout.ts`.
* 2. The Withings activity sync (W17b, deferred) — server-to-server
* ingest with `source: "WITHINGS"` and `externalId` formatted as
* `"${withings.id}:${withings.model_id ?? "0"}:${withings.startdate}"`.
* Withings ships no route geometry, so the nested `route` is
* always absent for WITHINGS-sourced entries.
*
* Cross-cutting concerns mirror `/api/measurements/batch` so the iOS
* sync engine can re-use the same retry / cursor plumbing:
* - `requireAuth()` — cookie + Bearer (narrow-scope-token safe per
* v1.4.25 W10 fix-C: when the route declares no scope, any
* authenticated token passes).
* - `withIdempotency` — `Idempotency-Key` header replays the cached
* response on retry (24h window).
* - `checkRateLimit("workouts:batch:${user.id}", 60, 60s)` — same
* window as the measurements batch. 60 batches/min × 100
* workouts/batch = 6 000 workouts/min headroom (orders of
* magnitude past any healthy iOS sync).
* - `Content-Length` ceiling at 5 MB before parsing — anything
* larger returns 413 so the iOS client falls back to one workout
* per call.
* - Per-entry status (`inserted | duplicate | skipped`) so the iOS
* sync cursor can checkpoint accurately.
*
* Idempotency contract:
* - HTTP-level via `Idempotency-Key` (replays cached envelope).
* - Per-entry via the `@@unique([userId, source, externalId])`
* composite index. Re-posting the same batch surfaces duplicates
* rather than failing the call.
*
* Race reconciliation (mirrors the measurements batch fix from
* v1.4.25 W10): under contention, `createMany` with `skipDuplicates`
* absorbs duplicate-key conflicts but cannot tell us WHICH rows it
* absorbed. We trust the `createMany.count` return value and downgrade
* enough per-entry "inserted" statuses to "duplicate" so the envelope
* sums stay consistent with the aggregate counts. The iOS sync cursor
* advances past both statuses identically.
*
* Prompt-injection surface: `Workout.metadata` is opaque — the Coach
* pipeline reads typed columns only (`sportType`, `durationSec`,
* distance, HR). A future change that pulls metadata into the Coach
* prompt must add deliberate escaping; the ingest path stores it raw.
*/
import { NextRequest } from "next/server";
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
apiError,
apiSuccess,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
import {
createBatchWorkoutSchema,
MAX_WORKOUTS_PER_BATCH,
} from "@/lib/validations/workout";
import { Prisma } from "@/generated/prisma/client";
// v1.4.25 W16c — push-suppression threshold for workout PRs. A batch
// larger than this fires the detection job with `silent: true` so a
// multi-year HKWorkout backfill doesn't spam the user with hundreds of
// pushes. Matches the measurements-batch threshold so behaviour stays
// uniform across both ingest paths.
const PR_DETECTION_SILENT_THRESHOLD = 50;
// v1.4.25 W10 reconcile (security H-2 parity): cap batch ingest at 60
// batches per user per minute. Healthy iOS sync drains its HealthKit
// observer queue in well under one batch per minute (the observer
// pattern coalesces); 60/min × 100 workouts/batch = 6 000 workouts/
// min headroom — generous for legitimate use, and a hard stop for a
// leaked wildcard token trying to saturate the write pipeline.
const BATCH_RATE_LIMIT_MAX = 60;
const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
// 5 MB request-body ceiling. The route geometry is the largest tail in
// a workout payload; with the 20 000-point cap on a single LineString
// AND the 100-workout cap per batch, the worst-case body is bounded
// well below this ceiling for typical lon/lat encodings. A request
// above this ceiling almost certainly indicates a misbehaving client
// or an attempted DoS — return 413 so the iOS client falls back to
// one-workout-per-call.
const MAX_BODY_BYTES = 5 * 1024 * 1024;
/**
* Per-entry outcome the iOS client uses to advance its sync cursor.
* `inserted` and `duplicate` both indicate the row landed (or was
* already present); the client can checkpoint past them identically.
* `skipped` covers entries the server cannot durably store — currently
* unused by this endpoint (the Zod schema rejects malformed entries
* with a 422 before we reach the per-entry pass), but reserved on the
* envelope so the response shape parallels the measurements batch and
* future server-side validation can surface fine-grained skips without
* breaking the iOS DTO.
*/
type EntryStatus = "inserted" | "duplicate" | "skipped";
interface EntryResult {
index: number;
status: EntryStatus;
reason?: string;
}
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBatch));
async function postBatch(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
// Content-Length pre-flight. The body cap exists to bound the
// request-pipeline cost BEFORE Node has to read the full payload —
// a misbehaving client shipping a 50 MB body shouldn't tie up the
// worker for a parse it'd reject anyway. We trust the header here
// because Next.js / undici populates it for any client that sets a
// body length; for chunked uploads (no Content-Length) we fall
// through and rely on the schema-level point / workout caps to keep
// memory bounded.
const contentLength = request.headers.get("content-length");
if (contentLength !== null) {
const parsedLength = Number.parseInt(contentLength, 10);
if (Number.isFinite(parsedLength) && parsedLength > MAX_BODY_BYTES) {
annotate({
action: { name: "workout.batch.ingest" },
meta: { outcome: "payload_too_large", bytes: parsedLength },
});
return apiError(
`Request body exceeds the ${MAX_BODY_BYTES}-byte limit`,
413,
{ errorCode: "workout.batch.payload_too_large" },
);
}
}
// v1.4.25 W10 reconcile (security H-2 parity): per-user rate limit.
// Without it, a leaked wildcard iOS token can sustain unbounded
// writes (100 workouts/batch × N batches/sec) and degrade the
// database for every other user on the host. 60 batches/min/user is
// generous for healthy iOS sync and tight enough that a misbehaving
// client bottoms out within a minute.
const rl = await checkRateLimit(
`workouts:batch:${user.id}`,
BATCH_RATE_LIMIT_MAX,
BATCH_RATE_LIMIT_WINDOW_MS,
);
if (!rl.allowed) {
annotate({
action: { name: "workout.batch.ingest" },
meta: { outcome: "rate_limited" },
});
return apiError("Too many batch submissions, try again later", 429);
}
const { data: rawBody, error: jsonError } = await safeJson<unknown>(request);
if (jsonError) return jsonError;
// Distinguish "too many entries" from "validation failed" so the
// client surfaces a useful diagnostic. The cap stops a buggy client
// from saturating the request pipeline; the iOS app paginates above
// this threshold.
if (
typeof rawBody === "object" &&
rawBody !== null &&
"workouts" in rawBody &&
Array.isArray((rawBody as { workouts: unknown }).workouts) &&
(rawBody as { workouts: unknown[] }).workouts.length >
MAX_WORKOUTS_PER_BATCH
) {
return apiError(
`Batch exceeds the ${MAX_WORKOUTS_PER_BATCH}-workout limit`,
400,
{ errorCode: "workout.batch.too_large" },
);
}
const parsed = createBatchWorkoutSchema.safeParse(rawBody);
if (!parsed.success) {
const issue = parsed.error.issues[0];
return apiError(issue?.message ?? "Invalid batch", 400, {
errorCode: "workout.batch.invalid",
});
}
const { workouts } = parsed.data;
// Pre-flight duplicate detection so we can return per-entry status.
// The composite unique index is `(userId, source, externalId)` —
// entries without an `externalId` cannot collide on the key (NULL-
// distinct semantics), so they always insert as new rows.
type Prepared = {
index: number;
row: Prisma.WorkoutCreateManyInput;
route: {
geometry: unknown;
sampleTimestamps: unknown;
} | null;
/** Composite-key probe for the pre-flight findMany. NULL when
* the entry has no externalId — those rows always pass through
* as "inserted" because the NULL-distinct unique index can never
* collide them. */
dedupKey: { source: string; externalId: string } | null;
};
const results: EntryResult[] = new Array(workouts.length);
const prepared: Prepared[] = workouts.map((w, index) => {
const durationSec = Math.max(
0,
Math.round((w.endedAt.getTime() - w.startedAt.getTime()) / 1000),
);
const route = w.route
? {
geometry: w.route.geometry as unknown,
sampleTimestamps: (w.route.sampleTimestamps ?? null) as unknown,
}
: null;
const row: Prisma.WorkoutCreateManyInput = {
userId: user.id,
sportType: w.sportType,
startedAt: w.startedAt,
endedAt: w.endedAt,
durationSec,
totalEnergyKcal: w.totalEnergyKcal ?? null,
totalDistanceM: w.totalDistanceM ?? null,
avgHeartRate: w.avgHeartRate ?? null,
maxHeartRate: w.maxHeartRate ?? null,
minHeartRate: w.minHeartRate ?? null,
stepCount: w.stepCount ?? null,
elevationM: w.elevationM ?? null,
pauseDurationSec: w.pauseDurationSec ?? null,
source: w.source,
externalId: w.externalId ?? null,
externalSourceVersion: w.externalSourceVersion ?? null,
metadata: w.metadata
? (w.metadata as Prisma.InputJsonValue)
: Prisma.JsonNull,
};
return {
index,
row,
route,
dedupKey: w.externalId
? { source: w.source, externalId: w.externalId }
: null,
};
});
let insertedCount = 0;
let duplicateCount = 0;
if (prepared.length > 0) {
const dedupCandidates = prepared
.map((p) => p.dedupKey)
.filter((k): k is { source: string; externalId: string } => k !== null);
const existing = dedupCandidates.length
? await prisma.workout.findMany({
where: {
userId: user.id,
OR: dedupCandidates.map((k) => ({
source: k.source as Prisma.WorkoutCreateManyInput["source"],
externalId: k.externalId,
})),
},
select: { source: true, externalId: true },
})
: [];
const existingSet = new Set(
existing.map((row) => `${row.source}::${row.externalId}`),
);
const toInsert: Prepared[] = [];
for (const p of prepared) {
const keyTuple = p.dedupKey
? `${p.dedupKey.source}::${p.dedupKey.externalId}`
: null;
if (keyTuple !== null && existingSet.has(keyTuple)) {
results[p.index] = { index: p.index, status: "duplicate" };
duplicateCount += 1;
} else {
results[p.index] = { index: p.index, status: "inserted" };
toInsert.push(p);
}
}
if (toInsert.length > 0) {
// Two-step write: createMany the workout rows, then findMany the
// freshly-inserted rows to recover their ids so the route table
// can be populated by FK. We chunk both steps for parity with the
// measurements path and to keep individual SQL statements bounded
// under Postgres's 65k-parameter cap.
const CHUNK = 100;
await prisma.$transaction(async (tx) => {
for (let i = 0; i < toInsert.length; i += CHUNK) {
const slice = toInsert.slice(i, i + CHUNK);
const result = await tx.workout.createMany({
data: slice.map((p) => p.row),
skipDuplicates: true,
});
insertedCount += result.count;
}
// Look up the workouts we just wrote (or that the racing batch
// wrote) so we can attach routes by FK. We probe by the
// composite unique key for entries that have one, and by the
// `(userId, source, startedAt, sportType)` tuple as a fallback
// for manual entries (where externalId is null). The fallback
// is best-effort — manual entries from the iOS client always
// carry an HK uuid, so the null-externalId case is exercised
// only by future surface (manual workout entry).
const routeAttachable = toInsert.filter((p) => p.route !== null);
if (routeAttachable.length > 0) {
const withExternal = routeAttachable.filter(
(p) => p.dedupKey !== null,
);
const withoutExternal = routeAttachable.filter(
(p) => p.dedupKey === null,
);
const lookedUp = withExternal.length
? await tx.workout.findMany({
where: {
userId: user.id,
OR: withExternal.map((p) => ({
source: p.dedupKey!
.source as Prisma.WorkoutCreateManyInput["source"],
externalId: p.dedupKey!.externalId,
})),
},
select: {
id: true,
source: true,
externalId: true,
},
})
: [];
const idBySourceExt = new Map<string, string>();
for (const row of lookedUp) {
if (row.externalId !== null) {
idBySourceExt.set(`${row.source}::${row.externalId}`, row.id);
}
}
// Manual entries — `externalId` is null and the unique index
// is NULL-distinct, so two manual entries on the same instant
// would both insert. We look these up by the deterministic
// (userId, source, startedAt, sportType) tuple for each row.
const manualMatches = await Promise.all(
withoutExternal.map((p) =>
tx.workout.findFirst({
where: {
userId: user.id,
source: p.row.source,
startedAt: p.row.startedAt as Date,
sportType: p.row.sportType,
route: null,
},
select: { id: true },
orderBy: { createdAt: "desc" },
}),
),
);
const routesToInsert: Prisma.WorkoutRouteCreateManyInput[] = [];
for (let i = 0; i < withExternal.length; i++) {
const p = withExternal[i];
const id = idBySourceExt.get(
`${p.dedupKey!.source}::${p.dedupKey!.externalId}`,
);
if (id && p.route) {
routesToInsert.push({
workoutId: id,
geometry: p.route.geometry as Prisma.InputJsonValue,
sampleTimestamps:
p.route.sampleTimestamps === null
? Prisma.JsonNull
: (p.route.sampleTimestamps as Prisma.InputJsonValue),
});
}
}
for (let i = 0; i < withoutExternal.length; i++) {
const p = withoutExternal[i];
const match = manualMatches[i];
if (match?.id && p.route) {
routesToInsert.push({
workoutId: match.id,
geometry: p.route.geometry as Prisma.InputJsonValue,
sampleTimestamps:
p.route.sampleTimestamps === null
? Prisma.JsonNull
: (p.route.sampleTimestamps as Prisma.InputJsonValue),
});
}
}
if (routesToInsert.length > 0) {
for (let i = 0; i < routesToInsert.length; i += CHUNK) {
await tx.workoutRoute.createMany({
data: routesToInsert.slice(i, i + CHUNK),
// 1:1 FK — `workoutId` has a UNIQUE index, so the
// composite unique guards routes too. `skipDuplicates`
// means a re-submitted batch that wins the race on the
// workout row but loses on the route is a no-op rather
// than a hard error.
skipDuplicates: true,
});
}
}
}
});
// v1.4.25 W10 reconcile (senior-dev H-1 parity): when two batches
// race on overlapping externalIds, only one row lands per key.
// `createMany.count` is the source of truth for how many rows
// THIS request wrote; downgrade enough per-entry "inserted"
// statuses to "duplicate" so the per-entry envelope sums match
// the aggregate counts. Order doesn't matter — the iOS sync
// cursor advances past both statuses identically.
const racedDuplicates = toInsert.length - insertedCount;
if (racedDuplicates > 0) {
let downgraded = 0;
for (const p of prepared) {
if (downgraded >= racedDuplicates) break;
if (results[p.index]?.status === "inserted") {
results[p.index] = { index: p.index, status: "duplicate" };
duplicateCount += 1;
downgraded += 1;
}
}
}
}
}
const skipped = results
.filter((r) => r.status === "skipped")
.map((r) => ({ index: r.index, reason: r.reason ?? "unknown" }));
await auditLog("workout.batch.ingest", {
userId: user.id,
ipAddress: getClientIp(request),
details: {
processed: workouts.length,
inserted: insertedCount,
duplicates: duplicateCount,
skipped: skipped.length,
},
});
// v1.4.25 W16c — kick off PR detection for this user. Suppress push
// notifications for historical-backfill batches so a multi-year
// HKWorkout import doesn't fire hundreds of pushes during initial
// sync. The detector also scans Measurement history on the same
// pass, so a workout-only batch still surfaces measurement-side PRs
// the user may have racked up since the last detection.
if (insertedCount > 0 || duplicateCount > 0) {
const silent = workouts.length > PR_DETECTION_SILENT_THRESHOLD;
try {
await enqueuePrDetection(user.id, { silent });
await auditLog("personal_records.detection_enqueued", {
userId: user.id,
details: {
source: "workout.batch",
batchSize: workouts.length,
silent,
},
});
} catch (err) {
annotate({
action: { name: "personal_records.detection_enqueue_failed" },
meta: { error: err instanceof Error ? err.message : String(err) },
});
}
}
annotate({
action: { name: "workout.batch.ingest" },
meta: {
processed: workouts.length,
inserted: insertedCount,
duplicates: duplicateCount,
skipped: skipped.length,
},
});
return apiSuccess({
processed: workouts.length,
inserted: insertedCount,
duplicates: duplicateCount,
skipped,
entries: results,
});
}
+8 -1
View File
@@ -11,6 +11,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { PasswordStrength } from "@/components/ui/password-strength";
import { useTranslations } from "@/lib/i18n/context";
import { detectBrowserTimezone } from "@/lib/tz/format";
export default function RegisterPage() {
const router = useRouter();
@@ -28,10 +29,16 @@ export default function RegisterPage() {
setLoading(true);
try {
// v1.4.25 W7 — ship the browser-detected timezone with the
// signup payload. Server validates against the IANA list and
// falls back to the admin server-default if the value is
// bogus, so a tampered or proxied request can never poison
// the user's stored zone.
const timezone = detectBrowserTimezone();
const res = await fetch("/api/auth/register", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, username, password }),
body: JSON.stringify({ email, username, password, timezone }),
});
const json = await res.json();
+29 -1
View File
@@ -1,7 +1,35 @@
@import "tailwindcss";
@import "tailwindcss" source("../..");
@import "tw-animate-css";
@import "shadcn/tailwind.css";
/*
* Exclude planning + docs directories from Tailwind v4's content scan.
*
* v4's scanner walks the entire project root by default and picks any
* class-shaped token out of every text file. Planning reports and
* design docs often quote literal class-name fragments such as
* `bg-[var(...)]/12` as examples, and the bracketed-CSS-variable form
* combined with an opacity modifier generates an escaped selector that
* Turbopack's CSS parser rejects with `Unexpected token Delim('.')`,
* 500-ing every page. Constraining the scan to source code avoids
* fixture-style strings poisoning the generated CSS.
*
* The `source("../..")` argument on the `@import "tailwindcss"` line
* above pins auto-detection to the project root (resolved from this
* stylesheet) instead of letting Tailwind walk up from whatever cwd
* the build tool happens to be sitting at. That mirrors the
* cwd-anchored fix the safety-contracts loader landed in
* commit `dd44a0d` and stops the planning / docs directories from
* leaking class-shaped fragments into the generated CSS regardless of
* whether the build runs from the repo root or a tooling subdir.
*
* The `@source not` directives below are resolved relative to this
* stylesheet, so they point at `<repo-root>/.planning` and
* `<repo-root>/docs`.
*/
@source not "../../.planning";
@source not "../../docs";
@custom-variant dark (&:is(.dark *));
@theme inline {
+95
View File
@@ -0,0 +1,95 @@
"use client";
import dynamic from "next/dynamic";
import { HeartPulse } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useInsightStatus } from "@/hooks/use-insight-status";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { getBpTargets } from "@/lib/analytics/bp-targets";
/**
* v1.4.25 W4 — `/insights/blutdruck`.
*
* Routed Blood-Pressure sub-page. The mother page hosts the overview
* (hero + briefing + advisor); this page concentrates on BP-only depth:
* the BP chart with its chart-cog overlays, the per-section AI
* assessment, and the chart's target zones tied to the user's age.
*
* Empty-state rule (Marc directive): if the user has no BP data, the
* chart's own empty-state takes over — no extra "no data" CTAs stack
* on top of the chart skeleton.
*/
const HealthChart = dynamic(
() =>
import("@/components/charts/health-chart").then((mod) => ({
default: mod.HealthChart,
})),
{ ssr: false },
);
export default function InsightsBlutdruckPage() {
const { isAuthenticated, user } = useAuth();
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
const { data: status, isLoading: isStatusLoading } =
useInsightStatus("blood-pressure");
const bpTargets =
user?.dateOfBirth != null ? getBpTargets(new Date(user.dateOfBirth)) : null;
const bpTargetZones = bpTargets
? [
{
min: bpTargets.sysLow,
max: bpTargets.sysHigh,
color: "#ff79c6",
opacity: 0.21,
label: t("charts.systolic"),
textColor: "#ff79c6",
lineOpacity: 0.24,
},
{
min: bpTargets.diaLow,
max: bpTargets.diaHigh,
color: "#8be9fd",
opacity: 0.21,
label: t("charts.diastolic"),
textColor: "#8be9fd",
lineOpacity: 0.24,
},
]
: undefined;
return (
<SubPageShell
title={t("insights.bloodPressureSectionTitle")}
description={t("insights.subPage.blutdruckDescription")}
>
<HealthChart
chartKey="bp"
types={["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"]}
title={t("charts.bloodPressure")}
colors={["#ff79c6", "#8be9fd"]}
unit="mmHg"
yAxisUnit="Hg"
targetZones={bpTargetZones}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<HeartPulse className="h-5 w-5" />}
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
cached={status?.cached ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
/>
</SubPageShell>
);
}
+95
View File
@@ -0,0 +1,95 @@
"use client";
import dynamic from "next/dynamic";
import Link from "next/link";
import { Ruler } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useInsightStatus } from "@/hooks/use-insight-status";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
/**
* v1.4.25 W4 — `/insights/bmi`.
*
* Routed BMI sub-page. BMI is derived from `WEIGHT / (height/100)^2`,
* so the chart sets `valueMode="bmi"` on `<HealthChart>` and the
* underlying WEIGHT series is reused. When the user has no height set
* the chart can't compute; we surface the same plain empty-state the
* mother page used.
*/
const HealthChart = dynamic(
() =>
import("@/components/charts/health-chart").then((mod) => ({
default: mod.HealthChart,
})),
{ ssr: false },
);
const BMI_BANDS = [
{ min: 0, max: 17, color: "#ff5555", opacity: 0.16 },
{ min: 17, max: 18.5, color: "#ffb86c", opacity: 0.18 },
{ min: 18.5, max: 24.9, color: "#50fa7b", opacity: 0.2 },
{ min: 24.9, max: 29.9, color: "#ffb86c", opacity: 0.18 },
{ min: 29.9, max: 120, color: "#ff5555", opacity: 0.16 },
];
export default function InsightsBmiPage() {
const { isAuthenticated, user } = useAuth();
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
const { data: status, isLoading: isStatusLoading } = useInsightStatus("bmi");
if (!user?.heightCm) {
return (
<SubPageShell title={t("insights.bmiSectionTitle")}>
<EmptyState
icon={<Ruler className="size-6" />}
title={t("insights.bmiEmptyTitle")}
description={t("insights.bmiEmptyDescription")}
action={
<Button size="sm" asChild>
<Link href="/settings/account">
{t("insights.bmiEmptyAction")}
</Link>
</Button>
}
/>
</SubPageShell>
);
}
return (
<SubPageShell
title={t("insights.bmiSectionTitle")}
description={t("insights.subPage.bmiDescription")}
>
<HealthChart
chartKey="bmi"
types={["WEIGHT"]}
title={t("targets.bmi")}
colors={["#f1fa8c"]}
unit="kg/m²"
valueMode="bmi"
valueBands={BMI_BANDS}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Ruler className="h-5 w-5" />}
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
cached={status?.cached ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
/>
</SubPageShell>
);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import dynamic from "next/dynamic";
import { Scale } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useInsightStatus } from "@/hooks/use-insight-status";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { buildWeightBandsFromHeight } from "@/lib/analytics/value-bands";
/**
* v1.4.25 W4 — `/insights/gewicht`.
*
* Routed Weight sub-page. Renders the weight chart with the user's
* height-derived green/orange/red bands plus the per-section AI
* assessment. The chart-cog (`chartKey="weight"`) lets the user toggle
* trend lines + comparison overlay independently from the dashboard
* weight card.
*/
const HealthChart = dynamic(
() =>
import("@/components/charts/health-chart").then((mod) => ({
default: mod.HealthChart,
})),
{ ssr: false },
);
export default function InsightsGewichtPage() {
const { isAuthenticated, user } = useAuth();
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
const { data: status, isLoading: isStatusLoading } =
useInsightStatus("weight");
const weightBands = user?.heightCm
? buildWeightBandsFromHeight(user.heightCm, {
lowerBound: 30,
upperBound: 250,
})
: undefined;
return (
<SubPageShell
title={t("insights.weightSectionTitle")}
description={t("insights.subPage.gewichtDescription")}
>
<HealthChart
chartKey="weight"
types={["WEIGHT"]}
title={t("charts.weight")}
colors={["#bd93f9"]}
unit="kg"
valueBands={weightBands}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Scale className="h-5 w-5" />}
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
cached={status?.cached ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
/>
</SubPageShell>
);
}
+21
View File
@@ -0,0 +1,21 @@
import type { ReactNode } from "react";
import { InsightsLayoutShell } from "@/components/insights/insights-layout-shell";
/**
* v1.4.25 W4 — shared layout for the routed `/insights` sub-pages.
*
* Hosts the sticky `<InsightsTabStrip>` so the metric pills + (mother-
* page-only) regenerate affordance stay reachable while a user drills
* into a sub-page. The actual page body still owns its own data fetches
* — the layout is a thin presentational frame.
*
* Critical design rule (Marc directive 2026-05-11):
* The `<CoachDrawer>` is NOT mounted in this layout. It stays inside
* `src/app/insights/page.tsx` body so navigating into a sub-page
* unmounts the drawer (matches the Apple Health convention that AI/
* coach surfaces only live on the overview, never on a metric page).
*/
export default function InsightsLayout({ children }: { children: ReactNode }) {
return <InsightsLayoutShell>{children}</InsightsLayoutShell>;
}
+268
View File
@@ -0,0 +1,268 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { Loader2, Pill } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { EmptyState } from "@/components/ui/empty-state";
import { Progress } from "@/components/ui/progress";
import { ComplianceHeatmap } from "@/components/charts/compliance-heatmap";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { TherapyTimeline } from "@/components/insights/therapy-timeline";
/**
* v1.4.25 W4 — `/insights/medikamente`.
*
* Routed Medication-Compliance sub-page. Mirrors the per-medication
* grid from the v1.4.24 mother page (compliance bars + heatmap +
* per-med AI sentence) plus the section-level AI assessment.
*
* No `<HealthChart>` here — medication compliance is event-driven, so
* the data path runs through `/api/insights/comprehensive` +
* `/api/medications/[id]/compliance` instead of the measurement-series
* Prisma path.
*
* Empty-state: zero medications → big "Add your first medication" CTA.
*/
interface MedicationEntry {
id: string;
name: string;
dose: string;
category: "BLOOD_PRESSURE" | "VITAMIN" | "OTHER";
compliance7: number;
compliance30: number;
streak: number;
taken7: number;
skipped7: number;
missed7: number;
}
interface ComprehensiveMedicationData {
medications: MedicationEntry[];
}
interface MedicationComplianceStatusData {
hasProvider: boolean;
summary: string | null;
medications: Array<{ medicationId: string; text: string }>;
cached: boolean;
updatedAt: string | null;
}
interface MedicationDailyData {
expected: number;
taken: number;
skipped: number;
onTime?: number;
late?: number;
veryLate?: number;
}
interface MedicationComplianceDailyResponse {
dailyCompliance: Record<string, MedicationDailyData>;
}
export default function InsightsMedikamentePage() {
const { isAuthenticated } = useAuth();
const { t, locale } = useTranslations();
const { data: comprehensive, isLoading: isComprehensiveLoading } = useQuery({
queryKey: ["insights", "comprehensive"],
queryFn: async () => {
const res = await fetch("/api/insights/comprehensive");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as ComprehensiveMedicationData;
},
enabled: isAuthenticated,
});
const { data: status, isLoading: isStatusLoading } = useQuery({
queryKey: ["insights", "medication-compliance-status", locale],
queryFn: async () => {
const res = await fetch(
`/api/insights/medication-compliance-status?locale=${locale}`,
);
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as MedicationComplianceStatusData;
},
enabled: isAuthenticated,
staleTime: 60 * 1000,
});
const medications = comprehensive?.medications ?? [];
const medicationSummaryById = new Map(
(status?.medications ?? []).map((entry) => [
entry.medicationId,
entry.text,
]),
);
if (isComprehensiveLoading) {
return (
<SubPageShell title={t("insights.medicationCompliance")}>
<div className="flex items-center justify-center py-12">
<Loader2 className="text-primary h-6 w-6 animate-spin" />
</div>
</SubPageShell>
);
}
if (medications.length === 0) {
return (
<SubPageShell title={t("insights.medicationCompliance")}>
<EmptyState
icon={<Pill className="size-6" />}
title={t("insights.subPage.medikamenteEmptyTitle")}
description={t("insights.subPage.medikamenteEmptyDescription")}
action={
<Button size="sm" asChild>
<Link href="/medications">
{t("insights.subPage.medikamenteEmptyAction")}
</Link>
</Button>
}
/>
</SubPageShell>
);
}
return (
<SubPageShell
title={t("insights.medicationCompliance")}
description={t("insights.subPage.medikamenteDescription")}
>
<div
className={
medications.length >= 2 ? "grid gap-4 sm:grid-cols-2" : "grid gap-4"
}
>
{medications.map((med) => {
const medicationSummary = medicationSummaryById.get(med.id);
return (
<Card key={med.id}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Pill className="text-dracula-orange h-4 w-4" />
<CardTitle className="text-sm font-medium">
{med.name}
</CardTitle>
</div>
{med.streak > 0 && (
<Badge variant="outline" className="text-xs">
{t("insights.dayStreak", { count: med.streak })}
</Badge>
)}
</div>
<p className="text-muted-foreground text-xs">{med.dose}</p>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1.5">
<div className="flex justify-between text-xs">
<span>{t("insights.compliance7d")}</span>
<span className="font-medium">{med.compliance7}%</span>
</div>
<Progress value={med.compliance7} className="h-2" />
</div>
<div className="space-y-1.5">
<div className="flex justify-between text-xs">
<span>{t("insights.compliance30d")}</span>
<span className="font-medium">{med.compliance30}%</span>
</div>
<Progress value={med.compliance30} className="h-2" />
</div>
<div className="text-muted-foreground flex justify-between text-xs">
<span>
<span className="text-dracula-green">{med.taken7}</span>{" "}
{t("insights.taken")}
</span>
<span>
<span className="text-dracula-orange">
{med.skipped7}
</span>{" "}
{t("insights.skipped")}
</span>
<span>
<span className="text-dracula-red">{med.missed7}</span>{" "}
{t("insights.missed")}
</span>
</div>
<MedicationComplianceCalendar medicationId={med.id} />
{medicationSummary ? (
<p className="text-muted-foreground text-sm leading-6">
{medicationSummary}
</p>
) : null}
</CardContent>
</Card>
);
})}
</div>
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Pill className="h-5 w-5" />}
text={status?.summary ?? null}
hasProvider={status?.hasProvider ?? false}
cached={status?.cached ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
/>
{/* v1.4.25 W4d — GLP-1 therapy timeline. Self-hides for users
without an active GLP-1 medication, so the page collapses
back to the compliance grid for everyone else. */}
<TherapyTimeline />
</SubPageShell>
);
}
function MedicationComplianceCalendar({
medicationId,
}: {
medicationId: string;
}) {
const { t } = useTranslations();
const { data, isLoading } = useQuery({
queryKey: ["compliance-chart-inline", medicationId],
queryFn: async () => {
const res = await fetch(`/api/medications/${medicationId}/compliance`);
if (!res.ok) return null;
const json = await res.json();
return json.data as MedicationComplianceDailyResponse;
},
enabled: !!medicationId,
staleTime: 60 * 1000,
});
if (isLoading) {
return (
<div className="flex h-32 items-center justify-center">
<Loader2 className="text-primary h-4 w-4 animate-spin" />
</div>
);
}
if (!data?.dailyCompliance) {
return (
<div className="text-muted-foreground rounded-lg border border-dashed px-3 py-4 text-xs">
{t("insights.complianceNoData")}
</div>
);
}
return (
<div className="w-full">
<ComplianceHeatmap dailyCompliance={data.dailyCompliance} stretch />
</div>
);
}
+65 -1652
View File
File diff suppressed because it is too large. Load diff
+134
View File
@@ -0,0 +1,134 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import { Heart } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useInsightStatus } from "@/hooks/use-insight-status";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
import { Vo2MaxChartRow } from "@/components/insights/vo2-max-chart-row";
import type { DataSummary } from "@/lib/analytics/trends";
import {
getAgeFromDateOfBirth,
getPersonalizedPulseTarget,
} from "@/lib/analytics/pulse-targets";
/**
* v1.4.25 W4 — `/insights/puls`.
*
* Routed Pulse sub-page. Renders the pulse chart with the personalized
* Karvonen-derived target band plus the per-section AI assessment.
* Note: `chartKey="pulse"` so the chart-cog can override the
* comparison-overlay independently from the dashboard pulse card; the
* MeasurementType filter is `PULSE` (the same field used elsewhere
* in the codebase).
*/
const HealthChart = dynamic(
() =>
import("@/components/charts/health-chart").then((mod) => ({
default: mod.HealthChart,
})),
{ ssr: false },
);
interface AnalyticsData {
summaries: Record<string, DataSummary>;
}
export default function InsightsPulsPage() {
const { isAuthenticated, user } = useAuth();
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
const { data: status, isLoading: isStatusLoading } =
useInsightStatus("pulse");
// v1.4.25 W16a — VO2 max chart-row consumes the same `/api/analytics`
// bundle the mother page reads. Sharing the cache key keeps the
// payload single-fetch on tab navigation (React-Query unwraps from
// the same key).
const { data: analytics } = useQuery({
queryKey: ["analytics"],
queryFn: async () => {
const res = await fetch("/api/analytics");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as AnalyticsData;
},
enabled: isAuthenticated,
staleTime: 60 * 1000,
});
const vo2Summary = analytics?.summaries?.VO2_MAX ?? null;
const pulseAge = getAgeFromDateOfBirth(user?.dateOfBirth ?? null);
const pulseTarget = getPersonalizedPulseTarget(
pulseAge,
(user?.gender as "MALE" | "FEMALE" | null | undefined) ?? null,
);
const pulseBands = [
{ min: 30, max: pulseTarget.orangeMin, color: "#ff5555", opacity: 0.16 },
{
min: pulseTarget.orangeMin,
max: pulseTarget.greenMin,
color: "#ffb86c",
opacity: 0.18,
},
{
min: pulseTarget.greenMin,
max: pulseTarget.greenMax,
color: "#50fa7b",
opacity: 0.2,
},
{
min: pulseTarget.greenMax,
max: pulseTarget.orangeMax,
color: "#ffb86c",
opacity: 0.18,
},
{ min: pulseTarget.orangeMax, max: 220, color: "#ff5555", opacity: 0.16 },
].filter((band) => band.max > band.min);
return (
<SubPageShell
title={t("insights.pulseSectionTitle")}
description={t("insights.subPage.pulsDescription")}
>
<HealthChart
chartKey="pulse"
types={["PULSE"]}
title={t("charts.pulse")}
colors={["#50fa7b"]}
unit="bpm"
valueBands={pulseBands}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Heart className="h-5 w-5" />}
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
cached={status?.cached ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
/>
{/* v1.4.25 W16a — VO2 max sits on the cardio sub-page because it
is a cardio-fitness metric (Apple's Health app surfaces it
under "Heart"). The chart-row stays mounted even at zero
samples so a brand-new account sees the "no data yet" hint
rather than a missing surface — same pattern the dashboard
tile uses (opt-in via Settings → Dashboard). */}
<Vo2MaxChartRow
summary={vo2Summary}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
</SubPageShell>
);
}
+25
View File
@@ -0,0 +1,25 @@
"use client";
import { useTranslations } from "@/lib/i18n/context";
import { SleepOverview } from "@/components/insights/sleep-overview";
import { SubPageShell } from "@/components/insights/sub-page-shell";
/**
* v1.4.25 W4c — `/insights/schlaf`.
*
* The Sleep sub-page surfaces the per-stage breakdown + duration trend
* the v1.4.23 schema gained but never rendered. All charts live inside
* `<SleepOverview>` so the page-level scaffold stays trivial; data
* fetches and empty-state handling are encapsulated in the component.
*/
export default function InsightsSchlafPage() {
const { t } = useTranslations();
return (
<SubPageShell
title={t("insights.sleep.title")}
description={t("insights.sleep.description")}
>
<SleepOverview />
</SubPageShell>
);
}
+102
View File
@@ -0,0 +1,102 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import dynamic from "next/dynamic";
import Link from "next/link";
import { Smile } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useInsightStatus } from "@/hooks/use-insight-status";
import { useTranslations } from "@/lib/i18n/context";
import { useInsightsLayoutPrefs } from "@/hooks/use-insights-layout-prefs";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { InsightStatusCard } from "@/components/insights/insight-status-card";
import { SubPageShell } from "@/components/insights/sub-page-shell";
/**
* v1.4.25 W4 — `/insights/stimmung`.
*
* Routed Mood sub-page. Unlike the mother page (which hid the mood
* section entirely when no mood data existed), a dedicated sub-page
* cannot just blank itself — we'd land on an empty white page. Instead,
* the sub-page surfaces a clear empty-state CTA into `/mood` so the
* user can log their first entry, matching the Apple Health "No data"
* convention (research §1.1).
*/
const MoodChart = dynamic(
() =>
import("@/components/charts/mood-chart").then((mod) => ({
default: mod.MoodChart,
})),
{ ssr: false },
);
interface ComprehensiveMoodData {
moodSummary: { count: number } | null;
}
export default function InsightsStimmungPage() {
const { isAuthenticated, user } = useAuth();
const { t } = useTranslations();
const { compareBaseline } = useInsightsLayoutPrefs(isAuthenticated);
const { data: status, isLoading: isStatusLoading } = useInsightStatus("mood");
// Reuse the mother-page comprehensive query — TanStack Query
// dedups so this is a free cache read for the common case.
const { data: comprehensive } = useQuery({
queryKey: ["insights", "comprehensive"],
queryFn: async () => {
const res = await fetch("/api/insights/comprehensive");
if (!res.ok) throw new Error("Failed");
const json = await res.json();
return json.data as ComprehensiveMoodData;
},
enabled: isAuthenticated,
});
const moodCount = comprehensive?.moodSummary?.count ?? 0;
if (isAuthenticated && comprehensive && moodCount === 0) {
return (
<SubPageShell title={t("insights.moodSectionTitle")}>
<EmptyState
icon={<Smile className="size-6" />}
title={t("insights.subPage.stimmungEmptyTitle")}
description={t("insights.subPage.stimmungEmptyDescription")}
action={
<Button size="sm" asChild>
<Link href="/mood">
{t("insights.subPage.stimmungEmptyAction")}
</Link>
</Button>
}
/>
</SubPageShell>
);
}
return (
<SubPageShell
title={t("insights.moodSectionTitle")}
description={t("insights.subPage.stimmungDescription")}
>
<MoodChart
chartKey="mood"
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
<InsightStatusCard
title={t("insights.assessmentTitle")}
icon={<Smile className="h-5 w-5" />}
text={status?.text ?? null}
hasProvider={status?.hasProvider ?? false}
cached={status?.cached ?? false}
updatedAt={status?.updatedAt ?? null}
loading={isStatusLoading}
/>
</SubPageShell>
);
}
+1 -1
View File
@@ -51,7 +51,7 @@ export const metadata: Metadata = {
"Personal health tracking — weight, blood pressure, pulse, mood, medication compliance",
type: "website",
locale: "en_US",
alternateLocale: ["de_DE"],
alternateLocale: ["de_DE", "fr_FR", "es_ES", "it_IT", "pl_PL"],
siteName: "HealthLog",
},
twitter: {
+59 -2
View File
@@ -6,6 +6,10 @@ import { useAuth } from "@/hooks/use-auth";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { IntakeHistoryList } from "@/components/medications/intake-history-list";
import { DrugLevelChart } from "@/components/medications/DrugLevelChart";
import { SideEffectsSection } from "@/components/medications/SideEffectsSection";
import { SchedulingSection } from "@/components/medications/SchedulingSection";
import { TitrationSection } from "@/components/medications/TitrationSection";
import { ArrowLeft, Loader2, Plus } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "@/lib/i18n/context";
@@ -33,7 +37,13 @@ export default function IntakeHistoryPage({
const res = await fetch(`/api/medications/${id}`);
if (!res.ok) throw new Error("Failed to fetch");
const json = await res.json();
return json.data as { id: string; name: string; dose: string };
return json.data as {
id: string;
name: string;
dose: string;
treatmentClass?: string;
notificationsEnabled?: boolean;
};
},
enabled: isAuthenticated,
});
@@ -56,7 +66,7 @@ export default function IntakeHistoryPage({
>
<Link href="/medications">
<ArrowLeft className="h-4 w-4" />
Zurück
{t("medications.back")}
</Link>
</Button>
@@ -77,6 +87,53 @@ export default function IntakeHistoryPage({
</Button>
</div>
{/* v1.4.25 W19c-Frontend — Research-mode-gated drug-level chart.
Renders only for GLP-1 medications; the component itself
gates further on Research Mode + version-aligned
acknowledgment, so this page can mount it unconditionally
for any GLP-1 row and trust the chart's internal logic. */}
{medication?.treatmentClass === "GLP1" && (
<DrugLevelChart
medication={{
id: medication.id,
name: medication.name,
dose: medication.dose,
}}
/>
)}
{/* v1.4.25 W19d — GLP-1 side-effect logbook. Sits between the
drug-level chart and the intake history so the user lands on
the cycle context (where am I in the curve), then on the
symptom record, then on the dose-by-dose timeline. Mounted
only for GLP-1 medications; future waves (W19e reminders,
W19f titration ladder) hang off this same surface. */}
{medication?.treatmentClass === "GLP1" && (
<SideEffectsSection medicationId={id} />
)}
{/* v1.4.25 W19e — GLP-1 cadence visualisation + compliance chips.
Sits between the side-effect logbook and the intake history so
the user lands on cycle context (drug-level), then symptom
record, then schedule cadence + adherence, then the
dose-by-dose timeline. */}
{medication?.treatmentClass === "GLP1" && (
<SchedulingSection
medicationId={id}
reminderEnabled={medication.notificationsEnabled ?? true}
/>
)}
{/* v1.4.25 W19f — GLP-1 titration-ladder display. Read-only EMA
reference visual showing the standard dose-escalation schedule
with the user's current step highlighted. Sits between
SchedulingSection and IntakeHistoryList so the user lands on
cycle context (chart) -> side effects -> cadence/adherence ->
titration position -> per-dose timeline. */}
{medication?.treatmentClass === "GLP1" && (
<TitrationSection medicationId={id} />
)}
<IntakeHistoryList
medicationId={id}
createOpen={createOpen}
+44 -17
View File
@@ -8,6 +8,7 @@ import { invalidateKeys, medicationDependentKeys } from "@/lib/query-keys";
import { parseScheduleRecurrence } from "@/lib/medication-schedule";
import { MedicationForm } from "@/components/medications/medication-form";
import { MedicationCard } from "@/components/medications/medication-card";
import { Glp1MedicationCard } from "@/components/medications/glp1-medication-card";
import { Button } from "@/components/ui/button";
import { EmptyState } from "@/components/ui/empty-state";
import { Switch } from "@/components/ui/switch";
@@ -56,6 +57,10 @@ interface Medication {
name: string;
dose: string;
category: string;
/** v1.4.25 W4d — Prisma treatment class (GENERIC | GLP1). */
treatmentClass?: string;
/** v1.4.25 W4d — doses per pen/vial for inventory math. */
dosesPerUnit?: number | null;
active: boolean;
notificationsEnabled: boolean;
pausedAt: string | null;
@@ -115,7 +120,7 @@ export default function MedicationsPage() {
if (!isAuthenticated) {
return (
<div className="space-y-5">
<div className="space-y-6">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">
{t("medications.title")}
@@ -138,7 +143,7 @@ export default function MedicationsPage() {
const inactiveMeds = medsArray.filter((m) => !m.active).sort(byName);
return (
<div className="space-y-5">
<div className="space-y-6">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">
@@ -190,18 +195,26 @@ export default function MedicationsPage() {
}
/>
) : (
<div className="space-y-5">
<div className="space-y-6">
{/* Active medications */}
{activeMeds.length > 0 && (
<div className="space-y-3.5">
<div className="grid gap-4 sm:grid-cols-2">
{activeMeds.map((med) => (
<MedicationCard
key={med.id}
medication={med}
onEdit={openEdit}
/>
))}
{activeMeds.map((med) =>
med.treatmentClass === "GLP1" ? (
<Glp1MedicationCard
key={med.id}
medication={med}
onEdit={openEdit}
/>
) : (
<MedicationCard
key={med.id}
medication={med}
onEdit={openEdit}
/>
),
)}
</div>
</div>
)}
@@ -213,13 +226,21 @@ export default function MedicationsPage() {
{t("common.inactive")} ({inactiveMeds.length})
</h2>
<div className="grid gap-4 sm:grid-cols-2">
{inactiveMeds.map((med) => (
<MedicationCard
key={med.id}
medication={med}
onEdit={openEdit}
/>
))}
{inactiveMeds.map((med) =>
med.treatmentClass === "GLP1" ? (
<Glp1MedicationCard
key={med.id}
medication={med}
onEdit={openEdit}
/>
) : (
<MedicationCard
key={med.id}
medication={med}
onEdit={openEdit}
/>
),
)}
</div>
</div>
)}
@@ -253,6 +274,12 @@ export default function MedicationsPage() {
name: editingMed.name,
dose: editingMed.dose,
category: editingMed.category,
// v1.4.25 W4d — pass through the Prisma treatment-class
// discriminator and the optional dosesPerUnit so the
// form preselects the GLP-1 surfaces correctly when
// editing an existing GLP-1 row.
treatmentClass: editingMed.treatmentClass,
dosesPerUnit: editingMed.dosesPerUnit ?? null,
active: editingMed.active,
notificationsEnabled: editingMed.notificationsEnabled,
schedules: editingMed.schedules.map((s) => ({
+9 -2
View File
@@ -8,6 +8,10 @@ import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import Link from "next/link";
import { Bell, Loader2, Settings, AlertCircle } from "lucide-react";
import {
EVENT_DEFAULT_ENABLED,
type EventType,
} from "@/lib/notifications/types";
interface ChannelInfo {
id: string;
@@ -115,8 +119,11 @@ export default function NotificationsPage() {
const pref = data?.preferences?.find(
(p) => p.channelId === channelId && p.eventType === eventType,
);
// Opt-out model: default is ON when no preference row exists
return pref ? pref.enabled : true;
if (pref) return pref.enabled;
// v1.4.25 W16c — per-event default. Most events stay opt-out
// (no row = ON); PERSONAL_RECORD flips to opt-in (no row = OFF)
// so a backfill doesn't fire hundreds of pushes on first sync.
return EVENT_DEFAULT_ENABLED[eventType as EventType] ?? true;
}
if (authLoading || isLoading) {
+178
View File
@@ -0,0 +1,178 @@
import { redirect, notFound } from "next/navigation";
import Link from "next/link";
import { OnboardingShell } from "@/components/onboarding/OnboardingShell";
import { WelcomeCarousel } from "@/components/onboarding/WelcomeCarousel";
import { GoalsChipPicker } from "@/components/onboarding/GoalsChipPicker";
import { SourceCardGrid } from "@/components/onboarding/SourceCardGrid";
import { BaselineForm } from "@/components/onboarding/BaselineForm";
import { DoneScreen } from "@/components/onboarding/DoneScreen";
import { Button } from "@/components/ui/button";
import { getSession } from "@/lib/auth/session";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
/**
* v1.4.25 W14b — onboarding wizard step page (foundation scaffold).
*
* Routes:
* /onboarding/0 welcome → carousel / value-prop intro
* /onboarding/1 goals → "what do you want to track?"
* /onboarding/2 source → Withings / Apple Health / manual cards
* /onboarding/3 baseline → first measurement or sync confirmation
* /onboarding/4 done → success screen + return to dashboard
*
* This page ships the scaffold only — the step body is a placeholder
* `<div>` that the W14b-Content agent replaces with real step UI.
*
* Step gating:
* - Unauthenticated → `/auth/login` (the proxy also enforces this,
* but the server-side check keeps the contract explicit).
* - `onboardingCompletedAt != null` → `/` (no replaying the wizard
* by URL once the user has finished).
* - Out-of-order requests (e.g. user lands on `/onboarding/3` while
* `onboardingStep == 1`) redirect to the user's current step. The
* "current step" is `User.onboardingStep ?? 0`.
*
* The user MAY navigate backwards (e.g. `/onboarding/0` while on step
* 2). Backwards navigation is non-destructive — the shell's "Back"
* button uses it. Only forward jumps are blocked.
*/
const VALID_STEPS = [0, 1, 2, 3, 4] as const;
type Step = (typeof VALID_STEPS)[number];
export const dynamicParams = false;
export function generateStaticParams() {
return VALID_STEPS.map((step) => ({ step: String(step) }));
}
interface PageProps {
params: Promise<{ step: string }>;
}
export default async function OnboardingStepPage({ params }: PageProps) {
const { step: stepParam } = await params;
const parsedStep = Number.parseInt(stepParam, 10);
if (!Number.isFinite(parsedStep) || !VALID_STEPS.includes(parsedStep as Step)) {
notFound();
}
const requested = parsedStep as Step;
const session = await getSession();
if (!session) {
redirect("/auth/login");
}
const { user } = session;
const completed = user.onboardingCompletedAt != null;
// Completed users hitting any mid-flow step (1, 2, 3) are bounced
// back to the dashboard — replaying half the wizard is never useful.
// Step 0 stays accessible as the welcome-back surface (banner instead
// of carousel) and step 4 stays accessible as the success screen so
// a re-landing user has a clean "Open dashboard" exit.
if (completed && requested > 0 && requested < 4) {
redirect("/");
}
const current = clampCurrentStep(user.onboardingStep);
if (!completed && requested > current) {
redirect(`/onboarding/${current}`);
}
const locale = await resolveServerLocale({ userLocale: user.locale });
const { t } = getServerTranslator(locale);
// Welcome (step 0)
// * Fresh user → value-prop carousel.
// * Returning user (onboarding already completed) → welcome-back
// banner with "Open dashboard" CTA. Marc note 2026-05-14: the
// "restart onboarding" affordance lives in Settings → Account in
// v1.4.26; for now the banner is informational only.
if (requested === 0) {
return (
<OnboardingShell step={0} userLocale={user.locale ?? null}>
{completed ? (
<section
aria-labelledby="onboarding-welcomeback-title"
className="space-y-5"
>
<h1
id="onboarding-welcomeback-title"
tabIndex={-1}
className="text-2xl font-semibold tracking-tight"
>
{t("onboarding.welcomeBack.title")}
</h1>
<p className="text-muted-foreground text-base leading-relaxed">
{t("onboarding.welcomeBack.body")}
</p>
<div className="flex justify-end">
<Button asChild size="lg">
<Link href="/">{t("onboarding.welcomeBack.cta")}</Link>
</Button>
</div>
</section>
) : (
<WelcomeCarousel />
)}
</OnboardingShell>
);
}
// Goals (step 1) — multi-select chip grid. Component owns its own
// Back/Skip/Next row so the shell drops every footer href to avoid
// duplicate controls. The user id is threaded as a prop so the
// client hydration reads localStorage synchronously in its state
// initializer (avoids the setState-in-effect anti-pattern).
if (requested === 1) {
return (
<OnboardingShell step={1} userLocale={user.locale ?? null}>
<GoalsChipPicker userId={user.id} />
</OnboardingShell>
);
}
// Source (step 2) — four-card grid. Manual is the implicit default;
// Withings opens OAuth in a new tab; Apple Health is rendered as a
// "coming with v1.5" announce card; Garmin is omitted per the
// W14b-Content brief (not on the roadmap yet).
if (requested === 2) {
return (
<OnboardingShell step={2} userLocale={user.locale ?? null}>
<SourceCardGrid />
</OnboardingShell>
);
}
// Baseline (step 3) — profile form. PUT /api/auth/profile saves the
// four fields, then POST step:4 advances + flips
// `onboardingCompletedAt`.
if (requested === 3) {
return (
<OnboardingShell step={3} userLocale={user.locale ?? null}>
<BaselineForm />
</OnboardingShell>
);
}
// Done (step 4) — success screen + dashboard CTA. Reachable both
// after first-completion (POST step:4) and again if a completed
// user lands here by URL.
return (
<OnboardingShell step={4} userLocale={user.locale ?? null}>
<DoneScreen />
</OnboardingShell>
);
}
function clampCurrentStep(value: number | null | undefined): Step {
if (value == null || !Number.isFinite(value)) return 0;
const floor = Math.floor(value);
if (floor <= 0) return 0;
if (floor >= 4) return 4;
return floor as Step;
}
+80 -79
View File
@@ -1,100 +1,101 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
// Stub Next.js navigation — we never actually navigate in these tests.
/**
* v1.4.25 W14b-Content — onboarding root redirect tests.
*
* The v1.4.20 single-file wizard was deleted in this commit; the root
* `/onboarding` page is now a server-side redirect into the new
* `/onboarding/[step]` flow. These tests cover the three redirect
* branches: missing session, mid-flow user, completed user.
*/
const redirectMock = vi.fn((href: string) => {
// next/navigation `redirect()` throws a special sentinel inside the
// server-component renderer to short-circuit rendering. Mimic that
// by throwing an error tagged with the href so each test can
// unambiguously assert the redirect target.
const err = new Error(`__redirect__:${href}`);
(err as Error & { __redirect__: string }).__redirect__ = href;
throw err;
});
vi.mock("next/navigation", () => ({
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
redirect: (href: string) => redirectMock(href),
}));
// Stub TanStack Query — the onboarding page only invalidates after
// finish, which we don't trigger here.
vi.mock("@tanstack/react-query", () => ({
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useQuery: () => ({ data: null, isLoading: false }),
const getSessionMock = vi.fn();
vi.mock("@/lib/auth/session", () => ({
getSession: () => getSessionMock(),
}));
// Stub the toaster — fires post-finish only.
vi.mock("sonner", () => ({
toast: { success: vi.fn(), error: vi.fn() },
}));
// The Logo pulls in an SVG icon set we don't need to assert on.
vi.mock("@/components/ui/logo", () => ({
Logo: () => null,
}));
// MeasurementForm is independently tested. Stub it so step 2's render
// doesn't drag the entire form's dependencies into this SSR test.
vi.mock("@/components/measurements/measurement-form", () => ({
MeasurementForm: () => null,
}));
import { I18nProvider } from "@/lib/i18n/context";
import OnboardingPage from "../page";
function render(locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<OnboardingPage />
</I18nProvider>,
);
}
import OnboardingRootPage from "../page";
beforeEach(() => {
vi.clearAllMocks();
redirectMock.mockClear();
getSessionMock.mockReset();
});
describe("<OnboardingPage> v2", () => {
it("renders three progress dots, not four", () => {
const html = render();
// The progress row holds exactly three dots — count `w-10` segments.
const dots = html.match(/h-1\.5 w-10 rounded-full/g) ?? [];
expect(dots.length).toBe(3);
function makeSession(opts: {
onboardingStep?: number | null;
onboardingCompletedAt?: Date | null;
}) {
return {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 86400000) },
user: {
id: "user-1",
onboardingStep: opts.onboardingStep ?? null,
onboardingCompletedAt: opts.onboardingCompletedAt ?? null,
},
};
}
async function runRedirect(): Promise<string> {
try {
await OnboardingRootPage();
} catch (e) {
const tagged = e as Error & { __redirect__?: string };
if (tagged.__redirect__) return tagged.__redirect__;
throw e;
}
throw new Error("expected redirect, none thrown");
}
describe("<OnboardingRootPage> root redirect", () => {
it("redirects to /auth/login when no session", async () => {
getSessionMock.mockResolvedValueOnce(null);
const href = await runRedirect();
expect(href).toBe("/auth/login");
});
it("shows the v2 welcome copy and step 1 of 3 indicator", () => {
const html = render();
expect(html).toContain("Welcome to HealthLog");
expect(html).toContain("Step 1 of 3");
expect(html).toContain("About you");
it("redirects fresh user (onboardingStep null) to /onboarding/0", async () => {
getSessionMock.mockResolvedValueOnce(makeSession({}));
const href = await runRedirect();
expect(href).toBe("/onboarding/0");
});
it("renders the German title when locale is de", () => {
const html = render("de");
expect(html).toContain("Willkommen bei HealthLog");
expect(html).toContain("Schritt 1 von 3");
it("redirects mid-flow user to /onboarding/<current>", async () => {
getSessionMock.mockResolvedValueOnce(makeSession({ onboardingStep: 2 }));
const href = await runRedirect();
expect(href).toBe("/onboarding/2");
});
it("includes a single skip link with the descriptive label", () => {
const html = render();
const skipMatches =
html.match(
/Skip this step — you can finish setup later from Settings./g,
) ?? [];
// Exactly one skip control on step 1; second skip from the legacy
// wizard is gone.
expect(skipMatches.length).toBe(1);
it("clamps an out-of-range onboardingStep into the 0..4 window", async () => {
getSessionMock.mockResolvedValueOnce(makeSession({ onboardingStep: 99 }));
const href = await runRedirect();
expect(href).toBe("/onboarding/4");
});
it("does not show a Back button on step 1 (first step)", () => {
const html = render();
expect(html).not.toContain(">Back<");
});
it("provides every step-1 form field via accessible labels", () => {
const html = render();
expect(html).toContain('for="ob-display-name"');
expect(html).toContain('for="ob-language"');
expect(html).toContain('for="ob-height"');
expect(html).toContain('for="ob-gender"');
expect(html).toContain('for="ob-dob"');
});
it("step-1 progressbar has correct aria attributes", () => {
const html = render();
expect(html).toContain('role="progressbar"');
expect(html).toContain('aria-valuemin="1"');
expect(html).toContain('aria-valuemax="3"');
expect(html).toContain('aria-valuenow="1"');
it("redirects a completed user to /onboarding/<current>", async () => {
getSessionMock.mockResolvedValueOnce(
makeSession({
onboardingStep: 4,
onboardingCompletedAt: new Date("2026-05-01"),
}),
);
const href = await runRedirect();
// The step page handles the welcome-back banner at step 0 and the
// done-screen at step 4 — the root page just bounces into the
// current step regardless of completion state.
expect(href).toBe("/onboarding/4");
});
});
+15
View File
@@ -0,0 +1,15 @@
import type { ReactNode } from "react";
/**
* v1.4.25 W14b — onboarding route-segment layout.
*
* Intentionally pass-through. The `OnboardingShell` chrome lives inside
* each `[step]/page.tsx` so the existing `/onboarding` root page
* (`page.tsx` — the legacy v1.4.20 single-file wizard) keeps rendering
* unchanged while the rebuilt multi-step flow ships beside it. The
* Content agent that follows W14b-Foundation will swap the root page's
* redirect target once every step has real content.
*/
export default function OnboardingLayout({ children }: { children: ReactNode }) {
return <div className="bg-background min-h-[100svh] w-full">{children}</div>;
}
+39 -466
View File
@@ -1,471 +1,44 @@
"use client";
import { redirect } from "next/navigation";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import {
ArrowLeft,
ArrowRight,
Bell,
CheckCircle2,
Loader2,
MessageCircle,
Smartphone,
} from "lucide-react";
import { getSession } from "@/lib/auth/session";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { DateInput } from "@/components/ui/date-input";
import { Label } from "@/components/ui/label";
import { Logo } from "@/components/ui/logo";
import { MeasurementForm } from "@/components/measurements/measurement-form";
import { tourReferrerKey } from "@/components/onboarding/tour-launcher";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { locales, localeLabels, type Locale } from "@/lib/i18n/config";
import { cn } from "@/lib/utils";
const TOTAL_STEPS = 3;
type ChannelChoice = "TELEGRAM" | "WEB_PUSH" | "NTFY" | "NONE";
const CHANNEL_OPTIONS: ReadonlyArray<{
id: ChannelChoice;
Icon: typeof MessageCircle;
titleKey: string;
hintKey: string;
/**
* Hash anchor on the notifications settings page to scroll to. NONE = skip,
* no hash. The wizard pushes the user to `/settings/notifications#<hash>`
* after onboarding finishes — see the v1.4 settings split (PR A2-shell)
* for the new route shape.
*/
hash: string | null;
}> = [
{
id: "TELEGRAM",
Icon: MessageCircle,
titleKey: "onboarding.v2.step3.channelTelegramTitle",
hintKey: "onboarding.v2.step3.channelTelegramHint",
hash: "telegram",
},
{
id: "WEB_PUSH",
Icon: Bell,
titleKey: "onboarding.v2.step3.channelWebPushTitle",
hintKey: "onboarding.v2.step3.channelWebPushHint",
hash: "web-push",
},
{
id: "NTFY",
Icon: Smartphone,
titleKey: "onboarding.v2.step3.channelNtfyTitle",
hintKey: "onboarding.v2.step3.channelNtfyHint",
hash: "ntfy",
},
{
id: "NONE",
Icon: CheckCircle2,
titleKey: "onboarding.v2.step3.channelNoneTitle",
hintKey: "onboarding.v2.step3.channelNoneHint",
hash: null,
},
];
export default function OnboardingPage() {
const router = useRouter();
const queryClient = useQueryClient();
const { t, locale, setLocale } = useTranslations();
const { user } = useAuth();
const [step, setStep] = useState<1 | 2 | 3>(1);
const [saving, setSaving] = useState(false);
// Step 1 — about you
const [displayName, setDisplayName] = useState("");
const [heightCm, setHeightCm] = useState("");
const [dateOfBirth, setDateOfBirth] = useState("");
const [gender, setGender] = useState("");
// Step 2 — first measurement is captured by `<MeasurementForm>`
// directly; we just track whether the user has already submitted one
// so the UI can switch to a "got it" state.
const [measurementLogged, setMeasurementLogged] = useState(false);
// Step 3 — preferred notification channel.
const [channelChoice, setChannelChoice] = useState<ChannelChoice>("NONE");
function goBack() {
setStep((prev) => (prev > 1 ? ((prev - 1) as 1 | 2) : 1));
/**
* v1.4.25 W14b — onboarding root redirect.
*
* The proxy redirect (`src/proxy.ts:179`) lands every still-pending user
* on `/onboarding`. Until W14b shipped, this file rendered the v1.4.20
* single-file 3-step wizard inline. The new flow lives under
* `/onboarding/[step]/page.tsx`, so this root page now resolves to a
* server-side redirect into the right step:
*
* - No session → `/auth/login` (the proxy enforces
* this too; we mirror it to keep the contract explicit).
* - `onboardingCompletedAt != null` (returning user)
* → `/onboarding/0` so the welcome-back
* banner can show; the dashboard is
* one click away from there.
* - Otherwise → `/onboarding/<current>` where
* `current = user.onboardingStep ?? 0`.
*
* The previous wizard remained the entry point through the
* W14b-Foundation phase to keep an unbroken flow; this commit swaps it
* out now that every step page renders real UI.
*/
export default async function OnboardingRootPage() {
const session = await getSession();
if (!session) {
redirect("/auth/login");
}
const { user } = session;
/**
* Persist the profile + complete onboarding. Always called once at
* the end of the wizard — independent of which steps were skipped —
* so `onboardingCompletedAt` is set even if the user skips
* everything except the wizard click-through.
*/
async function persistAndExit(opts?: { gotoSettingsHash?: string | null }) {
setSaving(true);
try {
const body: Record<string, unknown> = {};
if (displayName.trim()) body.displayName = displayName.trim();
if (heightCm) body.heightCm = parseFloat(heightCm);
if (dateOfBirth) body.dateOfBirth = dateOfBirth;
if (gender) body.gender = gender;
const res = await fetch("/api/onboarding/complete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) throw new Error(t("onboarding.errorProfile"));
await queryClient.invalidateQueries({ queryKey: ["auth"] });
toast.success(t("onboarding.v2.doneToast"));
// v1.4.15 Phase B5: leave a presence flag so the dashboard's
// tour launcher knows the user JUST came from the wizard and
// delays the spotlight by ~1.5s. Avoids the doneToast and
// tour overlay stacking on the same z-layer. We use a
// presence-only `"1"` (not a timestamp) so the launcher's
// render-phase decision can stay pure under
// `react-hooks/purity` — the launcher consumes-and-clears the
// key on first read.
//
// v1.4.15 H4: key is scoped by user id so a shared browser
// (admin impersonation, family laptop) keeps each user's
// wizard-referrer state independent. Skip the write entirely
// if the auth payload hasn't loaded — the worst-case is the
// tour pops up immediately instead of after the 1.5s grace
// window, which is harmless.
if (user?.id) {
try {
window.sessionStorage.setItem(tourReferrerKey(user.id), "1");
} catch {
/* ignore — storage disabled or full */
}
}
if (opts?.gotoSettingsHash) {
router.replace(`/settings/notifications#${opts.gotoSettingsHash}`);
} else {
router.replace("/");
}
} catch (err) {
const message =
err instanceof Error ? err.message : t("onboarding.errorGeneric");
toast.error(message);
setSaving(false);
}
}
function nextStep() {
if (step < TOTAL_STEPS) {
setStep((step + 1) as 1 | 2 | 3);
}
}
function finish() {
const target =
channelChoice !== "NONE"
? (CHANNEL_OPTIONS.find((c) => c.id === channelChoice)?.hash ?? null)
: null;
void persistAndExit({ gotoSettingsHash: target });
}
return (
<div className="mx-auto w-full max-w-md space-y-6 py-6">
<div className="text-center">
<Logo className="text-primary mx-auto mb-4" size={48} />
<h1 className="text-2xl font-semibold tracking-tight">
{t("onboarding.v2.title")}
</h1>
<p className="text-muted-foreground mt-2 text-sm">
{t("onboarding.v2.subtitle")}
</p>
</div>
{/* Progress indicator — labelled for screen readers */}
<div
role="progressbar"
aria-valuemin={1}
aria-valuemax={TOTAL_STEPS}
aria-valuenow={step}
aria-label={t("onboarding.v2.stepOf", {
current: step,
total: TOTAL_STEPS,
})}
className="flex items-center justify-center gap-2"
>
{Array.from({ length: TOTAL_STEPS }, (_, i) => (
<div
key={i}
className={cn(
"h-1.5 w-10 rounded-full transition-colors duration-150 ease-out motion-reduce:transition-none",
i + 1 <= step ? "bg-primary" : "bg-muted",
)}
/>
))}
</div>
<p className="text-muted-foreground -mt-3 text-center text-xs">
{t("onboarding.v2.stepOf", { current: step, total: TOTAL_STEPS })}
</p>
{/* ───── Step 1 — about you ───── */}
{step === 1 && (
<section
aria-labelledby="ob-step1-title"
className="bg-card border-border space-y-5 rounded-xl border p-6"
>
<header className="space-y-1">
<h2
id="ob-step1-title"
className="text-lg font-semibold tracking-tight"
>
{t("onboarding.v2.step1.title")}
</h2>
<p className="text-muted-foreground text-sm">
{t("onboarding.v2.step1.description")}
</p>
</header>
<div className="space-y-2">
<Label htmlFor="ob-display-name">
{t("onboarding.v2.step1.displayNameLabel")}
</Label>
<Input
id="ob-display-name"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
autoComplete="nickname"
placeholder={t("settings.username")}
maxLength={50}
/>
<p className="text-muted-foreground text-xs">
{t("onboarding.v2.step1.displayNameHint")}
</p>
</div>
<div className="space-y-2">
<Label htmlFor="ob-language">
{t("onboarding.v2.step1.languageLabel")}
</Label>
<select
id="ob-language"
value={locale}
onChange={(e) => setLocale(e.target.value as Locale)}
className="border-input bg-background text-foreground ring-offset-background focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm focus-visible:ring-[3px] focus-visible:outline-none"
>
{locales.map((loc) => (
<option key={loc} value={loc}>
{localeLabels[loc as Locale]}
</option>
))}
</select>
<p className="text-muted-foreground text-xs">
{t("onboarding.v2.step1.languageHint")}
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="ob-height">{t("settings.height")}</Label>
<Input
id="ob-height"
type="number"
inputMode="decimal"
value={heightCm}
onChange={(e) => setHeightCm(e.target.value)}
placeholder="175"
min={50}
max={300}
step={0.1}
autoComplete="off"
/>
</div>
<div className="space-y-2">
<Label htmlFor="ob-gender">{t("settings.gender")}</Label>
<select
id="ob-gender"
value={gender}
onChange={(e) => setGender(e.target.value)}
className="border-input bg-background text-foreground ring-offset-background focus-visible:ring-ring flex h-9 w-full rounded-md border px-3 py-1 text-sm focus-visible:ring-[3px] focus-visible:outline-none"
>
<option value="">{t("settings.genderNone")}</option>
<option value="MALE">{t("settings.genderMale")}</option>
<option value="FEMALE">{t("settings.genderFemale")}</option>
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="ob-dob">{t("settings.dateOfBirth")}</Label>
<DateInput
id="ob-dob"
value={dateOfBirth}
onChange={(e) => setDateOfBirth(e.target.value)}
max={new Date().toISOString().slice(0, 10)}
autoComplete="bday"
/>
<p className="text-muted-foreground text-xs">
{t("settings.dateOfBirthHint")}
</p>
</div>
</section>
)}
{/* ───── Step 2 — first measurement ───── */}
{step === 2 && (
<section
aria-labelledby="ob-step2-title"
className="bg-card border-border space-y-5 rounded-xl border p-6"
>
<header className="space-y-1">
<h2
id="ob-step2-title"
className="text-lg font-semibold tracking-tight"
>
{t("onboarding.v2.step2.title")}
</h2>
<p className="text-muted-foreground text-sm">
{t("onboarding.v2.step2.description")}
</p>
</header>
{measurementLogged ? (
<div className="border-primary/40 bg-primary/5 text-foreground flex items-start gap-3 rounded-lg border p-4 text-sm">
<CheckCircle2 className="text-primary mt-0.5 size-5 shrink-0" />
<p>{t("onboarding.v2.step2.added")}</p>
</div>
) : (
<MeasurementForm onSuccess={() => setMeasurementLogged(true)} />
)}
<p className="text-muted-foreground text-xs">
{t("onboarding.v2.step2.skipHint")}
</p>
</section>
)}
{/* ───── Step 3 — pick a channel ───── */}
{step === 3 && (
<section
aria-labelledby="ob-step3-title"
className="bg-card border-border space-y-5 rounded-xl border p-6"
>
<header className="space-y-1">
<h2
id="ob-step3-title"
className="text-lg font-semibold tracking-tight"
>
{t("onboarding.v2.step3.title")}
</h2>
<p className="text-muted-foreground text-sm">
{t("onboarding.v2.step3.description")}
</p>
</header>
<fieldset className="space-y-2">
<legend className="sr-only">
{t("onboarding.v2.step3.title")}
</legend>
{CHANNEL_OPTIONS.map((option) => {
const checked = channelChoice === option.id;
const Icon = option.Icon;
return (
<label
key={option.id}
className={cn(
"border-border hover:border-primary/50 flex cursor-pointer items-start gap-3 rounded-lg border p-3 transition-colors duration-150 ease-out motion-reduce:transition-none",
checked && "border-primary bg-primary/5",
)}
>
<input
type="radio"
name="ob-channel"
value={option.id}
checked={checked}
onChange={() => setChannelChoice(option.id)}
className="text-primary focus-visible:ring-ring mt-1 size-4 focus-visible:ring-[3px] focus-visible:outline-none"
/>
<span className="flex min-w-0 flex-1 items-start gap-3">
<span
aria-hidden="true"
className="bg-muted text-muted-foreground flex size-8 shrink-0 items-center justify-center rounded-md"
>
<Icon className="size-4" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium">
{t(option.titleKey)}
</span>
<span className="text-muted-foreground block text-xs">
{t(option.hintKey)}
</span>
</span>
</span>
</label>
);
})}
</fieldset>
</section>
)}
{/* ───── Navigation row ───── */}
<div className="flex items-center gap-3">
{step > 1 ? (
<Button
type="button"
variant="outline"
onClick={goBack}
disabled={saving}
>
<ArrowLeft className="mr-1 size-4" />
{t("onboarding.v2.back")}
</Button>
) : (
<span className="flex-1" />
)}
{step < TOTAL_STEPS ? (
<Button
type="button"
onClick={nextStep}
className="ml-auto"
disabled={saving}
>
{t("onboarding.v2.continue")}
<ArrowRight className="ml-1 size-4" />
</Button>
) : (
<Button
type="button"
onClick={finish}
className="ml-auto"
disabled={saving}
>
{saving && <Loader2 className="mr-2 size-4 animate-spin" />}
{channelChoice === "NONE"
? t("onboarding.v2.finish")
: t("onboarding.v2.step3.openSettings")}
</Button>
)}
</div>
{/* Single Skip per step. Step 3's "NONE" choice already covers
skipping notifications, so we hide the underline on step 3. */}
{step < TOTAL_STEPS ? (
<button
type="button"
onClick={nextStep}
disabled={saving}
className="text-muted-foreground hover:text-foreground mx-auto block text-sm underline-offset-4 transition-colors duration-150 ease-out hover:underline motion-reduce:transition-none"
>
{t("onboarding.v2.skipStep")}
</button>
) : null}
</div>
);
const current = clampCurrentStep(user.onboardingStep);
redirect(`/onboarding/${current}`);
}
function clampCurrentStep(value: number | null | undefined): 0 | 1 | 2 | 3 | 4 {
if (value == null || !Number.isFinite(value)) return 0;
const floor = Math.floor(value);
if (floor <= 0) return 0;
if (floor >= 4) return 4;
return floor as 0 | 1 | 2 | 3 | 4;
}
+65 -9
View File
@@ -7,6 +7,7 @@ import {
Activity,
Droplet,
Footprints,
Gauge,
Heart,
Moon,
Percent,
@@ -47,6 +48,7 @@ import { TourLauncher } from "@/components/onboarding/tour-launcher";
import { RecentAchievementsCard } from "@/components/gamification/recent-achievements-card";
import { InsightsCardPreview } from "@/components/insights/insights-card";
import { useInsightsAdvisorQuery } from "@/components/insights/use-insights-advisor";
import { Glp1Tile } from "@/components/dashboard/glp1-tile";
const HealthChart = dynamic(
() =>
@@ -250,6 +252,10 @@ export default function DashboardPage() {
const bf = data?.summaries?.BODY_FAT;
const sleepSummary = data?.summaries?.SLEEP_DURATION;
const stepsSummary = data?.summaries?.ACTIVITY_STEPS;
// v1.4.25 W8d — VO2 max secondary-metric tile. /api/analytics
// auto-populates this summary because the route iterates over the
// full measurementTypeEnum.options list; no backend change needed.
const vo2Summary = data?.summaries?.VO2_MAX;
const moodSummary = moodData?.summary;
// Resolve full dashboard layout — controls visibility + order of every widget
@@ -306,6 +312,7 @@ export default function DashboardPage() {
const hasMood = (moodSummary?.count ?? 0) > 0;
const hasSleep = (sleepSummary?.count ?? 0) > 0;
const hasSteps = (stepsSummary?.count ?? 0) > 0;
const hasVo2 = (vo2Summary?.count ?? 0) > 0;
const hasBpInTarget = data?.bpInTargetPct != null;
// Tile (strip) gates — controlled by the new `tileVisible` flag.
@@ -316,6 +323,7 @@ export default function DashboardPage() {
const showMoodTile = isTileVisible("mood") && hasMood;
const showSleepTile = isTileVisible("sleep") && hasSleep;
const showStepsTile = isTileVisible("steps") && hasSteps;
const showVo2Tile = isTileVisible("vo2Max") && hasVo2;
const showBpInTargetTile = isTileVisible("bpInTarget") && hasBpInTarget;
// Chart (lower row) gates — controlled by the legacy `visible` flag.
@@ -563,7 +571,7 @@ export default function DashboardPage() {
node: (
<TrendCard
key="weight"
label={t("dashboard.weight")}
label={t("dashboard.weightShort")}
latest={w?.latest ?? null}
unit="kg"
avg7={w?.avg7 ?? null}
@@ -608,7 +616,7 @@ export default function DashboardPage() {
node: (
<TrendCard
key="bp-sys"
label={t("dashboard.bloodPressureSys")}
label={t("dashboard.bloodPressureSysShort")}
latest={sys?.latest ?? null}
unit="mmHg"
avg7={sys?.avg7 ?? null}
@@ -648,7 +656,7 @@ export default function DashboardPage() {
node: (
<TrendCard
key="bp-dia"
label={t("dashboard.bloodPressureDia")}
label={t("dashboard.bloodPressureDiaShort")}
latest={dia?.latest ?? null}
unit="mmHg"
avg7={dia?.avg7 ?? null}
@@ -688,7 +696,7 @@ export default function DashboardPage() {
node: (
<TrendCard
key="pulse"
label={t("dashboard.pulse")}
label={t("dashboard.pulseShort")}
latest={p?.latest ?? null}
unit="bpm"
avg7={p?.avg7 ?? null}
@@ -727,7 +735,7 @@ export default function DashboardPage() {
node: (
<TrendCard
key="bodyFat"
label={t("dashboard.bodyFat")}
label={t("dashboard.bodyFatShort")}
latest={bf?.latest ?? null}
unit="%"
avg7={bf?.avg7 ?? null}
@@ -749,7 +757,7 @@ export default function DashboardPage() {
node: (
<TrendCard
key="mood"
label={t("dashboard.mood")}
label={t("dashboard.moodShort")}
latest={moodSummary?.latest ?? null}
unit="/ 5"
avg7={moodSummary?.avg7 ?? null}
@@ -771,7 +779,7 @@ export default function DashboardPage() {
node: (
<TrendCard
key="sleep"
label={t("dashboard.sleep") ?? "Sleep"}
label={t("dashboard.sleepShort") ?? "Sleep"}
latest={sleepSummary?.latest ?? null}
unit="h"
avg7={sleepSummary?.avg7 ?? null}
@@ -793,7 +801,7 @@ export default function DashboardPage() {
node: (
<TrendCard
key="steps"
label={t("dashboard.steps") ?? "Steps"}
label={t("dashboard.stepsShort") ?? "Steps"}
latest={stepsSummary?.latest ?? null}
unit=""
avg7={stepsSummary?.avg7 ?? null}
@@ -808,6 +816,35 @@ export default function DashboardPage() {
),
});
}
// v1.4.25 W8d — VO2 max trend tile. Self-gates on the
// `vo2Max` widget being enabled (Settings → Dashboard) AND
// the analytics summary carrying at least one sample. Higher
// VO2 max is better, so the directionSentiment is up-good and
// an upward 30-day slope renders the green arrow. Unit
// matches the canonical DB unit in
// src/lib/validations/measurement.ts.
if (showVo2Tile) {
trendCards.push({
id: "vo2Max",
order: widgetOrder("vo2Max"),
node: (
<TrendCard
key="vo2Max"
label={t("dashboard.vo2MaxShort") ?? "VO₂ max"}
latest={vo2Summary?.latest ?? null}
unit={t("dashboard.vo2MaxUnit") ?? "mL/(kg·min)"}
avg7={vo2Summary?.avg7 ?? null}
avg30={vo2Summary?.avg30 ?? null}
slope30={vo2Summary?.slope30 ?? null}
trend7Delta={summaryToTrend7Delta(vo2Summary)}
icon={Gauge}
directionSentiment="up-good"
compareBaseline={compareBaseline}
compareDelta={tileCompareDelta(vo2Summary)}
/>
),
});
}
if (showBpInTargetTile) {
/* v1.4.22 A2 — feature parity with every other tile.
Synthesise a slope from the difference between the 7-day
@@ -872,7 +909,7 @@ export default function DashboardPage() {
node: (
<TrendCard
key="bpInTarget"
label={t("dashboard.bpInTarget")}
label={t("dashboard.bpInTargetShort")}
latest={data?.bpInTargetPct ?? null}
unit="%"
/* v1.4.18 A1 — wire 7T / 30T sub-values from the new
@@ -957,6 +994,7 @@ export default function DashboardPage() {
unit="kg"
valueBands={weightBands}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
),
});
@@ -1002,6 +1040,7 @@ export default function DashboardPage() {
yAxisUnit="Hg"
targetZones={bpTargetZones}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
),
});
@@ -1021,6 +1060,7 @@ export default function DashboardPage() {
unit="bpm"
valueBands={pulseBands}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
),
});
@@ -1040,6 +1080,7 @@ export default function DashboardPage() {
unit="%"
valueBands={bodyFatBands}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
),
});
@@ -1054,6 +1095,7 @@ export default function DashboardPage() {
key="mood-chart"
compareBaseline={compareBaseline}
chartKey="mood"
userTimezone={user?.timezone}
/>
),
});
@@ -1072,6 +1114,7 @@ export default function DashboardPage() {
colors={["#8be9fd"]}
unit="h"
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
),
});
@@ -1089,6 +1132,7 @@ export default function DashboardPage() {
title={t("dashboard.steps") ?? "Steps"}
colors={["#50fa7b"]}
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
),
});
@@ -1106,6 +1150,7 @@ export default function DashboardPage() {
<MedicationComplianceChart
key="medications"
compareBaseline={compareBaseline}
userTimezone={user?.timezone}
/>
),
});
@@ -1215,6 +1260,17 @@ export default function DashboardPage() {
insight={advisor.payload?.insights ?? null}
/>
)}
{/* v1.4.25 W6 — GLP-1 status tile. The tile self-gates on
`Medication.treatmentClass === "GLP1"` (route returns
`data: null` when the user has no active GLP-1 med), so
we always mount it and let the tile suppress itself.
Slot is below the InsightsCardPreview and above the
chart row — high enough that a Mounjaro / Ozempic user
sees their dose-response chart without scrolling past
BP / pulse, but below the AI preview so the latter
stays the "scroll-stop hero" Marc anchored the dashboard
around. */}
<Glp1Tile />
{charts.map((entry) => (
<div key={entry.id} className="space-y-2">
{entry.node}
+2
View File
@@ -11,6 +11,7 @@ import { ExportSection } from "@/components/settings/export-section";
import { IntegrationsSection } from "@/components/settings/integrations-section";
import { NotificationsSection } from "@/components/settings/notifications-section";
import { SectionPlaceholder } from "@/components/settings/section-placeholder";
import { SourcesSection } from "@/components/settings/sources-section";
import { ThresholdsSection } from "@/components/settings/thresholds-section";
import {
SETTINGS_SECTION_SLUGS,
@@ -45,6 +46,7 @@ const SECTION_COMPONENTS: Record<
notifications: NotificationsSection,
dashboard: DashboardSection,
thresholds: ThresholdsSection,
sources: SourcesSection,
api: ApiSection,
export: ExportSection,
advanced: AdvancedSection,
+143 -639
View File
@@ -1,58 +1,22 @@
"use client";
import { useMemo } from "react";
import { useQuery } from "@tanstack/react-query";
import { AlertCircle, Loader2 } from "lucide-react";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Scale,
Heart,
Activity,
Moon,
Percent,
Loader2,
TrendingUp,
TrendingDown,
Minus,
AlertCircle,
ExternalLink,
Smile,
Droplet,
} from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose";
import {
TargetCard,
type TargetCardData,
} from "@/components/targets/target-card";
import { TargetsSummaryHeader } from "@/components/targets/targets-summary-header";
import { CoachDrawer } from "@/components/insights/coach-panel/coach-drawer";
import { useCoachHandoff } from "@/hooks/use-coach-handoff";
interface TargetData {
type: string;
label: string;
current: number | null;
average30: number | null;
trend: "up" | "down" | "stable" | null;
unit: string;
range: { min: number; max: number } | null;
classification: { category: string; color: string } | null;
source: string;
/**
* v1.4.22 C1 — sparkline + vs.-last-month delta. Optional; the API
* skips both when a window has fewer than 3 readings so the comparison
* stays honest on a fresh account.
*/
points30d?: number[] | null;
deltaVsLastMonth?: number | null;
details?: {
medications?: Array<{
name: string;
compliance7: number;
compliance30: number;
}>;
};
}
type TargetData = TargetCardData;
interface BpDiastolic {
current: number | null;
@@ -60,8 +24,15 @@ interface BpDiastolic {
range: { min: number; max: number } | null;
}
interface TargetPageSummary {
targetsMetThisWeek: number;
totalTargets: number;
streakHighlight: { metric: string; days: number } | null;
}
interface TargetsResponse {
targets: TargetData[];
pageSummary?: TargetPageSummary;
bpDiastolic: BpDiastolic;
profile: {
heightCm: number | null;
@@ -71,37 +42,22 @@ interface TargetsResponse {
};
}
const TYPE_ICONS: Record<string, typeof Scale> = {
WEIGHT: Scale,
BLOOD_PRESSURE: Heart,
BLOOD_PRESSURE_IN_TARGET: Heart,
PULSE: Activity,
SLEEP_DURATION: Moon,
BODY_FAT: Percent,
BMI: Scale,
MOOD_SCORE: Smile,
MOOD_STABILITY: Smile,
BLOOD_GLUCOSE_FASTING: Droplet,
BLOOD_GLUCOSE_POSTPRANDIAL: Droplet,
BLOOD_GLUCOSE_RANDOM: Droplet,
BLOOD_GLUCOSE_BEDTIME: Droplet,
};
const TYPE_COLORS: Record<string, string> = {
WEIGHT: "text-dracula-purple",
BLOOD_PRESSURE: "text-dracula-pink",
BLOOD_PRESSURE_IN_TARGET: "text-dracula-pink",
PULSE: "text-dracula-green",
SLEEP_DURATION: "text-dracula-cyan",
BODY_FAT: "text-dracula-orange",
BMI: "text-dracula-yellow",
MOOD_SCORE: "text-dracula-lavender",
MOOD_STABILITY: "text-dracula-lavender",
BLOOD_GLUCOSE_FASTING: "text-dracula-red",
BLOOD_GLUCOSE_POSTPRANDIAL: "text-dracula-red",
BLOOD_GLUCOSE_RANDOM: "text-dracula-red",
BLOOD_GLUCOSE_BEDTIME: "text-dracula-red",
};
/**
* v1.4.25 W3e — provider-chain status used to gate the per-card Coach
* CTA. Returns true when at least one provider is configured AND
* enabled. The Settings → AI surface owns the same query
* (`useQuery({ queryKey: ["insights", "provider-chain"] })`) so the
* cache is shared.
*/
interface ProviderChainStatus {
activeProvider: string | null;
cachedActiveProvider: string | null;
configuredChain: Array<{
providerType: string;
enabled: boolean;
available: boolean;
}>;
}
const GLUCOSE_TYPES = new Set([
"BLOOD_GLUCOSE_FASTING",
@@ -111,115 +67,45 @@ const GLUCOSE_TYPES = new Set([
]);
/**
* Map server-emitted classification.category strings to the i18n
* key under `targets.status.*`. The server is the source of truth
* for the category set (lib/analytics/classifications.ts +
* pulse-targets.ts + targets/route.ts inline maps); v1.4.19 phase
* A7 wires this map so the German locale stops surfacing English
* status pills like "Low / On Target / Stable / Moderate" — the
* exact regression the maintainer reported.
* Fixed card order per Marc directive — explicitly NOT status-sorted.
* Marc wants a stable visual hierarchy so the user's eye lands on the
* same metric in the same place every visit. Cards whose type is not
* in this list (glucose contexts, future metrics) sort after the
* core six in their server-emitted order.
*/
const STATUS_CATEGORY_KEY: Record<string, string> = {
// BMI
Underweight: "underweight",
Normal: "normal",
Overweight: "overweight",
"Obesity Grade I": "obesityGrade1",
"Obesity Grade II": "obesityGrade2",
"Obesity Grade III": "obesityGrade3",
// BP
Optimal: "optimal",
"High-normal": "highNormal",
"Hypertension Grade 1": "hypertensionGrade1",
"Hypertension Grade 2": "hypertensionGrade2",
"Hypertension Grade 3": "hypertensionGrade3",
// Pulse
Bradycardia: "bradycardia",
Elevated: "elevated",
Tachycardia: "tachycardia",
// Pulse target / sleep target
"Significantly low": "significantlyLow",
"Slightly low": "slightlyLow",
"On target": "onTarget",
"Slightly elevated": "slightlyElevated",
"Significantly elevated": "significantlyElevated",
// Sleep
"Far too short": "farTooShort",
"Too short": "tooShort",
"Slightly long": "slightlyLong",
"Far too long": "farTooLong",
// Body fat
"Below essential": "belowEssential",
Essential: "essential",
Athletic: "athletic",
Fitness: "fitness",
Acceptable: "acceptable",
Obese: "obese",
// Steps
"Very low": "veryLow",
"Low active": "lowActive",
"Moderately active": "moderatelyActive",
Active: "active",
"Very active": "veryActive",
// Generic / shared (BP-on-target, mood, medication compliance, glucose)
Good: "good",
Moderate: "moderate",
Low: "low",
High: "high",
"Very good": "veryGood",
"Very stable": "veryStable",
Stable: "stable",
Fluctuating: "fluctuating",
const FIXED_TARGET_ORDER: Record<string, number> = {
BLOOD_PRESSURE: 0,
BLOOD_PRESSURE_IN_TARGET: 1,
WEIGHT: 2,
PULSE: 3,
BMI: 4,
MOOD_SCORE: 5,
MOOD_STABILITY: 6,
MEDICATION_COMPLIANCE: 7,
SLEEP_DURATION: 8,
BODY_FAT: 9,
ACTIVITY_STEPS: 10,
};
/** Resolve a server-emitted classification.category to its translated label. */
function translateStatus(
category: string,
t: (key: string, vars?: Record<string, string>) => string,
): string {
const key = STATUS_CATEGORY_KEY[category];
if (!key) return category; // unmapped — fall back to verbatim string
return t(`targets.status.${key}`);
function sortKey(target: TargetData): number {
return FIXED_TARGET_ORDER[target.type] ?? 100;
}
/** Resolve a target's display label.
*
* Glucose contexts are special-cased: the server emits an i18n key
* (e.g. `targets.glucoseFasting`) as its `label`, and the page's
* top-level `visibleTargets` mapping resolves that to a localised
* string before the card mounts. So when this helper runs, glucose
* targets already carry the localised label and the i18n lookup
* below misses (no `targets.label.BLOOD_GLUCOSE_FASTING` key) — the
* fallback returns the verbatim resolved label, which is correct.
*
* Every other target type carries a stable English label from the
* server. Look it up under `targets.label.<TYPE>` here so the card
* title respects the user's locale.
/**
* Source-citation URL for each target type. Kept on the page (rather
* than the card) so the card stays presentational and the routing
* stays declarative.
*/
function translateTargetLabel(
target: TargetData,
t: (key: string, vars?: Record<string, string>) => string,
): string {
const i18nKey = `targets.label.${target.type}`;
const localised = t(i18nKey);
if (localised && localised !== i18nKey) {
return localised;
}
return target.label;
}
function getTargetSourceLink(target: TargetData): string | null {
if (target.type === "WEIGHT" || target.type === "BMI") {
return "https://www.who.int/news-room/fact-sheets/detail/obesity-and-overweight";
}
if (target.type === "BLOOD_PRESSURE") {
if (
target.type === "BLOOD_PRESSURE" ||
target.type === "BLOOD_PRESSURE_IN_TARGET"
) {
return "https://academic.oup.com/eurheartj/article/39/33/3021/5079119";
}
if (target.type === "BLOOD_PRESSURE_IN_TARGET") {
return "https://academic.oup.com/eurheartj/article/39/33/3021/5079119";
}
if (target.type === "PULSE") {
if (target.source.includes("CDC/NCHS")) {
return "https://www.cdc.gov/nchs/data/nhsr/nhsr041.pdf";
@@ -228,453 +114,18 @@ function getTargetSourceLink(target: TargetData): string | null {
return "https://www.heart.org/en/health-topics/high-blood-pressure/the-facts-about-high-blood-pressure/all-about-heart-rate-pulse";
}
}
if (target.type === "SLEEP_DURATION") {
return "https://aasm.org/seven-or-more-hours-of-sleep-per-night-a-health-necessity-for-adults/";
}
if (target.type === "BODY_FAT") {
return "https://www.acefitness.org/resources/everyone/blog/6596/what-are-the-guidelines-for-percentage-of-body-fat-loss/";
}
if (target.type === "ACTIVITY_STEPS") {
return "https://www.who.int/publications/i/item/9789240015128";
}
if (target.type === "MOOD_SCORE" || target.type === "MOOD_STABILITY") {
return null;
}
return null;
}
/**
* v1.4.22 C1 — tiny inline sparkline. Each target card paints the last
* 30 days of values beneath the range bar so the page reads as a
* "current state + recent journey" surface rather than a static
* reference card. The SVG is dependency-free and renders in <1ms; we
* deliberately avoid pulling Recharts in here because every card on
* the page would mount its own ResponsiveContainer.
*
* The path stays in the dracula-purple token so the colour reads as a
* neutral trace, not a status signal — the range bar above already
* carries the green/yellow/red semantics and a second status colour
* on the sparkline would compete for the user's eye.
*/
function Sparkline({
points,
className,
}: {
points: number[];
className?: string;
}) {
if (points.length < 2) return null;
const min = Math.min(...points);
const max = Math.max(...points);
const span = Math.max(max - min, 0.0001);
const w = 100;
const h = 24;
const stepX = w / (points.length - 1);
const path = points
.map((value, i) => {
const x = i * stepX;
const y = h - ((value - min) / span) * h;
return `${i === 0 ? "M" : "L"}${x.toFixed(2)},${y.toFixed(2)}`;
})
.join(" ");
return (
<svg
viewBox={`0 0 ${w} ${h}`}
preserveAspectRatio="none"
className={`h-6 w-full ${className ?? ""}`}
aria-hidden="true"
data-slot="target-sparkline"
>
<path
d={path}
fill="none"
stroke="var(--dracula-purple)"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
function TrendIcon({ trend }: { trend: "up" | "down" | "stable" | null }) {
if (trend === "up") {
return <TrendingUp className="h-4 w-4 text-orange-400" />;
}
if (trend === "down") {
return <TrendingDown className="h-4 w-4 text-cyan-400" />;
}
if (trend === "stable") {
return <Minus className="h-4 w-4 text-green-400" />;
}
return null;
}
/**
* A horizontal range bar with green/yellow/red zones showing where
* the current value falls relative to a target range.
*/
function RangeBar({
value,
min,
max,
unit,
orangeMin,
orangeMax,
}: {
value: number;
min: number;
max: number;
unit: string;
orangeMin?: number;
orangeMax?: number;
}) {
const { t } = useTranslations();
const span = max - min;
const defaultOrangeWidth = span * 0.3;
const computedOrangeMin = min - defaultOrangeWidth;
const computedOrangeMax = max + defaultOrangeWidth;
const effectiveOrangeMin =
orangeMin != null ? Math.min(orangeMin, min) : computedOrangeMin;
const effectiveOrangeMax =
orangeMax != null ? Math.max(orangeMax, max) : computedOrangeMax;
const orangeSpan = Math.max(1, effectiveOrangeMax - effectiveOrangeMin);
const sidePadding = Math.max(1, orangeSpan * 0.18);
const visualMin = effectiveOrangeMin - sidePadding;
const visualMax = effectiveOrangeMax + sidePadding;
const visualSpan = visualMax - visualMin;
const clampedValue = Math.max(visualMin, Math.min(visualMax, value));
const rawPosition = ((clampedValue - visualMin) / visualSpan) * 100;
const EDGE_PADDING_PERCENT = 4;
const position = Math.max(
EDGE_PADDING_PERCENT,
Math.min(100 - EDGE_PADDING_PERCENT, rawPosition),
);
// Zone boundaries (percent of visual bar)
const greenStart = Math.max(0, ((min - visualMin) / visualSpan) * 100);
const greenEnd = Math.min(100, ((max - visualMin) / visualSpan) * 100);
const yellowLeftStart = Math.max(
0,
((effectiveOrangeMin - visualMin) / visualSpan) * 100,
);
const yellowRightEnd = Math.min(
100,
((effectiveOrangeMax - visualMin) / visualSpan) * 100,
);
// Determine marker color
const inGreen = value >= min && value <= max;
const inYellow =
!inGreen && value >= effectiveOrangeMin && value <= effectiveOrangeMax;
const markerColor = inGreen
? "var(--dracula-green)"
: inYellow
? "var(--dracula-orange)"
: "var(--dracula-red)";
const minLabelPosition = Math.max(5, Math.min(95, greenStart));
const maxLabelPosition = Math.max(5, Math.min(95, greenEnd));
// Delta to target range
const delta = value < min ? min - value : value > max ? value - max : 0;
const deltaText =
delta > 0
? value < min
? t("targets.belowTarget", { delta: delta.toFixed(1), unit })
: t("targets.aboveTarget", { delta: delta.toFixed(1), unit })
: t("targets.inTarget");
return (
<div className="space-y-1.5">
<div className="bg-muted/50 relative h-3 w-full overflow-hidden rounded-full">
{/* Red background (full bar) */}
<div className="absolute inset-0 rounded-full bg-red-500/8" />
{/* Yellow zones */}
<div
className="absolute top-0 h-full bg-yellow-500/12"
style={{
left: `${yellowLeftStart}%`,
width: `${greenStart - yellowLeftStart}%`,
}}
/>
<div
className="absolute top-0 h-full bg-yellow-500/12"
style={{
left: `${greenEnd}%`,
width: `${yellowRightEnd - greenEnd}%`,
}}
/>
{/* Green zone */}
<div
className="absolute top-0 h-full bg-green-500/20"
style={{
left: `${greenStart}%`,
width: `${greenEnd - greenStart}%`,
}}
/>
{/* Current value marker with tooltip */}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
className="absolute top-1/2 h-5 w-5 -translate-x-1/2 -translate-y-1/2 cursor-pointer rounded-full border-2 shadow-sm"
style={{
left: `${position}%`,
backgroundColor: markerColor,
borderColor: markerColor,
}}
/>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs font-medium">
{t("targets.currentValue", { value: String(value), unit })}
</p>
<p className="text-xs">
{t("targets.targetRangeValue", {
min: String(min),
max: String(max),
unit,
})}
</p>
<p className="text-xs font-medium">{deltaText}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="text-muted-foreground relative h-4 text-xs">
<span
className="absolute -translate-x-1/2"
style={{ left: `${minLabelPosition}%` }}
>
{min} {unit}
</span>
<span
className="absolute -translate-x-1/2"
style={{ left: `${maxLabelPosition}%` }}
>
{max} {unit}
</span>
</div>
</div>
);
}
function TargetCard({
target,
bpDiastolic,
}: {
target: TargetData;
bpDiastolic?: BpDiastolic;
}) {
const { t } = useTranslations();
const Icon = TYPE_ICONS[target.type] ?? Activity;
const iconColor = TYPE_COLORS[target.type] ?? "text-primary";
const isBp = target.type === "BLOOD_PRESSURE";
const isMedicationCompliance = target.type === "MEDICATION_COMPLIANCE";
const medicationBreakdown = target.details?.medications ?? [];
const sourceLink = getTargetSourceLink(target);
const localisedLabel = translateTargetLabel(target, t);
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Icon className={`h-4 w-4 ${iconColor}`} />
<CardTitle className="text-sm font-medium">
{localisedLabel}
</CardTitle>
</div>
<TrendIcon trend={target.trend} />
</div>
</CardHeader>
<CardContent className="flex h-full flex-col gap-4">
{/* Current value */}
{target.current != null ? (
<div className="space-y-1">
<div className="flex items-baseline gap-2">
{isBp && bpDiastolic?.current != null ? (
<>
<span className="text-3xl font-bold">
{Math.round(target.current)}
</span>
<span className="text-muted-foreground text-lg">/</span>
<span className="text-2xl font-bold">
{Math.round(bpDiastolic.current)}
</span>
</>
) : (
<span className="text-3xl font-bold">
{target.type === "BODY_FAT"
? target.current.toFixed(1)
: Math.round(target.current * 10) / 10}
</span>
)}
<span className="text-muted-foreground text-sm">
{target.unit}
</span>
</div>
{target.average30 != null && (
<p className="text-muted-foreground text-xs">
{t("targets.average30d")}{" "}
{isBp && bpDiastolic?.average30 != null
? `${Math.round(target.average30)}/${Math.round(bpDiastolic.average30)}`
: Math.round(target.average30 * 10) / 10}{" "}
{target.unit}
</p>
)}
</div>
) : (
<div className="text-muted-foreground flex items-center gap-2">
<AlertCircle className="h-4 w-4" />
<span className="text-sm">{t("targets.noMeasurementYet")}</span>
</div>
)}
{/* Range bar */}
{target.range && target.current != null && (
<RangeBar
value={target.current}
min={target.range.min}
max={target.range.max}
unit={target.unit}
orangeMin={isMedicationCompliance ? 70 : undefined}
orangeMax={isMedicationCompliance ? 100 : undefined}
/>
)}
{/* BP diastolic range bar */}
{isBp && bpDiastolic?.range && bpDiastolic.current != null && (
<div className="space-y-1">
<p className="text-muted-foreground text-xs">
{t("targets.diastolic")}
</p>
<RangeBar
value={bpDiastolic.current}
min={bpDiastolic.range.min}
max={bpDiastolic.range.max}
unit="mmHg"
/>
</div>
)}
{/* v1.4.22 C1 — sparkline + Δ-vs-last-month caption. The
sparkline collapses to a thinner trace on mobile via the
shared 24px height, and the delta caption sits on its own
line so it stays scannable next to the range bar above. */}
{target.points30d && target.points30d.length >= 2 && (
<div className="space-y-1" data-slot="target-trend">
<Sparkline points={target.points30d} />
{target.deltaVsLastMonth != null && (
<p
className="text-muted-foreground text-xs"
data-slot="target-delta"
>
{Math.abs(target.deltaVsLastMonth) < 0.05
? t("targets.deltaVsLastMonthFlat", { unit: target.unit })
: t("targets.deltaVsLastMonth", {
delta:
(target.deltaVsLastMonth > 0 ? "+" : "−") +
Math.abs(target.deltaVsLastMonth).toFixed(1),
unit: target.unit,
})}
</p>
)}
</div>
)}
{isMedicationCompliance && medicationBreakdown.length > 0 && (
<div className="space-y-1.5">
<p className="text-muted-foreground text-xs">
{t("targets.compliancePerMedication")}
</p>
<div className="space-y-1">
{medicationBreakdown.map((medication) => (
<div
key={medication.name}
className="text-muted-foreground flex items-center justify-between gap-2 text-xs"
>
<span className="truncate">{medication.name}</span>
<span className="shrink-0">
{medication.compliance7.toFixed(1)}% (7T) ·{" "}
{medication.compliance30.toFixed(1)}% (30T)
</span>
</div>
))}
</div>
</div>
)}
{/* Classification + source */}
<div className="mt-auto flex items-center justify-between gap-2">
{target.classification ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge
className="cursor-help"
style={{
backgroundColor: `${target.classification.color}20`,
color: target.classification.color,
}}
>
{translateStatus(target.classification.category, t)}
</Badge>
</TooltipTrigger>
<TooltipContent>
{target.range && (
<p className="text-xs">
{t("targets.targetRangeValue", {
min: String(target.range.min),
max: String(target.range.max),
unit: target.unit,
})}
</p>
)}
<p className="text-xs">
{t("targets.sourceLabel", { source: target.source })}
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<span className="invisible text-xs">placeholder</span>
)}
{target.type !== "MEDICATION_COMPLIANCE" ? (
sourceLink ? (
<a
href={sourceLink}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-1 text-xs"
>
<span>{target.source}</span>
<ExternalLink className="h-3 w-3" />
</a>
) : (
<span className="text-muted-foreground text-xs">
{target.source}
</span>
)
) : (
<span />
)}
</div>
</CardContent>
</Card>
);
}
export default function TargetsPage() {
const { t } = useTranslations();
const { isAuthenticated } = useAuth();
@@ -690,6 +141,58 @@ export default function TargetsPage() {
enabled: isAuthenticated,
});
// v1.4.25 W3e — share the cache with Settings → AI section. When the
// user has no configured provider, `activeProvider` is null and the
// per-card Coach CTA hides entirely (no broken-button state).
const { data: chainStatus } = useQuery({
queryKey: ["insights", "provider-chain"],
queryFn: async () => {
const res = await fetch("/api/insights/provider-chain");
if (!res.ok) return null;
const json = await res.json();
return json.data as ProviderChainStatus;
},
enabled: isAuthenticated,
});
const aiEnabled = chainStatus?.activeProvider != null;
// v1.4.25 W3e — Coach drawer state owned by the page. The per-card
// CTA + summary header both feed the same drawer so the user only
// ever sees one drawer instance.
const { coachOpen, setCoachOpen, coachPrefill, coachScope, askCoach } =
useCoachHandoff();
const visibleTargets = useMemo(() => {
if (!data) return [] as TargetData[];
const displayGlucoseUnit = resolveGlucoseUnit(
data.profile.glucoseUnit ?? null,
);
return data.targets
.filter((target) => target.current != null)
.map((target) => {
if (!GLUCOSE_TYPES.has(target.type)) return target;
// Server label is an i18n key for glucose; resolve here.
const label = t(target.label);
// Convert mg/dL canonical values to the user's display unit.
const convert = (v: number | null) =>
v == null ? null : convertGlucose(v, displayGlucoseUnit);
return {
...target,
label,
unit: displayGlucoseUnit,
current: convert(target.current),
average30: convert(target.average30),
range: target.range
? {
min: convertGlucose(target.range.min, displayGlucoseUnit),
max: convertGlucose(target.range.max, displayGlucoseUnit),
}
: null,
};
})
.sort((a, b) => sortKey(a) - sortKey(b));
}, [data, t]);
if (isLoading) {
return (
<div className="flex items-center justify-center py-20">
@@ -707,32 +210,6 @@ export default function TargetsPage() {
}
const profileIncomplete = !data.profile.heightCm || !data.profile.age;
const displayGlucoseUnit = resolveGlucoseUnit(
data.profile.glucoseUnit ?? null,
);
const visibleTargets = data.targets
.filter((target) => target.current != null)
.map((target) => {
if (!GLUCOSE_TYPES.has(target.type)) return target;
// Server label is an i18n key for glucose; resolve here.
const label = t(target.label);
// Convert mg/dL canonical values to the user's display unit.
const convert = (v: number | null) =>
v == null ? null : convertGlucose(v, displayGlucoseUnit);
return {
...target,
label,
unit: displayGlucoseUnit,
current: convert(target.current),
average30: convert(target.average30),
range: target.range
? {
min: convertGlucose(target.range.min, displayGlucoseUnit),
max: convertGlucose(target.range.max, displayGlucoseUnit),
}
: null,
};
});
return (
// v1.4.19 phase A7 — the maintainer reported "relativ viel Platz" wasted
@@ -766,9 +243,23 @@ export default function TargetsPage() {
</Card>
)}
{/* Target cards grid */}
{/* v1.4.25 W3e — page-level summary line. Renders nothing when
the API hasn't shipped pageSummary yet (older clients during
rollout / mocked test fixtures that pre-date this addition). */}
{data.pageSummary && (
<TargetsSummaryHeader
targetsMetThisWeek={data.pageSummary.targetsMetThisWeek}
totalTargets={data.pageSummary.totalTargets}
streakHighlight={data.pageSummary.streakHighlight}
/>
)}
{/* v1.4.25 W3e — responsive grid. Mobile (default): single
column. sm (640px+): two columns. lg (1024px+): three columns,
matching the dashboard / insights rhythm. Cards reflow
internally too — see <TargetCard>. */}
{visibleTargets.length > 0 ? (
<div className="grid gap-4 sm:grid-cols-2">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 lg:gap-6">
{visibleTargets.map((target) => (
<TargetCard
key={target.type}
@@ -776,6 +267,9 @@ export default function TargetsPage() {
bpDiastolic={
target.type === "BLOOD_PRESSURE" ? data.bpDiastolic : undefined
}
aiEnabled={aiEnabled}
onAskCoach={askCoach}
sourceLink={getTargetSourceLink(target)}
/>
))}
</div>
@@ -784,6 +278,16 @@ export default function TargetsPage() {
{t("targets.noMeasurementData")}
</div>
)}
{/* v1.4.25 W3e — Coach drawer mounted at the page level. The
drawer is fully-controlled; per-card CTAs flip `coachOpen`
and seed `coachPrefill` via the `askCoach()` hook callback. */}
<CoachDrawer
open={coachOpen}
onOpenChange={setCoachOpen}
prefill={coachPrefill}
key={`coach-drawer-${coachScope?.sources?.join(",") ?? "default"}`}
/>
</div>
);
}
@@ -107,4 +107,34 @@ describe("CoachFeedbackSection", () => {
expect(html).toContain("Failed to load Coach feedback summary");
expect(html).toContain('role="alert"');
});
// v1.4.25 W8 regression — Marc reported a layout shift on
// /admin/coach-feedback. Root cause was the title only rendering in the
// empty + data branches, so the section grew taller (loading → data)
// and snapped down. Lock the heading + outer-card structure across
// every render state so the section maintains a constant top-of-card
// baseline.
it("keeps the section heading rendered across loading / error / empty states", () => {
for (const queryState of [
{ isLoading: true, isError: false, data: null },
{ isLoading: false, isError: true, data: null },
{
isLoading: false,
isError: false,
data: {
generatedAt: "2026-05-10T04:00:00Z",
windowDays: 30,
coachBuckets: [],
},
},
]) {
useQueryMock.mockReturnValue(queryState);
const html = render();
// Sparkles icon + h2 title rendered in every state ⇒ constant
// header height ⇒ no layout shift when the query resolves.
expect(html).toContain("Coach Feedback");
expect(html).toContain("bg-card");
expect(html).toContain("rounded-xl");
}
});
});
@@ -0,0 +1,258 @@
import { describe, it, expect } from "vitest";
import {
auditLogCsvHeaderLabels,
buildAuditLogCsvRecords,
type AuditCsvEntry,
type AuditCsvLabels,
} from "../_shared";
import { toCSV } from "@/lib/export";
/**
* v1.4.25 W8b — Login-Übersicht CSV export contract.
*
* The previous CSV emitted snake_case English headers and the raw UTC
* `createdAt` string, which Excel/LibreOffice misread once the `Z`
* suffix was stripped. This test pins:
*
* - The column order Marc requested:
* `timestamp → user → IP → location → provider → outcome` (email
* is absent from the audit-log API so it's intentionally skipped)
* followed by `action` + `details` for triage completeness.
* - The header row uses the translated labels from the active
* locale, not the structural keys.
* - The timestamp formatter is honoured (production passes
* `formatInUserTz` with `iso-with-offset`).
* - CSV special characters in `details` are RFC 4180-escaped.
*/
const LABELS_EN: AuditCsvLabels = {
timestamp: "Time",
user: "Users",
ip: "IP",
location: "Location",
provider: "Provider",
outcome: "Outcome",
action: "Action",
details: "Details",
outcomeFailed: "Failed",
outcomeSuccess: "Success",
unknownUser: "Unknown",
providerLabels: {
password: "Password",
passkey: "Passkey",
api_token: "API token",
withings: "Withings",
unknown: "Unknown",
},
};
const LABELS_DE: AuditCsvLabels = {
...LABELS_EN,
timestamp: "Zeitpunkt",
user: "Benutzer",
ip: "IP",
location: "Standort",
provider: "Anbieter",
outcome: "Ergebnis",
action: "Aktion",
details: "Details",
outcomeFailed: "Fehlgeschlagen",
outcomeSuccess: "Erfolgreich",
unknownUser: "Unbekannt",
providerLabels: {
password: "Passwort",
passkey: "Passkey",
api_token: "API-Token",
withings: "Withings",
unknown: "Unbekannt",
},
};
function deterministicFormatter(iso: string): string {
// Strip milliseconds, swap `Z` for `+00:00` so the test asserts the
// ISO-with-offset shape without being coupled to a specific zone.
return iso.replace(/\.\d{3}Z$/, "+00:00").replace(/Z$/, "+00:00");
}
describe("buildAuditLogCsvRecords", () => {
it("emits Marc's column order: timestamp → user → ip → location → provider → outcome (+ action, details)", () => {
const entries: AuditCsvEntry[] = [
{
createdAt: "2026-05-11T09:05:00.000Z",
action: "auth.login.passkey",
ipAddress: "203.0.113.7",
location: "Berlin, DE",
details: null,
user: { id: "u1", username: "marc" },
},
];
const [record] = buildAuditLogCsvRecords(
entries,
LABELS_EN,
deterministicFormatter,
);
// Object.keys preserves insertion order in V8 / every modern JS engine
// — the column order is therefore guaranteed by `Object.keys(record)`.
expect(Object.keys(record)).toEqual([
"timestamp",
"user",
"ip",
"location",
"provider",
"outcome",
"action",
"details",
]);
});
it("maps the action to a coarse provider label using the i18n labels", () => {
const entries: AuditCsvEntry[] = [
{
createdAt: "2026-05-11T09:05:00.000Z",
action: "auth.login.passkey",
ipAddress: null,
location: null,
details: null,
user: { id: "u1", username: "marc" },
},
{
createdAt: "2026-05-11T09:06:00.000Z",
action: "auth.bearer.success",
ipAddress: null,
location: null,
details: null,
user: { id: "u2", username: "ios" },
},
{
createdAt: "2026-05-11T09:07:00.000Z",
action: "auth.login.failed",
ipAddress: null,
location: null,
details: null,
user: null,
},
];
const records = buildAuditLogCsvRecords(
entries,
LABELS_EN,
deterministicFormatter,
);
expect(records[0].provider).toBe("Passkey");
expect(records[1].provider).toBe("API token");
expect(records[2].provider).toBe("Password");
// Failed login records the failed outcome.
expect(records[0].outcome).toBe("Success");
expect(records[2].outcome).toBe("Failed");
// No user → unknown placeholder.
expect(records[2].user).toBe("Unknown");
});
it("uses the German label set when the active locale is German", () => {
const entries: AuditCsvEntry[] = [
{
createdAt: "2026-05-11T09:05:00.000Z",
action: "auth.login.passkey",
ipAddress: null,
location: null,
details: null,
user: null,
},
];
const [record] = buildAuditLogCsvRecords(
entries,
LABELS_DE,
deterministicFormatter,
);
expect(record.provider).toBe("Passkey");
expect(record.outcome).toBe("Erfolgreich");
expect(record.user).toBe("Unbekannt");
});
it("applies the injected timestamp formatter (production uses formatInUserTz)", () => {
const entries: AuditCsvEntry[] = [
{
createdAt: "2026-05-11T09:05:00.000Z",
action: "auth.login.password",
ipAddress: null,
location: null,
details: null,
user: null,
},
];
const records = buildAuditLogCsvRecords(
entries,
LABELS_EN,
(iso) => `formatted:${iso}`,
);
expect(records[0].timestamp).toBe("formatted:2026-05-11T09:05:00.000Z");
});
});
describe("auditLogCsvHeaderLabels", () => {
it("returns translated headers keyed by the record-key contract", () => {
const headers = auditLogCsvHeaderLabels(LABELS_EN);
expect(headers).toEqual({
timestamp: "Time",
user: "Users",
ip: "IP",
location: "Location",
provider: "Provider",
outcome: "Outcome",
action: "Action",
details: "Details",
});
});
});
describe("toCSV(records, headerLabels) — audit-log integration", () => {
it("writes a translated header row + correct column order", () => {
const entries: AuditCsvEntry[] = [
{
createdAt: "2026-05-11T09:05:00.000Z",
action: "auth.login.passkey",
ipAddress: "203.0.113.7",
location: "Berlin, DE",
details: null,
user: { id: "u1", username: "marc" },
},
];
const records = buildAuditLogCsvRecords(
entries,
LABELS_DE,
deterministicFormatter,
);
const csv = toCSV(records, auditLogCsvHeaderLabels(LABELS_DE));
const lines = csv.split("\n");
expect(lines[0]).toBe(
"Zeitpunkt,Benutzer,IP,Standort,Anbieter,Ergebnis,Aktion,Details",
);
expect(lines[1]).toBe(
'2026-05-11T09:05:00+00:00,marc,203.0.113.7,"Berlin, DE",Passkey,Erfolgreich,auth.login.passkey,',
);
});
it("escapes commas, quotes, and newlines in the details column", () => {
const entries: AuditCsvEntry[] = [
{
createdAt: "2026-05-11T09:05:00.000Z",
action: "auth.login.failed",
ipAddress: null,
location: null,
// Three RFC 4180 escape triggers in one value: a comma, a
// double quote, and a newline.
details: 'reason="invalid_password", attempts: 3\nfollowup: lock',
user: null,
},
];
const records = buildAuditLogCsvRecords(
entries,
LABELS_EN,
deterministicFormatter,
);
const csv = toCSV(records, auditLogCsvHeaderLabels(LABELS_EN));
// The cell is wrapped in double quotes and embedded `"` is doubled.
expect(csv).toContain(
'"reason=""invalid_password"", attempts: 3\nfollowup: lock"',
);
});
});
@@ -0,0 +1,94 @@
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
/**
* v1.4.25 W8b — `/admin/login-overview` no longer renders a Collapse
* / Expand toggle. The toggle was a leftover from the v1.4 shared
* admin page where 13 sections lived on one route; collapsing the
* only card on the dedicated login-overview route hid the entire
* surface. Visiting the page is itself the opt-in.
*/
vi.mock("next/navigation", () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/admin/login-overview",
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({
data: {
entries: [],
meta: { total: 0, limit: 50, offset: 0, page: 1, perPage: 50 },
},
isLoading: false,
isError: false,
refetch: vi.fn(),
}),
useQueryClient: () => ({ invalidateQueries: vi.fn() }),
useMutation: () => ({
mutate: vi.fn(),
isPending: false,
isError: false,
error: null,
}),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "marc",
email: "marc@example.com",
role: "ADMIN",
timezone: "Europe/Berlin",
},
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
}));
import { I18nProvider } from "@/lib/i18n/context";
import { LoginOverviewSection } from "../login-overview-section";
function render(locale: "en" | "de" = "en") {
return renderToStaticMarkup(
<I18nProvider initialLocale={locale}>
<LoginOverviewSection />
</I18nProvider>,
);
}
describe("LoginOverviewSection — no collapse button", () => {
it("does not render a Collapse / Expand button (English)", () => {
const html = render("en");
expect(html).not.toMatch(/\bCollapse\b/);
expect(html).not.toMatch(/\bExpand\b/);
});
it("does not render an Einklappen / Ausklappen button (German)", () => {
const html = render("de");
expect(html).not.toMatch(/\bEinklappen\b/);
expect(html).not.toMatch(/\bAusklappen\b/);
});
it("never renders an `aria-expanded` button on the section header", () => {
const html = render();
// The toggle previously surfaced as <button aria-expanded={…}>
// immediately after the ScrollText icon + title pair. Radix
// Select triggers below carry `aria-expanded` legitimately, so
// we scope the assertion to the first 800 chars of the markup
// (the heading slice) instead of the entire SSR output.
const heading = html.slice(0, 800);
expect(heading).not.toMatch(/aria-expanded=/);
});
it("paints the section body unconditionally (no `expanded` gate)", () => {
// The filter pills used to live inside an `{expanded && (…)}`
// wrapper. With the toggle gone the pills must always be in the
// SSR output.
const html = render("en");
expect(html).toContain("All auth events");
expect(html).toContain("Failed only");
});
});
@@ -0,0 +1,68 @@
import { describe, it, expect } from "vitest";
import { Cpu, Fingerprint, Globe, KeyRound } from "lucide-react";
import { iconForAuthProvider, providerForAction } from "../_shared";
/**
* v1.4.25 W8b — Provider column for the admin Login-Übersicht.
*
* The audit-log entries don't carry a dedicated "provider" field — the
* action name itself is the source of truth. These tests pin the
* coarse mapping that drives the Provider column (and any later
* surface that reuses `useAuthProviderLabels`). New `auth.*` actions
* that land in the audit log must be wired into `providerForAction`
* with a corresponding regression case here.
*/
describe("providerForAction", () => {
it("classifies passkey actions as `passkey`", () => {
expect(providerForAction("auth.login.passkey")).toBe("passkey");
expect(providerForAction("auth.passkey.register")).toBe("passkey");
expect(providerForAction("auth.passkey.delete")).toBe("passkey");
});
it("classifies password actions and failed sign-ins as `password`", () => {
expect(providerForAction("auth.login.password")).toBe("password");
expect(providerForAction("auth.password.change")).toBe("password");
// Failed logins flow through the password endpoint; treating them
// as `password` is the truthful summary of how the credential was
// offered. A failed passkey attempt has its own audit row.
expect(providerForAction("auth.login.failed")).toBe("password");
});
it("classifies bearer + native-token actions as `api_token`", () => {
expect(providerForAction("auth.bearer.success")).toBe("api_token");
expect(providerForAction("auth.bearer.failure")).toBe("api_token");
expect(providerForAction("auth.token.autoissue.native")).toBe("api_token");
expect(providerForAction("auth.token.refresh")).toBe("api_token");
expect(providerForAction("auth.token.refresh.failed")).toBe("api_token");
expect(providerForAction("auth.token.refresh.revoke")).toBe("api_token");
expect(providerForAction("auth.token.revoke")).toBe("api_token");
});
it("classifies Withings OAuth events as `withings`", () => {
expect(providerForAction("auth.withings.connect")).toBe("withings");
expect(providerForAction("auth.withings.disconnect")).toBe("withings");
});
it("falls back to `unknown` for unmapped or generic auth actions", () => {
expect(providerForAction("auth.login")).toBe("unknown");
expect(providerForAction("auth.logout")).toBe("unknown");
expect(providerForAction("auth.register")).toBe("unknown");
expect(providerForAction("admin.user.delete")).toBe("unknown");
expect(providerForAction("")).toBe("unknown");
});
});
describe("iconForAuthProvider", () => {
it("maps each provider to the documented lucide glyph", () => {
// Marc's brief pinned the exact icon for each provider so the
// visual language stays consistent across the admin surface.
expect(iconForAuthProvider("password")).toBe(KeyRound);
expect(iconForAuthProvider("passkey")).toBe(Fingerprint);
expect(iconForAuthProvider("api_token")).toBe(Cpu);
expect(iconForAuthProvider("withings")).toBe(Globe);
// `unknown` reuses the OAuth glyph rather than a `?` so the
// column never paints a missing icon.
expect(iconForAuthProvider("unknown")).toBe(Globe);
});
});
+187
View File
@@ -3,6 +3,7 @@
import { useMemo } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { Cpu, Fingerprint, Globe, KeyRound } from "lucide-react";
import { Switch } from "@/components/ui/switch";
import { useTranslations } from "@/lib/i18n/context";
import { PasswordInput as SettingsPasswordInput } from "@/components/settings/password-input";
@@ -81,6 +82,9 @@ export interface AdminSettings {
bugReportEnabled: boolean;
reminderLateMinutes: number;
reminderMissedMinutes: number;
// v1.4.25 W7 — null means "fall back to Europe/Berlin in the resolver".
defaultUserTimezone: string | null;
moodLogGlobal?: boolean;
}
export interface AdminAuditEntry {
@@ -286,6 +290,189 @@ export function useAuthActionLabels(): Record<string, string> {
);
}
/**
* v1.4.25 W8b — Provider column for the Login-Übersicht.
*
* The audit log doesn't have a dedicated `provider` field; the action
* name itself encodes how the login happened (`auth.login.passkey`,
* `auth.bearer.success`, …). We derive a coarse provider tag from
* the action so admins can see at a glance whether each row is a
* password, passkey, API token, or OAuth (Withings) event.
*
* Keep the mapping exhaustive — every `auth.*` action that lands in
* the audit table must resolve to one of `password | passkey |
* api_token | withings | unknown`. New actions are wired here once,
* mirroring the convention established by `useAuthActionLabels`.
*/
export type AuthProvider =
| "password"
| "passkey"
| "api_token"
| "withings"
| "unknown";
export function providerForAction(action: string): AuthProvider {
if (
action === "auth.login.passkey" ||
action === "auth.passkey.register" ||
action === "auth.passkey.delete"
) {
return "passkey";
}
if (
action === "auth.login.password" ||
action === "auth.password.change" ||
// Failed sign-ins go through the password endpoint — the passkey
// flow has its own `auth.login.failed` row but `details.reason`
// disambiguates further; for the column tag, "password" is the
// truthful summary of how the credential was offered.
action === "auth.login.failed"
) {
return "password";
}
if (
action === "auth.bearer.success" ||
action === "auth.bearer.failure" ||
action === "auth.token.autoissue.native" ||
action === "auth.token.refresh" ||
action === "auth.token.refresh.failed" ||
action === "auth.token.refresh.revoke" ||
action === "auth.token.revoke"
) {
return "api_token";
}
if (action.startsWith("auth.withings")) {
return "withings";
}
return "unknown";
}
const AUTH_PROVIDER_ICONS = {
password: KeyRound,
passkey: Fingerprint,
api_token: Cpu,
withings: Globe,
unknown: Globe,
} as const;
export function iconForAuthProvider(
provider: AuthProvider,
): React.ComponentType<{ className?: string }> {
return AUTH_PROVIDER_ICONS[provider];
}
/**
* i18n label map for the Provider column. Keys match `AuthProvider`
* so callers can index directly: `labels[providerForAction(action)]`.
*/
export function useAuthProviderLabels(): Record<AuthProvider, string> {
const { t, locale } = useTranslations();
return useMemo(
() => ({
password: t("admin.providerPassword"),
passkey: t("admin.providerPasskey"),
api_token: t("admin.providerApiToken"),
withings: t("admin.providerWithings"),
unknown: t("admin.providerUnknown"),
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[locale],
);
}
/**
* v1.4.25 W8b — Build the CSV record set for the audit-log export.
*
* Pulled out of the section component so the column order, header
* mapping, and provider/outcome derivation are reachable from a
* pure unit test without rendering the React tree. Marc's spec pins
* the column order at `timestamp → user → IP → location → provider
* → outcome` (email is absent from the audit-log API and would
* require schema/API changes that are out of scope). `action` and
* `details` follow so the export keeps the full triage payload.
*
* `formatTimestamp` is injected (not imported) so the production
* callsite passes the user-tz formatter (`formatInUserTz`) while the
* unit test passes a deterministic stub.
*/
export interface AuditCsvEntry {
createdAt: string;
action: string;
ipAddress: string | null;
location: string | null;
details: string | null;
user: { id: string; username: string } | null;
}
export interface AuditCsvLabels {
timestamp: string;
user: string;
ip: string;
location: string;
provider: string;
outcome: string;
action: string;
details: string;
outcomeFailed: string;
outcomeSuccess: string;
unknownUser: string;
providerLabels: Record<AuthProvider, string>;
}
export interface AuditCsvRecord {
timestamp: string;
user: string;
ip: string;
location: string;
provider: string;
outcome: string;
action: string;
details: string;
// Index signature so the record satisfies the structural
// `ExportableRecord` contract used by `toCSV`. Fixed columns above
// still drive the order of the emitted CSV.
[key: string]: string;
}
export function buildAuditLogCsvRecords(
entries: AuditCsvEntry[],
labels: AuditCsvLabels,
formatTimestamp: (iso: string) => string,
): AuditCsvRecord[] {
return entries.map((entry) => {
const provider = providerForAction(entry.action);
const isFailed =
entry.action === "auth.login.failed" ||
entry.action === "auth.bearer.failure" ||
entry.action === "auth.token.refresh.failed";
return {
timestamp: formatTimestamp(entry.createdAt),
user: entry.user?.username ?? labels.unknownUser,
ip: entry.ipAddress ?? "",
location: entry.location ?? "",
provider: labels.providerLabels[provider],
outcome: isFailed ? labels.outcomeFailed : labels.outcomeSuccess,
action: entry.action,
details: entry.details ?? "",
};
});
}
export function auditLogCsvHeaderLabels(
labels: AuditCsvLabels,
): Record<keyof AuditCsvRecord, string> {
return {
timestamp: labels.timestamp,
user: labels.user,
ip: labels.ip,
location: labels.location,
provider: labels.provider,
outcome: labels.outcome,
action: labels.action,
details: labels.details,
};
}
export function useSystemStatus() {
return useQuery({
queryKey: ["admin", "status"],
Loaded 100 of 528 files, more files were not shown because too many files have changed in this diff. Show more