mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
chore(release): v1.4.25
Largest feature delta in the v1.4.x line. Insights expands into seven dedicated metric sub-pages; GLP-1 medication tracking lands end-to-end (drug-level chart with Research Mode acknowledgment, EMA titration ladder, cadence + compliance, side-effect taxonomy, pen/vial inventory); cross-source priority becomes a two-axis resolver; per-user timezone threads through ten analytics surfaces; Withings activity + sleep v2 sync; onboarding rebuilt as a nested-route wizard; Personal Records ship end-to-end; Health Score provenance accordion; Coach runs native first-party prompts across six locales with a 1800-assertion refusal-probe matrix; OpenAPI drift gate hard-fails; multi-arch GHCR image. Migrations 0043 through 0060 are additive and forward-only. PROMPT_VERSION 4.24.0 → 4.25.0 with GROUND RULE 9 (refuses dose recommendations) and GROUND RULE 15 (refuses drug-level estimates with MDR + MDCG 2021-24 cites). See CHANGELOG.md for the full per-section delta.
This commit is contained in:
1 parent
de0689e419
commit
17b8d8d2c2
528 files changed
+104033
-6524
No files matched your search
@@ -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"');
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,10 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { adminSettingsSchema } from "@/lib/validations/admin";
|
||||
import { NextRequest } from "next/server";
|
||||
import {
|
||||
invalidateServerDefaultTimezone,
|
||||
isValidTimezone,
|
||||
} from "@/lib/tz/resolver";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -50,6 +54,10 @@ export const GET = apiHandler(async () => {
|
||||
reminderLateMinutes: settings?.reminderLateMinutes ?? 120,
|
||||
reminderMissedMinutes: settings?.reminderMissedMinutes ?? 240,
|
||||
moodLogGlobal: settings?.moodLogGlobal ?? true,
|
||||
// v1.4.25 W7 — null means "fall back to Europe/Berlin in the
|
||||
// resolver"; surfacing the raw value lets the admin UI render
|
||||
// an empty picker placeholder until they opt in.
|
||||
defaultUserTimezone: settings?.defaultUserTimezone ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -186,6 +194,26 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
auditDetails.reminderMissedMinutes = data.reminderMissedMinutes;
|
||||
}
|
||||
|
||||
// v1.4.25 W7 — server-default timezone for new signups.
|
||||
// Empty string clears the override (resolver falls back to
|
||||
// Europe/Berlin); a non-empty string must pass Intl validation
|
||||
// upstream of the column write.
|
||||
let didTouchTimezone = false;
|
||||
if (data.defaultUserTimezone !== undefined) {
|
||||
const trimmed = data.defaultUserTimezone.trim();
|
||||
if (trimmed === "") {
|
||||
updates.defaultUserTimezone = null;
|
||||
auditDetails.defaultUserTimezone = null;
|
||||
didTouchTimezone = true;
|
||||
} else if (isValidTimezone(trimmed)) {
|
||||
updates.defaultUserTimezone = trimmed;
|
||||
auditDetails.defaultUserTimezone = trimmed;
|
||||
didTouchTimezone = true;
|
||||
} else {
|
||||
return apiError("Not a valid IANA timezone.", 422);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0) {
|
||||
return apiError("No valid fields", 422);
|
||||
}
|
||||
@@ -196,6 +224,10 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
create: { id: "singleton", ...updates },
|
||||
});
|
||||
|
||||
if (didTouchTimezone) {
|
||||
invalidateServerDefaultTimezone();
|
||||
}
|
||||
|
||||
await auditLog("admin.settings.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
@@ -230,5 +262,6 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
reminderLateMinutes: settings.reminderLateMinutes,
|
||||
reminderMissedMinutes: settings.reminderMissedMinutes,
|
||||
moodLogGlobal: settings.moodLogGlobal,
|
||||
defaultUserTimezone: settings.defaultUserTimezone,
|
||||
});
|
||||
});
|
||||
@@ -44,9 +44,17 @@ vi.mock("@/lib/jobs/worker-status", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
// v1.4.25 W21 Fix-K — `/api/admin/status` now reads the legacy-form
|
||||
// counter from the Withings webhook module. Mock it at module boundary
|
||||
// so the test fixture stays deterministic.
|
||||
vi.mock("@/app/api/withings/webhook/route", () => ({
|
||||
getLegacyFormTotal: vi.fn(() => 0),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireAdmin, HttpError } from "@/lib/api-handler";
|
||||
import { getLegacyFormTotal } from "@/app/api/withings/webhook/route";
|
||||
|
||||
const ADMIN_CTX = {
|
||||
session: { id: "s1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
@@ -169,4 +177,13 @@ describe("GET /api/admin/status (integration health summary)", () => {
|
||||
const tokenCallArgs = vi.mocked(prisma.apiToken.count).mock.calls[0]?.[0];
|
||||
expect(tokenCallArgs).toEqual({ where: { revoked: false } });
|
||||
});
|
||||
|
||||
it("surfaces the legacy Withings webhook counter on the counters block (Fix-K sec-M2)", async () => {
|
||||
vi.mocked(getLegacyFormTotal).mockReturnValue(7);
|
||||
const res = await GET();
|
||||
const body = (await res.json()) as {
|
||||
data: { counters: { withingsWebhookLegacyFormTotal: number } };
|
||||
};
|
||||
expect(body.data.counters.withingsWebhookLegacyFormTotal).toBe(7);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { getWorkerStatus } from "@/lib/jobs/worker-status";
|
||||
import { getLegacyFormTotal } from "@/app/api/withings/webhook/route";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
// Build timestamp (set at build time via next.config)
|
||||
@@ -89,5 +90,11 @@ export const GET = apiHandler(async () => {
|
||||
webPush: webPushConfigured ? { configured: true } : null,
|
||||
bugReport: bugReportConfigured ? { configured: true } : null,
|
||||
},
|
||||
// v1.4.25 W21 Fix-K — in-memory counters that the release-gate can
|
||||
// read to confirm "legacy form usage trending toward zero" before
|
||||
// the v1.4.27 cut of the legacy Withings webhook route.
|
||||
counters: {
|
||||
withingsWebhookLegacyFormTotal: getLegacyFormTotal(),
|
||||
},
|
||||
});
|
||||
});
|
||||
+282
-60
@@ -5,11 +5,12 @@ import { apiSuccess } from "@/lib/api-response";
|
||||
import { summarize, type DataPoint } from "@/lib/analytics/trends";
|
||||
import { getBpTargets } from "@/lib/analytics/bp-targets";
|
||||
import { computeBpInTargetWindows } from "@/lib/analytics/bp-in-target";
|
||||
import { berlinDayKey } from "@/lib/analytics/berlin-day";
|
||||
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import { calculateCompliance } from "@/lib/analytics/compliance";
|
||||
import {
|
||||
computeHealthScore,
|
||||
defaultWeightTargetFromHeight,
|
||||
type ContributingSource,
|
||||
type HealthScoreInput,
|
||||
type HealthScoreResult,
|
||||
} from "@/lib/analytics/health-score";
|
||||
@@ -19,8 +20,12 @@ import {
|
||||
correlateWeightWeekday,
|
||||
type CorrelationResult,
|
||||
} from "@/lib/insights/correlations";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import type {
|
||||
MeasurementSource,
|
||||
MeasurementType,
|
||||
} from "@/generated/prisma/client";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -28,6 +33,14 @@ export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "analytics.get" } });
|
||||
|
||||
// v1.4.25 W7b — every day-bucket call inside this route now honours
|
||||
// the user's display timezone. The legacy `berlinDayKey()` import
|
||||
// remains for sleep-stage and correlation paths that share their
|
||||
// helper signature with non-tz-aware code (`computeSleepStageBreakdown`
|
||||
// is called with a userId only); the per-call sites below all pass
|
||||
// `userTz` through `userDayKey()`.
|
||||
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
|
||||
// Derived from canonical enum so a new measurement type is auto-summarised
|
||||
// by /api/analytics (V3 audit: enum drift cousins).
|
||||
const types = [...measurementTypeEnum.options] as MeasurementType[];
|
||||
@@ -38,6 +51,12 @@ export const GET = apiHandler(async () => {
|
||||
// sync history. `summarize()` requires the full series (slope7/30/90,
|
||||
// anomalies) so groupBy cannot replace this read; the chunked path is
|
||||
// the smallest pagination contract that still satisfies the helper.
|
||||
// v1.4.25 W5e — per-metric-class source priority pulled once. The
|
||||
// aggregator passes the persisted JSON straight through so the
|
||||
// helper's `parseSourcePriority` runs and falls back to defaults
|
||||
// when the column is null or malformed.
|
||||
const sourcePriorityJson = user.sourcePriorityJson;
|
||||
|
||||
let totalRowsReadForAggregate = 0;
|
||||
const measurementsByType = await Promise.all(
|
||||
types.map((type) =>
|
||||
@@ -53,9 +72,21 @@ export const GET = apiHandler(async () => {
|
||||
// = total minutes asleep).
|
||||
let datapoints: DataPoint[];
|
||||
if (type === "SLEEP_DURATION") {
|
||||
// v1.4.25 W5e — pick ONE source per day before summing the
|
||||
// night's stages. With only WITHINGS + MANUAL today, the
|
||||
// picker passes everything through; once iOS passthrough
|
||||
// lands (v1.5) the picker prevents double-counted nights
|
||||
// (HealthKit forwards Withings' Sleep summary to iOS in
|
||||
// addition to ScanWatch's own stream).
|
||||
const sleepRows = pickCanonicalSourceRows(
|
||||
measurements,
|
||||
"sleep",
|
||||
sourcePriorityJson,
|
||||
(d) => userDayKey(d, userTz),
|
||||
).canonicalRows;
|
||||
const byDay = new Map<string, { total: number; date: Date }>();
|
||||
for (const m of measurements) {
|
||||
const key = berlinDayKey(m.measuredAt);
|
||||
for (const m of sleepRows) {
|
||||
const key = userDayKey(m.measuredAt, userTz);
|
||||
const slot = byDay.get(key) ?? {
|
||||
total: 0,
|
||||
date: m.measuredAt,
|
||||
@@ -104,7 +135,7 @@ export const GET = apiHandler(async () => {
|
||||
// included when the user has stage-tagged rows in window; null
|
||||
// otherwise so the UI can render a plain total without the
|
||||
// breakdown card painting empty.
|
||||
const sleepStages = await computeSleepStageBreakdown(user.id);
|
||||
const sleepStages = await computeSleepStageBreakdown(user.id, userTz);
|
||||
|
||||
// BMI calculation
|
||||
let bmi: number | null = null;
|
||||
@@ -202,7 +233,7 @@ export const GET = apiHandler(async () => {
|
||||
// floor from 14 → 20). Each runner gates on n >= 20 + p < 0.05;
|
||||
// below the bar the result.status === "insufficient" and the UI
|
||||
// paints an EmptyState.
|
||||
const correlations = await computeCorrelationHypotheses(user.id);
|
||||
const correlations = await computeCorrelationHypotheses(user.id, userTz);
|
||||
|
||||
// v1.4.20 phase B5 — Personal Health Score. Server-deterministic
|
||||
// composite of BP-in-target % + weight-trend alignment + mood
|
||||
@@ -248,12 +279,21 @@ export const GET = apiHandler(async () => {
|
||||
* `summaries.SLEEP_DURATION` totals without a stage card in that
|
||||
* case. Returns the sum-per-stage AND the day count covered so the
|
||||
* UI can render an "averaged across N nights" caption truthfully.
|
||||
*
|
||||
* v1.4.25 W3f — also returns a `perNight` array: one entry per
|
||||
* Berlin-tz day inside the window, with minutes-per-stage for the
|
||||
* stacked-bar chart. Days with zero stage-tagged rows are omitted
|
||||
* from `perNight` (the chart treats them as gaps).
|
||||
*/
|
||||
async function computeSleepStageBreakdown(userId: string): Promise<{
|
||||
async function computeSleepStageBreakdown(
|
||||
userId: string,
|
||||
userTz: string,
|
||||
): Promise<{
|
||||
windowDays: number;
|
||||
nights: number;
|
||||
totalMinutes: number;
|
||||
stages: Record<string, number>;
|
||||
perNight: Array<{ dayKey: string; stages: Record<string, number> }>;
|
||||
} | null> {
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const since = new Date(Date.now() - 30 * DAY_MS);
|
||||
@@ -272,18 +312,33 @@ async function computeSleepStageBreakdown(userId: string): Promise<{
|
||||
const stages: Record<string, number> = {};
|
||||
const dayKeys = new Set<string>();
|
||||
let totalMinutes = 0;
|
||||
// v1.4.25 W3f — per-day accumulator. Keyed by Berlin-tz day so the
|
||||
// chart's 7/14/30-day slicer can build a left-aligned series.
|
||||
const perNightMap = new Map<string, Record<string, number>>();
|
||||
for (const row of rows) {
|
||||
if (!row.sleepStage) continue;
|
||||
stages[row.sleepStage] = (stages[row.sleepStage] ?? 0) + row.value;
|
||||
totalMinutes += row.value;
|
||||
dayKeys.add(berlinDayKey(row.measuredAt));
|
||||
const dayKey = userDayKey(row.measuredAt, userTz);
|
||||
dayKeys.add(dayKey);
|
||||
const nightStages = perNightMap.get(dayKey) ?? {};
|
||||
nightStages[row.sleepStage] =
|
||||
(nightStages[row.sleepStage] ?? 0) + row.value;
|
||||
perNightMap.set(dayKey, nightStages);
|
||||
}
|
||||
|
||||
// Sort per-night ascending so the chart consumer can slice the
|
||||
// trailing N entries for the 7d / 14d / 30d toggle.
|
||||
const perNight = Array.from(perNightMap.entries())
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([dayKey, stages]) => ({ dayKey, stages }));
|
||||
|
||||
return {
|
||||
windowDays: 30,
|
||||
nights: dayKeys.size,
|
||||
totalMinutes,
|
||||
stages,
|
||||
perNight,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -295,7 +350,10 @@ async function computeSleepStageBreakdown(userId: string): Promise<{
|
||||
* because the user-facing "based on N paired readings · last 30 days"
|
||||
* source-chip has to remain truthful.
|
||||
*/
|
||||
async function computeCorrelationHypotheses(userId: string): Promise<{
|
||||
async function computeCorrelationHypotheses(
|
||||
userId: string,
|
||||
userTz: string,
|
||||
): Promise<{
|
||||
bpCompliance: CorrelationResult;
|
||||
moodPulse: CorrelationResult;
|
||||
weightWeekday: CorrelationResult;
|
||||
@@ -324,10 +382,10 @@ async function computeCorrelationHypotheses(userId: string): Promise<{
|
||||
]);
|
||||
|
||||
// ── Hypothesis 1: BP × medication compliance ────────────────
|
||||
// Aggregate by Berlin-day key so DST + UTC boundary issues don't split
|
||||
// a day's readings. The day's "compliance %" is taken / expected for
|
||||
// that calendar day across all medications.
|
||||
const dayKey = (d: Date): string => berlinDayKey(d);
|
||||
// Aggregate by the user's display-tz day key so DST + UTC boundary
|
||||
// issues don't split a day's readings. The day's "compliance %" is
|
||||
// taken / expected for that calendar day across all medications.
|
||||
const dayKey = (d: Date): string => userDayKey(d, userTz);
|
||||
|
||||
const dailySys = new Map<string, number[]>();
|
||||
for (const row of sysRows) {
|
||||
@@ -360,7 +418,7 @@ async function computeCorrelationHypotheses(userId: string): Promise<{
|
||||
const compliancePct = (slot.taken / slot.expected) * 100;
|
||||
const meanSys = sysValues.reduce((s, v) => s + v, 0) / sysValues.length;
|
||||
bpCompliancePairs.push({
|
||||
date: dateFromBerlinKey(key),
|
||||
date: dateFromDayKey(key),
|
||||
systolic: meanSys,
|
||||
compliancePct,
|
||||
});
|
||||
@@ -373,14 +431,14 @@ async function computeCorrelationHypotheses(userId: string): Promise<{
|
||||
// resting-pulse field, so we accept the noise rather than skip.
|
||||
const dailyMood = new Map<string, number[]>();
|
||||
for (const row of moodRows) {
|
||||
const key = berlinDayKey(row.moodLoggedAt);
|
||||
const key = userDayKey(row.moodLoggedAt, userTz);
|
||||
const list = dailyMood.get(key) ?? [];
|
||||
list.push(row.score);
|
||||
dailyMood.set(key, list);
|
||||
}
|
||||
const dailyPulse = new Map<string, number[]>();
|
||||
for (const row of pulseRows) {
|
||||
const key = berlinDayKey(row.measuredAt);
|
||||
const key = userDayKey(row.measuredAt, userTz);
|
||||
const list = dailyPulse.get(key) ?? [];
|
||||
list.push(row.value);
|
||||
dailyPulse.set(key, list);
|
||||
@@ -397,7 +455,7 @@ async function computeCorrelationHypotheses(userId: string): Promise<{
|
||||
const meanPulse =
|
||||
pulseValues.reduce((s, v) => s + v, 0) / pulseValues.length;
|
||||
moodPulsePairs.push({
|
||||
date: dateFromBerlinKey(key),
|
||||
date: dateFromDayKey(key),
|
||||
mood: meanMood,
|
||||
restingPulse: meanPulse,
|
||||
});
|
||||
@@ -406,9 +464,17 @@ async function computeCorrelationHypotheses(userId: string): Promise<{
|
||||
|
||||
// ── Hypothesis 3: Weight × weekday ──────────────────────────
|
||||
// 0 = Monday … 6 = Sunday. ISO weekday minus 1.
|
||||
//
|
||||
// v1.4.25 W10 reconcile (Code-H1) — buckets the weekday by the
|
||||
// user's display tz, matching W7's per-user-tz threading that the
|
||||
// BP, mood, sleep, and pulse aggregators above already honour. The
|
||||
// pre-W10 helper was pinned to `Europe/Berlin` and would land a
|
||||
// user-local 23:30 weight reading from `Pacific/Auckland` under
|
||||
// Sunday's bucket instead of Monday's — Pearson on the wrong
|
||||
// weekday column.
|
||||
const weightWeekdayPairs: Array<{ weekday: number; weight: number }> = [];
|
||||
for (const row of weightRows) {
|
||||
const isoWeekday = berlinIsoWeekday(row.measuredAt); // 1..7, 1=Mon
|
||||
const isoWeekday = isoWeekdayInTz(row.measuredAt, userTz); // 1..7, 1=Mon
|
||||
weightWeekdayPairs.push({
|
||||
weekday: isoWeekday - 1,
|
||||
weight: row.value,
|
||||
@@ -464,6 +530,22 @@ interface ChunkedRow {
|
||||
measuredAt: Date;
|
||||
value: number;
|
||||
sleepStage: string | null;
|
||||
/** v1.4.25 W5e — needed by `pickCanonicalSourceRows` so the SLEEP /
|
||||
* cumulative aggregators can pick ONE source per day when more than
|
||||
* one ingest path contributes to the same metric. */
|
||||
source: MeasurementSource;
|
||||
/** v1.4.25 W8c — second axis of the canonical picker. Nullable until
|
||||
* the iOS app starts shipping HKDevice.model with each sample;
|
||||
* legacy / Withings rows stay NULL and the picker treats them as
|
||||
* `unknown`. Carried on every type's read so the cumulative-metric
|
||||
* path can break Apple-Watch-vs-iPhone ties.
|
||||
*/
|
||||
deviceType: string | null;
|
||||
/** v1.4.25 W8c — feeds the per-MeasurementType device-type override
|
||||
* inside the picker. The picker keys
|
||||
* `deviceTypePriority[type]` off this so the user's "phone wins for
|
||||
* steps but watch wins for HR" config is honoured. */
|
||||
type: MeasurementType;
|
||||
}
|
||||
|
||||
async function fetchMeasurementSeriesChunked(
|
||||
@@ -485,6 +567,11 @@ async function fetchMeasurementSeriesChunked(
|
||||
id: true,
|
||||
measuredAt: true,
|
||||
value: true,
|
||||
source: true,
|
||||
// v1.4.25 W8c — read deviceType so the canonical picker can
|
||||
// honour the per-metric / per-device override. Nullable until
|
||||
// iOS sends it.
|
||||
deviceType: true,
|
||||
...(options.includeSleepStage ? { sleepStage: true } : {}),
|
||||
},
|
||||
take: MEASUREMENT_CHUNK_SIZE,
|
||||
@@ -495,6 +582,9 @@ async function fetchMeasurementSeriesChunked(
|
||||
out.push({
|
||||
measuredAt: row.measuredAt,
|
||||
value: row.value,
|
||||
source: row.source,
|
||||
deviceType: row.deviceType ?? null,
|
||||
type,
|
||||
sleepStage:
|
||||
"sleepStage" in row
|
||||
? ((row.sleepStage as string | null) ?? null)
|
||||
@@ -511,18 +601,34 @@ async function fetchMeasurementSeriesChunked(
|
||||
// `src/lib/analytics/berlin-day.ts` so the targets route's sparkline
|
||||
// bucketing shares the same Europe/Berlin contract. The
|
||||
// `weekday: "short"` formatter still lives here because it's only
|
||||
// used by `berlinIsoWeekday()` below.
|
||||
const BERLIN_DATE_PARTS = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
weekday: "short",
|
||||
});
|
||||
// used by `isoWeekdayInTz()` below.
|
||||
//
|
||||
// v1.4.25 W10 reconcile (Code-H1) — formatter is now per-tz so the
|
||||
// weight-weekday correlator honours the same per-user-tz contract the
|
||||
// BP/mood/pulse aggregators above already use. Memoised by tz so the
|
||||
// formatter is built once per unique timezone per process (tz strings
|
||||
// rarely change at runtime; the cache is bounded by the IANA database
|
||||
// to a few hundred entries even in the worst case).
|
||||
const WEEKDAY_FORMATTER_CACHE = new Map<string, Intl.DateTimeFormat>();
|
||||
function getWeekdayFormatter(timeZone: string): Intl.DateTimeFormat {
|
||||
let formatter = WEEKDAY_FORMATTER_CACHE.get(timeZone);
|
||||
if (!formatter) {
|
||||
formatter = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
weekday: "short",
|
||||
});
|
||||
WEEKDAY_FORMATTER_CACHE.set(timeZone, formatter);
|
||||
}
|
||||
return formatter;
|
||||
}
|
||||
|
||||
function dateFromBerlinKey(key: string): Date {
|
||||
function dateFromDayKey(key: string): Date {
|
||||
// Anchor to UTC midnight — the date is a sortable bucket label rather
|
||||
// than a wall-clock timestamp, so DST drift is irrelevant.
|
||||
// than a wall-clock timestamp, so DST drift is irrelevant. The tz
|
||||
// info is already baked into the key (built via `userDayKey` upstream).
|
||||
return new Date(`${key}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
@@ -536,12 +642,58 @@ const ISO_WEEKDAY: Record<string, number> = {
|
||||
Sun: 7,
|
||||
};
|
||||
|
||||
function berlinIsoWeekday(d: Date): number {
|
||||
const parts = BERLIN_DATE_PARTS.formatToParts(d);
|
||||
function isoWeekdayInTz(d: Date, timeZone: string): number {
|
||||
const parts = getWeekdayFormatter(timeZone).formatToParts(d);
|
||||
const weekday = parts.find((p) => p.type === "weekday")?.value ?? "Mon";
|
||||
return ISO_WEEKDAY[weekday] ?? 1;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* v1.4.25 W8e — collapse the persisted `MeasurementSource` enum onto
|
||||
* the camelCase token set the health-score analytics layer consumes.
|
||||
*
|
||||
* - `MANUAL` and `IMPORT` (CSV import — still user-supplied data) both
|
||||
* surface as `"manual"`.
|
||||
* - `WITHINGS` and `APPLE_HEALTH` ride one-to-one.
|
||||
*
|
||||
* Returns `null` for source values that don't fall into the three
|
||||
* exposed buckets — defence-in-depth in case the enum grows before the
|
||||
* client is taught about it.
|
||||
*/
|
||||
function mapMeasurementSourceToLabel(
|
||||
source: MeasurementSource,
|
||||
): ContributingSource | null {
|
||||
switch (source) {
|
||||
case "MANUAL":
|
||||
case "IMPORT":
|
||||
return "manual";
|
||||
case "WITHINGS":
|
||||
return "withings";
|
||||
case "APPLE_HEALTH":
|
||||
return "appleHealth";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.4.25 W8e — deduplicate the contributing-source list for a single
|
||||
* component. Returns the empty array when nothing in the input maps
|
||||
* onto a known label so downstream `resolveSourceLabel` falls through
|
||||
* to `none` (matches the empty-state branch).
|
||||
*/
|
||||
function uniqueComponentSources(
|
||||
rows: ReadonlyArray<MeasurementSource>,
|
||||
): ReadonlyArray<ContributingSource> {
|
||||
const seen = new Set<ContributingSource>();
|
||||
for (const src of rows) {
|
||||
const label = mapMeasurementSourceToLabel(src);
|
||||
if (label) seen.add(label);
|
||||
}
|
||||
return Array.from(seen);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Health Score input from the user's last-30-day weight,
|
||||
* mood, and medication-compliance data, plus the already-computed
|
||||
@@ -564,38 +716,58 @@ async function computeUserHealthScore(
|
||||
const prevSince30d = new Date(now.getTime() - 37 * DAY_MS);
|
||||
const prevUntil = new Date(now.getTime() - 7 * DAY_MS);
|
||||
|
||||
const [weightRows, moodRows, medications] = await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "WEIGHT",
|
||||
measuredAt: { gte: prevSince30d, lte: now },
|
||||
},
|
||||
select: { value: true, measuredAt: true },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
}),
|
||||
prisma.moodEntry.findMany({
|
||||
where: {
|
||||
userId,
|
||||
moodLoggedAt: { gte: prevSince30d, lte: now },
|
||||
},
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
}),
|
||||
prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
schedules: {
|
||||
select: {
|
||||
windowStart: true,
|
||||
windowEnd: true,
|
||||
// v1.4.25 W8e — read the `source` column alongside the value so the
|
||||
// health-score provenance accordion knows which ingest path drove
|
||||
// each component. The weight + BP reads already paid the row cost;
|
||||
// adding the column to the SELECT is free in Postgres terms (no extra
|
||||
// round-trip, no extra plan node) and the alternative — a second
|
||||
// aggregate just for the source pill — would burn another findMany.
|
||||
const [weightRows, bpSysRowsForSource, moodRows, medications] =
|
||||
await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "WEIGHT",
|
||||
measuredAt: { gte: prevSince30d, lte: now },
|
||||
},
|
||||
select: { value: true, measuredAt: true, source: true },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
}),
|
||||
// BP source attribution rides on the systolic-readings row set —
|
||||
// diastolic rows always carry the same `source` because both
|
||||
// halves of a pair are persisted in the same write. Pull only the
|
||||
// trailing 30 days to keep the call bounded for power users.
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: "BLOOD_PRESSURE_SYS",
|
||||
measuredAt: { gte: since30d, lte: now },
|
||||
},
|
||||
select: { measuredAt: true, source: true },
|
||||
orderBy: { measuredAt: "asc" },
|
||||
}),
|
||||
prisma.moodEntry.findMany({
|
||||
where: {
|
||||
userId,
|
||||
moodLoggedAt: { gte: prevSince30d, lte: now },
|
||||
},
|
||||
select: { score: true, moodLoggedAt: true },
|
||||
orderBy: { moodLoggedAt: "asc" },
|
||||
}),
|
||||
prisma.medication.findMany({
|
||||
where: { userId, active: true },
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
schedules: {
|
||||
select: {
|
||||
windowStart: true,
|
||||
windowEnd: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
}),
|
||||
]);
|
||||
|
||||
// Compliance30 per active medication, then again for the prior-week
|
||||
// snapshot. The compliance helper anchors on `Date.now()` internally;
|
||||
@@ -688,12 +860,62 @@ async function computeUserHealthScore(
|
||||
return null;
|
||||
}
|
||||
|
||||
// v1.4.25 W8e — build per-component source attribution from the rows
|
||||
// we already hold in memory. `mapMeasurementSourceToLabel` collapses
|
||||
// the persisted `MeasurementSource` enum onto the camelCase token list
|
||||
// the analytics helper consumes; `IMPORT` rides under `manual`
|
||||
// because the v1.4.20 CSV importer ingests user-supplied data — it's
|
||||
// not a wearable stream.
|
||||
const windowEndAt = now.toISOString();
|
||||
|
||||
const weightSourcesIn30d = uniqueComponentSources(
|
||||
weightRows
|
||||
.filter((r) => r.measuredAt >= since30d)
|
||||
.map((r) => r.source),
|
||||
);
|
||||
const latestWeightInWindow = weightRows
|
||||
.filter((r) => r.measuredAt >= since30d)
|
||||
.at(-1);
|
||||
|
||||
const bpSourceTokens = uniqueComponentSources(
|
||||
bpSysRowsForSource.map((r) => r.source),
|
||||
);
|
||||
const latestBpInWindow = bpSysRowsForSource.at(-1);
|
||||
|
||||
// Mood doesn't yet have a non-manual ingest (v1.5 will introduce
|
||||
// Apple Health mood) so the source list is always `["manual"]` when
|
||||
// there are entries in window. Keep the lookup explicit so the
|
||||
// v1.5 ingest path slot just drops in.
|
||||
const moodSourceTokens = moodSeriesLast30d.length > 0
|
||||
? (["manual"] as const)
|
||||
: [];
|
||||
const latestMoodInWindow = moodRows
|
||||
.filter((r) => r.moodLoggedAt >= since30d)
|
||||
.at(-1);
|
||||
|
||||
// Medication compliance always derives from logged intake events —
|
||||
// user-driven manual logging today.
|
||||
const complianceSourceTokens = medicationCompliance30.length > 0
|
||||
? (["manual"] as const)
|
||||
: [];
|
||||
|
||||
const current: HealthScoreInput = {
|
||||
bpInTargetRate: input.bpInTargetPct,
|
||||
weightSeriesLast30d,
|
||||
weightTargetKg: fallbackTarget,
|
||||
moodEntriesLast30d: moodSeriesLast30d,
|
||||
medicationCompliance30,
|
||||
attribution: {
|
||||
bpSources: bpSourceTokens,
|
||||
asOfBp: latestBpInWindow?.measuredAt.toISOString() ?? null,
|
||||
weightSources: weightSourcesIn30d,
|
||||
asOfWeight: latestWeightInWindow?.measuredAt.toISOString() ?? null,
|
||||
moodSources: moodSourceTokens,
|
||||
asOfMood: latestMoodInWindow?.moodLoggedAt.toISOString() ?? null,
|
||||
complianceSources: complianceSourceTokens,
|
||||
asOfCompliance: complianceSourceTokens.length > 0 ? windowEndAt : null,
|
||||
windowEndAt,
|
||||
},
|
||||
};
|
||||
// The all-time `bpInTargetPct` is a slow-moving aggregate and would
|
||||
// need a full historical re-pair to "rewind" by a week. We pass the
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { DEFAULT_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkPut(body: unknown): Request {
|
||||
return new Request("http://localhost/api/auth/me/doctor-report-prefs", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me/doctor-report-prefs", () => {
|
||||
it("returns defaults when the column is null", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
doctorReportPrefsJson: null,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/doctor-report-prefs"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: typeof DEFAULT_DOCTOR_REPORT_PREFS;
|
||||
};
|
||||
expect(env.data).toEqual(DEFAULT_DOCTOR_REPORT_PREFS);
|
||||
expect(env.data.mood).toBe(false); // privacy default per Marc
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/auth/me/doctor-report-prefs", () => {
|
||||
// ── Case 1 — happy path ─────────────────────────────────────────────
|
||||
it("persists the full shape and returns the canonical form", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
doctorReportPrefsJson: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const body = {
|
||||
bp: true,
|
||||
weight: true,
|
||||
pulse: false,
|
||||
bmi: true,
|
||||
mood: true,
|
||||
compliance: true,
|
||||
sleep: false,
|
||||
};
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: typeof body };
|
||||
expect(env.data).toEqual(body);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { doctorReportPrefsJson: body },
|
||||
});
|
||||
});
|
||||
|
||||
// ── Case 2 — partial update layers over current persisted row ───────
|
||||
it("merges a partial update over the persisted row", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const current = {
|
||||
bp: false,
|
||||
weight: false,
|
||||
pulse: false,
|
||||
bmi: false,
|
||||
mood: true,
|
||||
compliance: false,
|
||||
sleep: false,
|
||||
};
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
doctorReportPrefsJson: current,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
// User only flips mood off — every other key keeps its persisted value.
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(
|
||||
mkPut({ mood: false }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: typeof current };
|
||||
expect(env.data).toEqual({ ...current, mood: false });
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { doctorReportPrefsJson: { ...current, mood: false } },
|
||||
});
|
||||
});
|
||||
|
||||
// ── Case 3 — invalid shape ─────────────────────────────────────────
|
||||
it("rejects a non-boolean value with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(
|
||||
mkPut({ bp: "nope" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── Case 4 — unauthenticated cross-user request ────────────────────
|
||||
// Marc's spec mentions "cross-user 404" — the route layer returns 401
|
||||
// when there is no session at all, which is the only "you are not the
|
||||
// right user" surface available before requireAuth() resolves an
|
||||
// identity. `prisma.user.update` must NOT fire in this case.
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(
|
||||
mkPut({ mood: false }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── Case 5 — audit trail (v1.4.25 W10 reconcile security M-3) ──────
|
||||
it("writes an audit-log entry with the previous and new pref shape", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const current = {
|
||||
bp: true,
|
||||
weight: true,
|
||||
pulse: true,
|
||||
bmi: true,
|
||||
mood: false,
|
||||
compliance: true,
|
||||
sleep: true,
|
||||
};
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
doctorReportPrefsJson: current,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(
|
||||
mkPut({ mood: true }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.doctor-report-prefs.update",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
previous: current,
|
||||
next: expect.objectContaining({ mood: true }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* v1.4.25 W6c — per-user Doctor-Report section toggles.
|
||||
*
|
||||
* GET /api/auth/me/doctor-report-prefs — returns the resolved prefs
|
||||
* (defaults when the row is null).
|
||||
* PUT /api/auth/me/doctor-report-prefs — merges the supplied partial
|
||||
* shape over the current row
|
||||
* and persists the canonical
|
||||
* fully-resolved object so a
|
||||
* future schema migration
|
||||
* doesn't need to back-fill
|
||||
* missing keys.
|
||||
*
|
||||
* Bearer-auth + cookie-auth both work via the shared `requireAuth()`
|
||||
* helper. The doctor-report aggregator + PDF renderer both read this
|
||||
* row at generation time so mood data is dropped at the aggregator
|
||||
* layer when `mood = false` — the data never leaves the DB.
|
||||
*/
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
doctorReportPrefsSchema,
|
||||
parseDoctorReportPrefs,
|
||||
resolveDoctorReportPrefs,
|
||||
} from "@/lib/validations/doctor-report-prefs";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "auth.me.doctor-report-prefs.get" } });
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { doctorReportPrefsJson: true },
|
||||
});
|
||||
return apiSuccess(parseDoctorReportPrefs(row?.doctorReportPrefsJson));
|
||||
});
|
||||
|
||||
export const PUT = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new HttpError(422, "doctor-report-prefs.body.invalid_json");
|
||||
}
|
||||
|
||||
const parsed = doctorReportPrefsSchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "auth.me.doctor-report-prefs.put.invalid" },
|
||||
meta: { issues: parsed.error.issues.length },
|
||||
});
|
||||
throw new HttpError(422, "doctor-report-prefs.body.invalid_shape");
|
||||
}
|
||||
|
||||
// Merge the partial input over the current row (or defaults) so a
|
||||
// dialog that only toggles `mood` doesn't have to re-state every other
|
||||
// flag. Persist the canonical fully-resolved object so the column
|
||||
// shape stays stable across future schema additions.
|
||||
const current = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { doctorReportPrefsJson: true },
|
||||
});
|
||||
const merged = resolveDoctorReportPrefs(
|
||||
current?.doctorReportPrefsJson,
|
||||
parsed.data,
|
||||
);
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
// `merged` is a typed object; Prisma's `InputJsonValue` insists on an
|
||||
// index signature so cast through `unknown` rather than widen the
|
||||
// typed return surface. Shape is identical to the validated input.
|
||||
data: {
|
||||
doctorReportPrefsJson: merged as unknown as Record<string, boolean>,
|
||||
},
|
||||
});
|
||||
|
||||
// v1.4.25 W10 reconcile (security M-3): record the previous + new
|
||||
// pref shape in the audit log. Doctor-Report pref toggles
|
||||
// (especially `mood: true`) widen the PDF surface; without an
|
||||
// audit trail a silent compromise (or unintended client write)
|
||||
// is invisible. Mirrors the timezone-route audit pattern.
|
||||
await auditLog("user.doctor-report-prefs.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: {
|
||||
previous: current?.doctorReportPrefsJson ?? null,
|
||||
next: merged,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.me.doctor-report-prefs.put" },
|
||||
meta: {
|
||||
bp: merged.bp,
|
||||
weight: merged.weight,
|
||||
pulse: merged.pulse,
|
||||
bmi: merged.bmi,
|
||||
mood: merged.mood,
|
||||
compliance: merged.compliance,
|
||||
sleep: merged.sleep,
|
||||
},
|
||||
});
|
||||
return apiSuccess(merged);
|
||||
});
|
||||
@@ -0,0 +1,304 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 4,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { DELETE, GET, POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { RESEARCH_MODE_DISCLAIMER_VERSION } from "@/lib/medications/glp1-pk";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkPost(body: unknown): Request {
|
||||
return new Request("http://localhost/api/auth/me/research-mode", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function mkDelete(): Request {
|
||||
return new Request("http://localhost/api/auth/me/research-mode", {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me/research-mode", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/research-mode"),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the disabled default for a fresh user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/research-mode"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: {
|
||||
enabled: boolean;
|
||||
acknowledgedAt: string | null;
|
||||
acknowledgedVersion: string | null;
|
||||
currentDisclaimerVersion: string;
|
||||
};
|
||||
};
|
||||
expect(env.data.enabled).toBe(false);
|
||||
expect(env.data.acknowledgedAt).toBeNull();
|
||||
expect(env.data.acknowledgedVersion).toBeNull();
|
||||
expect(env.data.currentDisclaimerVersion).toBe(
|
||||
RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the enabled state with the persisted version", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const ackAt = new Date("2026-05-14T10:00:00Z");
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: ackAt,
|
||||
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as (r: Request) => Promise<Response>)(
|
||||
new Request("http://localhost/api/auth/me/research-mode"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: {
|
||||
enabled: boolean;
|
||||
acknowledgedAt: string;
|
||||
acknowledgedVersion: string;
|
||||
currentDisclaimerVersion: string;
|
||||
};
|
||||
};
|
||||
expect(env.data.enabled).toBe(true);
|
||||
expect(env.data.acknowledgedAt).toBe(ackAt.toISOString());
|
||||
expect(env.data.acknowledgedVersion).toBe(
|
||||
RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/auth/me/research-mode", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (POST as (r: Request) => Promise<Response>)(
|
||||
mkPost({ acknowledged: true, version: RESEARCH_MODE_DISCLAIMER_VERSION }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("enables Research Mode and writes the audit log on a valid ack", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (POST as (r: Request) => Promise<Response>)(
|
||||
mkPost({ acknowledged: true, version: RESEARCH_MODE_DISCLAIMER_VERSION }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: { enabled: boolean; acknowledgedVersion: string };
|
||||
};
|
||||
expect(env.data.enabled).toBe(true);
|
||||
expect(env.data.acknowledgedVersion).toBe(
|
||||
RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
);
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: "user-1" },
|
||||
data: expect.objectContaining({
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.research-mode.enable",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
next: expect.objectContaining({
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a stale disclaimer version with 400", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await (POST as (r: Request) => Promise<Response>)(
|
||||
mkPost({ acknowledged: true, version: "1970-01-01.0" }),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a body without acknowledged=true with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
|
||||
const res = await (POST as (r: Request) => Promise<Response>)(
|
||||
mkPost({ version: RESEARCH_MODE_DISCLAIMER_VERSION }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 429 when the per-user rate-limit fires", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 30_000,
|
||||
});
|
||||
|
||||
const res = await (POST as (r: Request) => Promise<Response>)(
|
||||
mkPost({ acknowledged: true, version: RESEARCH_MODE_DISCLAIMER_VERSION }),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/auth/me/research-mode", () => {
|
||||
it("rejects an unauthenticated request with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
|
||||
const res = await (DELETE as (r: Request) => Promise<Response>)(mkDelete());
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("disables Research Mode and clears the acknowledgment", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: new Date(),
|
||||
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (DELETE as (r: Request) => Promise<Response>)(mkDelete());
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as {
|
||||
data: {
|
||||
enabled: boolean;
|
||||
acknowledgedAt: string | null;
|
||||
acknowledgedVersion: string | null;
|
||||
};
|
||||
};
|
||||
expect(env.data.enabled).toBe(false);
|
||||
expect(env.data.acknowledgedAt).toBeNull();
|
||||
expect(env.data.acknowledgedVersion).toBeNull();
|
||||
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: {
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
},
|
||||
});
|
||||
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.research-mode.disable",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
next: {
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("is idempotent when Research Mode is already off", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const res = await (DELETE as (r: Request) => Promise<Response>)(mkDelete());
|
||||
expect(res.status).toBe(200);
|
||||
// The route writes the canonical off-state even when the
|
||||
// column is already off — guarantees audit trail symmetry.
|
||||
expect(prisma.user.update).toHaveBeenCalledTimes(1);
|
||||
expect(auditLog).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* v1.4.25 W19c — Research Mode acknowledgment endpoint.
|
||||
*
|
||||
* GET /api/auth/me/research-mode — returns the current flag,
|
||||
* the timestamp + version of
|
||||
* the last acknowledgment, and
|
||||
* the live disclaimer version
|
||||
* so the Settings UI can
|
||||
* compare and re-prompt.
|
||||
* POST /api/auth/me/research-mode — body { acknowledged: true,
|
||||
* version }. Flips the flag
|
||||
* on. Rejects 400 if `version`
|
||||
* is not the live version.
|
||||
* DELETE /api/auth/me/research-mode — disables Research Mode and
|
||||
* clears the acknowledgment.
|
||||
* Idempotent.
|
||||
*
|
||||
* Per-user POST rate-limit is intentionally low (5/min) — this is a
|
||||
* one-tap acknowledgment dialog, not a hot path. Anything above that
|
||||
* traffic looks like a malformed client retry-loop or an exploit.
|
||||
*
|
||||
* Prompt-injection surface — the API accepts the disclaimer
|
||||
* `version` string and nothing else from the client. The version
|
||||
* value never reaches the LLM; it is matched against the
|
||||
* server-side constant `RESEARCH_MODE_DISCLAIMER_VERSION` and then
|
||||
* persisted as the stamp on the user row. No free-text body.
|
||||
*
|
||||
* Audit-log entries fire on every state-changing call (POST, DELETE)
|
||||
* with the previous + next state, mirroring the doctor-report-prefs
|
||||
* route's reconcile pattern (W10 security M-3).
|
||||
*/
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { RESEARCH_MODE_DISCLAIMER_VERSION } from "@/lib/medications/glp1-pk";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const POST_RATE_LIMIT = 5;
|
||||
const POST_WINDOW_MS = 60_000;
|
||||
|
||||
type ResearchModeResponse = {
|
||||
enabled: boolean;
|
||||
acknowledgedAt: string | null;
|
||||
acknowledgedVersion: string | null;
|
||||
currentDisclaimerVersion: string;
|
||||
};
|
||||
|
||||
function shape(row: {
|
||||
researchModeEnabled: boolean;
|
||||
researchModeAcknowledgedAt: Date | null;
|
||||
researchModeAcknowledgedVersion: string | null;
|
||||
}): ResearchModeResponse {
|
||||
return {
|
||||
enabled: row.researchModeEnabled,
|
||||
acknowledgedAt: row.researchModeAcknowledgedAt
|
||||
? row.researchModeAcknowledgedAt.toISOString()
|
||||
: null,
|
||||
acknowledgedVersion: row.researchModeAcknowledgedVersion,
|
||||
currentDisclaimerVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "auth.me.research-mode.get" } });
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: true,
|
||||
researchModeAcknowledgedVersion: true,
|
||||
},
|
||||
});
|
||||
// `requireAuth` already proved the user exists; defensive default
|
||||
// here only matters for the (impossible-in-practice) race where
|
||||
// the user is deleted mid-request.
|
||||
return apiSuccess(
|
||||
shape(
|
||||
row ?? {
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Per-user rate-limit. 5/min is generous for a once-per-update
|
||||
// acknowledgment dialog; anything more looks like a retry loop.
|
||||
const rl = await checkRateLimit(
|
||||
`research-mode:post:${user.id}`,
|
||||
POST_RATE_LIMIT,
|
||||
POST_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
const response = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new HttpError(422, "research-mode.body.invalid_json");
|
||||
}
|
||||
|
||||
// Hand-rolled validation — the body is tiny (two fields), and
|
||||
// staying off Zod keeps the prompt-injection surface visibly
|
||||
// simple in a code review.
|
||||
if (
|
||||
!body ||
|
||||
typeof body !== "object" ||
|
||||
(body as { acknowledged?: unknown }).acknowledged !== true ||
|
||||
typeof (body as { version?: unknown }).version !== "string"
|
||||
) {
|
||||
annotate({ action: { name: "auth.me.research-mode.post.invalid_shape" } });
|
||||
throw new HttpError(422, "research-mode.body.invalid_shape");
|
||||
}
|
||||
|
||||
const submittedVersion = (body as { version: string }).version;
|
||||
|
||||
// Stale-version guard — the dialog must show the *live* copy at
|
||||
// the moment of acknowledgment. A stale version means the user
|
||||
// is acknowledging older wording; we refuse and force the
|
||||
// Settings UI to re-render with the new disclaimer.
|
||||
if (submittedVersion !== RESEARCH_MODE_DISCLAIMER_VERSION) {
|
||||
annotate({
|
||||
action: { name: "auth.me.research-mode.post.stale_version" },
|
||||
meta: {
|
||||
submitted: submittedVersion,
|
||||
current: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
},
|
||||
});
|
||||
throw new HttpError(400, "research-mode.version.stale");
|
||||
}
|
||||
|
||||
const previous = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: true,
|
||||
researchModeAcknowledgedVersion: true,
|
||||
},
|
||||
});
|
||||
|
||||
const next = {
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: new Date(),
|
||||
researchModeAcknowledgedVersion: RESEARCH_MODE_DISCLAIMER_VERSION,
|
||||
};
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: next,
|
||||
});
|
||||
|
||||
await auditLog("user.research-mode.enable", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: {
|
||||
previous: previous ?? null,
|
||||
next: {
|
||||
...next,
|
||||
researchModeAcknowledgedAt: next.researchModeAcknowledgedAt.toISOString(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.me.research-mode.post" },
|
||||
meta: { version: RESEARCH_MODE_DISCLAIMER_VERSION },
|
||||
});
|
||||
return apiSuccess(shape(next));
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const previous = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
researchModeEnabled: true,
|
||||
researchModeAcknowledgedAt: true,
|
||||
researchModeAcknowledgedVersion: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Idempotent — if Research Mode is already off, we still write
|
||||
// the canonical "off" state so the audit row exists.
|
||||
const next = {
|
||||
researchModeEnabled: false,
|
||||
researchModeAcknowledgedAt: null,
|
||||
researchModeAcknowledgedVersion: null,
|
||||
};
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: next,
|
||||
});
|
||||
|
||||
await auditLog("user.research-mode.disable", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: { previous: previous ?? null, next },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "auth.me.research-mode.delete" } });
|
||||
return apiSuccess(shape(next));
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkPut(body: unknown): Request {
|
||||
return new Request("http://localhost/api/auth/me/source-priority", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/auth/me/source-priority", () => {
|
||||
it("rejects unauthenticated requests with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await (GET as () => Promise<Response>)();
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns the resolved shape with defaults when the column is null", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
sourcePriorityJson: null,
|
||||
} as never);
|
||||
|
||||
const res = await (GET as () => Promise<Response>)();
|
||||
expect(res.status).toBe(200);
|
||||
const env = (await res.json()) as { data: Record<string, unknown> };
|
||||
// Resolver always emits the keys the analytics layer needs.
|
||||
expect(env.data).toHaveProperty("weight");
|
||||
expect(env.data).toHaveProperty("metricPriority");
|
||||
expect(env.data).toHaveProperty("deviceTypePriority");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PUT /api/auth/me/source-priority", () => {
|
||||
it("rejects unauthenticated requests with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(
|
||||
mkPut({ weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"] }),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists the partial shape", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
sourcePriorityJson: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const body = { weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"] };
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { sourcePriorityJson: body },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an invalid shape with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(
|
||||
mkPut({ weight: "not-an-array" }),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// ── v1.4.25 W10 reconcile (security M-3) ──
|
||||
it("writes an audit-log entry with the previous and new shape", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const previousShape = {
|
||||
weight: ["WITHINGS", "APPLE_HEALTH", "MANUAL"],
|
||||
};
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
sourcePriorityJson: previousShape,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({} as never);
|
||||
|
||||
const body = { weight: ["APPLE_HEALTH", "WITHINGS", "MANUAL"] };
|
||||
const res = await (PUT as (r: Request) => Promise<Response>)(mkPut(body));
|
||||
expect(res.status).toBe(200);
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"user.source-priority.update",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
previous: previousShape,
|
||||
next: body,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* v1.4.25 W5e — per-user, per-metric-class source priority.
|
||||
*
|
||||
* GET /api/auth/me/source-priority — returns the fully-defaulted
|
||||
* shape (missing keys filled
|
||||
* from `DEFAULT_SOURCE_PRIORITY`).
|
||||
* PUT /api/auth/me/source-priority — replaces the persisted shape;
|
||||
* body is validated against
|
||||
* `sourcePrioritySchema` and
|
||||
* persisted as the partial form
|
||||
* (missing keys keep the default
|
||||
* at read time).
|
||||
*
|
||||
* Bearer-auth + cookie-auth both work via the shared `requireAuth()`
|
||||
* helper. The analytics aggregator reads this row on every call and
|
||||
* the read path is cheap (one column) — no caching layer to flush.
|
||||
*/
|
||||
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
parseSourcePriority,
|
||||
sourcePrioritySchema,
|
||||
} from "@/lib/validations/source-priority";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "auth.me.source-priority.get" } });
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { sourcePriorityJson: true },
|
||||
});
|
||||
return apiSuccess(parseSourcePriority(row?.sourcePriorityJson));
|
||||
});
|
||||
|
||||
export const PUT = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
throw new HttpError(422, "source-priority.body.invalid_json");
|
||||
}
|
||||
|
||||
const parsed = sourcePrioritySchema.safeParse(body ?? {});
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "auth.me.source-priority.put.invalid" },
|
||||
meta: { issues: parsed.error.issues.length },
|
||||
});
|
||||
throw new HttpError(422, "source-priority.body.invalid_shape");
|
||||
}
|
||||
|
||||
// v1.4.25 W10 reconcile (security M-3): capture the previous shape
|
||||
// before overwriting so the audit-log entry records the actual
|
||||
// delta. Source-priority drives every aggregator's source pick,
|
||||
// so a silent compromise (or accidental client write) was
|
||||
// previously invisible — mirroring the timezone-route audit
|
||||
// pattern closes the gap.
|
||||
const before = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { sourcePriorityJson: true },
|
||||
});
|
||||
|
||||
// Persist the partial form — missing keys read as defaults via
|
||||
// `parseSourcePriority`. Storing only the user-edited subset keeps
|
||||
// the Json blob narrow and future-proofs the shape: when a new
|
||||
// metric class is added, every existing row reads the new key's
|
||||
// default until the user explicitly changes it.
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { sourcePriorityJson: parsed.data },
|
||||
});
|
||||
|
||||
await auditLog("user.source-priority.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(req),
|
||||
details: {
|
||||
previous: before?.sourcePriorityJson ?? null,
|
||||
next: parsed.data,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "auth.me.source-priority.put" },
|
||||
meta: { keys: Object.keys(parsed.data).length },
|
||||
});
|
||||
return apiSuccess(parseSourcePriority(parsed.data));
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
appSettings: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { PUT } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
function mkReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/auth/me/timezone", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("PUT /api/auth/me/timezone", () => {
|
||||
it("rejects unauthenticated requests with 401", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await PUT(mkReq({ timezone: "Europe/Berlin" }));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("accepts a valid IANA zone and writes it to the user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
timezone: "Europe/Berlin",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
timezone: "Pacific/Auckland",
|
||||
} as never);
|
||||
|
||||
const res = await PUT(mkReq({ timezone: "Pacific/Auckland" }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { timezone: "Pacific/Auckland" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an invalid timezone with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await PUT(mkReq({ timezone: "Mars/Olympus" }));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.user.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an empty timezone with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await PUT(mkReq({ timezone: "" }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("rejects a missing timezone field with 422", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await PUT(mkReq({}));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("trims whitespace before validating", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
timezone: "Europe/Berlin",
|
||||
} as never);
|
||||
vi.mocked(prisma.user.update).mockResolvedValue({
|
||||
timezone: "Asia/Tokyo",
|
||||
} as never);
|
||||
const res = await PUT(mkReq({ timezone: " Asia/Tokyo " }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: "user-1" },
|
||||
data: { timezone: "Asia/Tokyo" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* PUT /api/auth/me/timezone
|
||||
*
|
||||
* v1.4.25 W7 — per-user timezone setter. Lifts the timezone write
|
||||
* out of `PUT /api/auth/profile` so the picker UI can post the
|
||||
* single field without round-tripping email/heightCm/etc, and so
|
||||
* the change pipeline (validate → write → invalidate-cache) is
|
||||
* isolated from the heavier profile patch.
|
||||
*
|
||||
* Auth: cookie session OR Bearer token.
|
||||
* Audit: `user.timezone.update` with the old + new zone.
|
||||
*
|
||||
* Validation: the body is `{ "timezone": "Europe/Berlin" }`. We
|
||||
* accept any string the runtime's `Intl.DateTimeFormat` constructor
|
||||
* accepts — i.e. every IANA zone the active Node engine knows about.
|
||||
* Rejecting unknown zones at the API surface stops a malformed
|
||||
* client from poisoning the column with a value the resolver
|
||||
* would later have to defend against.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { invalidateUserTimezone, isValidTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "user.timezone.update" } });
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const tz =
|
||||
body && typeof (body as { timezone?: unknown }).timezone === "string"
|
||||
? ((body as { timezone: string }).timezone || "").trim()
|
||||
: "";
|
||||
|
||||
if (!isValidTimezone(tz)) {
|
||||
return apiError("Not a valid IANA timezone.", 422);
|
||||
}
|
||||
|
||||
const before = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { timezone: true },
|
||||
});
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { timezone: tz },
|
||||
});
|
||||
|
||||
invalidateUserTimezone(user.id);
|
||||
|
||||
await auditLog("user.timezone.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
previous: before?.timezone ?? null,
|
||||
next: tz,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
timezone_previous: before?.timezone ?? null,
|
||||
timezone_next: tz,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ timezone: tz });
|
||||
});
|
||||
@@ -15,6 +15,10 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
import {
|
||||
isValidTimezone,
|
||||
resolveServerDefaultTimezone,
|
||||
} from "@/lib/tz/resolver";
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const ip = getClientIp(request) ?? "unknown";
|
||||
@@ -59,7 +63,21 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const { email, username, password } = parsed.data;
|
||||
const { email, username, password, timezone: timezoneInput } = parsed.data;
|
||||
|
||||
// v1.4.25 W7 — accept a browser-detected timezone from the
|
||||
// registration form. Validate against the runtime IANA list; on
|
||||
// any invalid value (or absence), fall back to the admin-
|
||||
// configured server default. The `User.timezone` column has a
|
||||
// hard-coded "Europe/Berlin" default at the schema layer, so the
|
||||
// worst-case chain still produces a usable string.
|
||||
let timezone: string;
|
||||
const trimmedTimezone = timezoneInput?.trim() ?? "";
|
||||
if (trimmedTimezone && isValidTimezone(trimmedTimezone)) {
|
||||
timezone = trimmedTimezone;
|
||||
} else {
|
||||
timezone = await resolveServerDefaultTimezone();
|
||||
}
|
||||
|
||||
// Check if email or username already taken (unified message to prevent enumeration)
|
||||
const [existingEmail, existingUsername] = await Promise.all([
|
||||
@@ -91,6 +109,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
username,
|
||||
passwordHash,
|
||||
role,
|
||||
timezone,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -104,7 +123,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
await auditLog("auth.register", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: { method: "password" },
|
||||
details: { method: "password", timezone },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "auth.register" } });
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* GET /api/dashboard/glp1 — v1.4.25 W6
|
||||
*
|
||||
* Powers the dashboard's `<Glp1Tile>`. Returns the user's active GLP-1
|
||||
* medications (treatmentClass = "GLP1"), each enriched with a tiny
|
||||
* weight series + the dates the user logged an injection. Returns
|
||||
* `null` data when the user has no active GLP-1 medication — the tile
|
||||
* then suppresses itself per Marc's empty-state hide rule.
|
||||
*
|
||||
* Bridge layer: the heavy lifting (current dose / last + next injection
|
||||
* / titration history / inventory math) is delegated to
|
||||
* `buildGlp1SnapshotBlock` so the Coach's GLP-1-aware reply path and
|
||||
* this dashboard tile read from the exact same shape. The extras we
|
||||
* add on top here are purely UI-facing:
|
||||
* - weightSeries[] — daily aggregates since the first dose change
|
||||
* (or 90 days back, whichever is later) for
|
||||
* the mini-chart on the tile
|
||||
* - weightDeltaKg — convenience caption value (current − start);
|
||||
* the tile renders "−4.2 kg seit Beginn" off
|
||||
* this number, with `null` when either side
|
||||
* is missing
|
||||
* - injectionDates[] — `YYYY-MM-DD` keys for every recorded intake
|
||||
* event so the chart can paint the vertical
|
||||
* injection-day markers Marc wants
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { buildGlp1SnapshotBlock } from "@/lib/ai/coach/glp1-snapshot";
|
||||
import { DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
|
||||
const WEIGHT_LOOKBACK_DAYS = 90;
|
||||
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
||||
|
||||
function isoDate(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bucket raw weight measurements into one daily average so the mini-
|
||||
* chart doesn't render the noisy raw stream. Same `YYYY-MM-DD` keying
|
||||
* the dashboard chart layer uses (UTC-noon anchor — DST-safe).
|
||||
*/
|
||||
function bucketDaily(
|
||||
rows: Array<{ measuredAt: Date; value: number }>,
|
||||
): Array<{ date: string; weight: number }> {
|
||||
const sums = new Map<string, { sum: number; count: number }>();
|
||||
for (const row of rows) {
|
||||
const key = isoDate(row.measuredAt);
|
||||
const slot = sums.get(key) ?? { sum: 0, count: 0 };
|
||||
slot.sum += row.value;
|
||||
slot.count += 1;
|
||||
sums.set(key, slot);
|
||||
}
|
||||
return Array.from(sums.entries())
|
||||
.map(([date, { sum, count }]) => ({
|
||||
date,
|
||||
weight: Math.round((sum / count) * 100) / 100,
|
||||
}))
|
||||
.sort((a, b) => (a.date < b.date ? -1 : 1));
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
const now = new Date();
|
||||
|
||||
const snapshot = await buildGlp1SnapshotBlock(user.id, now);
|
||||
// No active GLP-1 medication → the tile suppresses itself. We still
|
||||
// return 200 + `data: null` so the client doesn't have to special-
|
||||
// case the 404 path.
|
||||
if (!snapshot) {
|
||||
annotate({
|
||||
action: { name: "dashboard.glp1" },
|
||||
meta: { active: 0 },
|
||||
});
|
||||
return apiSuccess(null);
|
||||
}
|
||||
|
||||
// Anchor the weight series to the earliest meaningful date: the user
|
||||
// wants to see the trend SINCE THEY STARTED the GLP-1, not just the
|
||||
// last 90 days. We pick the earlier of:
|
||||
// - the first dose-change `effectiveFrom` across all GLP-1 meds
|
||||
// - 90 days back from today (so brand-new accounts still get a
|
||||
// reasonable window when they only just logged their first dose)
|
||||
let anchor = new Date(now.getTime() - WEIGHT_LOOKBACK_DAYS * MS_PER_DAY);
|
||||
for (const med of snapshot.medications) {
|
||||
if (med.doseHistory.length === 0) continue;
|
||||
const firstDose = med.doseHistory[0].effectiveFrom;
|
||||
const firstDoseMs = Date.parse(`${firstDose}T00:00:00Z`);
|
||||
if (Number.isFinite(firstDoseMs) && firstDoseMs < anchor.getTime()) {
|
||||
anchor = new Date(firstDoseMs);
|
||||
}
|
||||
}
|
||||
|
||||
const [weights, intakeRows] = await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: "WEIGHT",
|
||||
measuredAt: { gte: anchor },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { measuredAt: true, value: true },
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
takenAt: { not: null, gte: anchor },
|
||||
medication: { treatmentClass: "GLP1" },
|
||||
},
|
||||
orderBy: { takenAt: "asc" },
|
||||
select: {
|
||||
medicationId: true,
|
||||
takenAt: true,
|
||||
medication: { select: { name: true } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const weightSeries = bucketDaily(weights);
|
||||
|
||||
// Per-med injection date set — used by the chart's vertical markers.
|
||||
// Multiple injection events on the same day collapse to a single
|
||||
// marker so the chart doesn't paint stacked overlapping lines.
|
||||
const injectionDatesByMed = new Map<string, Set<string>>();
|
||||
for (const ev of intakeRows) {
|
||||
if (!ev.takenAt) continue;
|
||||
const set = injectionDatesByMed.get(ev.medicationId) ?? new Set<string>();
|
||||
set.add(isoDate(ev.takenAt));
|
||||
injectionDatesByMed.set(ev.medicationId, set);
|
||||
}
|
||||
|
||||
// We also need to know the medication id per snapshot entry. The
|
||||
// snapshot exposes name + genericName but not the id; re-fetch the
|
||||
// id list ordered by `createdAt: desc` (matches the snapshot
|
||||
// ordering) and zip the two lists. Cheap re-query — already in cache
|
||||
// from the snapshot's first hit.
|
||||
const idsForOrder = await prisma.medication.findMany({
|
||||
where: { userId: user.id, treatmentClass: "GLP1", active: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
|
||||
// Build the enriched per-med payload. `startWeight` = the first daily
|
||||
// bucket on/after the med's first dose-change date; `currentWeight`
|
||||
// = the most recent daily bucket. Both `null` when no weight data
|
||||
// exists in the window — the tile suppresses the delta caption then.
|
||||
const medications = snapshot.medications.map((med, idx) => {
|
||||
const medId = idsForOrder[idx]?.id ?? null;
|
||||
|
||||
let startWeight: number | null = null;
|
||||
let currentWeight: number | null = null;
|
||||
let medSeries: Array<{ date: string; weight: number }> = [];
|
||||
|
||||
if (weightSeries.length > 0) {
|
||||
const firstDoseDate =
|
||||
med.doseHistory.length > 0 ? med.doseHistory[0].effectiveFrom : null;
|
||||
// Filter the series to "since the user started this medication".
|
||||
// If we don't have a dose-change row (legacy data) we fall back
|
||||
// to the whole 90-day window so the chart isn't empty.
|
||||
medSeries = firstDoseDate
|
||||
? weightSeries.filter((p) => p.date >= firstDoseDate)
|
||||
: weightSeries.slice();
|
||||
startWeight = medSeries[0]?.weight ?? null;
|
||||
currentWeight = medSeries[medSeries.length - 1]?.weight ?? null;
|
||||
}
|
||||
|
||||
const weightDeltaKg =
|
||||
startWeight !== null && currentWeight !== null
|
||||
? Math.round((currentWeight - startWeight) * 100) / 100
|
||||
: null;
|
||||
|
||||
const injectionDates = medId
|
||||
? Array.from(injectionDatesByMed.get(medId) ?? []).sort()
|
||||
: [];
|
||||
|
||||
return {
|
||||
...med,
|
||||
medicationId: medId,
|
||||
startWeight,
|
||||
currentWeight,
|
||||
weightDeltaKg,
|
||||
weightSeries: medSeries,
|
||||
injectionDates,
|
||||
};
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "dashboard.glp1" },
|
||||
meta: {
|
||||
active: medications.length,
|
||||
tz: userTz,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
active: snapshot.active,
|
||||
medications,
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ import type { MeasurementType } from "@/generated/prisma/client";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { defaultLocale, type Locale } from "@/lib/i18n/config";
|
||||
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
|
||||
const SPARK_DAYS = 7;
|
||||
const STREAK_WINDOW_DAYS = 365;
|
||||
@@ -81,35 +82,35 @@ function trendOf(values: number[]): MetricCard["trend"] {
|
||||
return delta > 0 ? "up" : "down";
|
||||
}
|
||||
|
||||
function startOfDayBerlin(date: Date): Date {
|
||||
// Compute midnight in Europe/Berlin → UTC ms.
|
||||
const berlin = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Europe/Berlin",
|
||||
function startOfDayInTz(date: Date, tz: string): Date {
|
||||
// Compute midnight in the user's tz → UTC ms.
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(date);
|
||||
const y = berlin.find((p) => p.type === "year")?.value ?? "1970";
|
||||
const m = berlin.find((p) => p.type === "month")?.value ?? "01";
|
||||
const d = berlin.find((p) => p.type === "day")?.value ?? "01";
|
||||
const y = parts.find((p) => p.type === "year")?.value ?? "1970";
|
||||
const m = parts.find((p) => p.type === "month")?.value ?? "01";
|
||||
const d = parts.find((p) => p.type === "day")?.value ?? "01";
|
||||
return new Date(`${y}-${m}-${d}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
function berlinDayKey(date: Date): string {
|
||||
return new Intl.DateTimeFormat("sv-SE", {
|
||||
timeZone: "Europe/Berlin",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
interface StreakInfo {
|
||||
currentDays: number;
|
||||
longest: number;
|
||||
}
|
||||
|
||||
/** Compute the current logging-day streak (days where any measurement or
|
||||
* intake event was recorded, in Berlin time) plus the longest streak in
|
||||
* the last `STREAK_WINDOW_DAYS` days. */
|
||||
function computeStreak(activityDays: Set<string>): StreakInfo {
|
||||
* intake event was recorded, in the user's display timezone) plus the
|
||||
* longest streak in the last `STREAK_WINDOW_DAYS` days.
|
||||
*
|
||||
* v1.4.25 W7b — `userTz` parameterises the "today" pivot so a Pacific/
|
||||
* Auckland user gets their Auckland-day streak rather than the Berlin
|
||||
* one. The activity-day Set entries are already produced in the same
|
||||
* zone by the caller (via `userDayKey`), so the cursor walk only needs
|
||||
* the same zone here to align. */
|
||||
function computeStreak(activityDays: Set<string>, userTz: string): StreakInfo {
|
||||
if (activityDays.size === 0) return { currentDays: 0, longest: 0 };
|
||||
|
||||
const sorted = [...activityDays].sort();
|
||||
@@ -126,15 +127,15 @@ function computeStreak(activityDays: Set<string>): StreakInfo {
|
||||
}
|
||||
}
|
||||
|
||||
// Current streak: walk back from today (Berlin).
|
||||
const todayKey = berlinDayKey(new Date());
|
||||
// Current streak: walk back from today (user's tz).
|
||||
const todayKey = userDayKey(new Date(), userTz);
|
||||
let currentDays = 0;
|
||||
let cursor = new Date(`${todayKey}T00:00:00.000Z`);
|
||||
// Allow yesterday's last day to count if today not yet logged.
|
||||
if (!activityDays.has(berlinDayKey(cursor))) {
|
||||
if (!activityDays.has(userDayKey(cursor, userTz))) {
|
||||
cursor = new Date(cursor.getTime() - 86_400_000);
|
||||
}
|
||||
while (activityDays.has(berlinDayKey(cursor))) {
|
||||
while (activityDays.has(userDayKey(cursor, userTz))) {
|
||||
currentDays += 1;
|
||||
cursor = new Date(cursor.getTime() - 86_400_000);
|
||||
}
|
||||
@@ -146,12 +147,18 @@ export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "dashboard.summary" } });
|
||||
|
||||
// v1.4.25 W7b — anchor every day-bucket call to the user's display
|
||||
// timezone. Falls back to Europe/Berlin when the column is somehow
|
||||
// missing (defensive — the schema's NOT NULL default normally pins
|
||||
// it).
|
||||
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
|
||||
const now = new Date();
|
||||
const sevenDaysAgo = new Date(now.getTime() - SPARK_DAYS * 86_400_000);
|
||||
const streakWindowStart = new Date(
|
||||
now.getTime() - STREAK_WINDOW_DAYS * 86_400_000,
|
||||
);
|
||||
const todayStart = startOfDayBerlin(now);
|
||||
const todayStart = startOfDayInTz(now, userTz);
|
||||
const todayEnd = new Date(todayStart.getTime() + 86_400_000);
|
||||
|
||||
// Derived from canonical enum so a new measurement type is auto-included
|
||||
@@ -192,7 +199,7 @@ export const GET = apiHandler(async () => {
|
||||
|
||||
const activityDays = new Set<string>();
|
||||
for (const m of recentMeasurements)
|
||||
activityDays.add(berlinDayKey(m.measuredAt));
|
||||
activityDays.add(userDayKey(m.measuredAt, userTz));
|
||||
// Pull a wider measurement window for the streak so it isn't capped by
|
||||
// SPARK_DAYS — but only if the user has any data at all.
|
||||
if (recentMeasurements.length > 0) {
|
||||
@@ -203,13 +210,13 @@ export const GET = apiHandler(async () => {
|
||||
},
|
||||
select: { measuredAt: true },
|
||||
});
|
||||
for (const m of wider) activityDays.add(berlinDayKey(m.measuredAt));
|
||||
for (const m of wider) activityDays.add(userDayKey(m.measuredAt, userTz));
|
||||
}
|
||||
for (const e of streakActivity) {
|
||||
activityDays.add(berlinDayKey(e.takenAt ?? e.scheduledFor));
|
||||
activityDays.add(userDayKey(e.takenAt ?? e.scheduledFor, userTz));
|
||||
}
|
||||
|
||||
const streak = computeStreak(activityDays);
|
||||
const streak = computeStreak(activityDays, userTz);
|
||||
|
||||
// Per-type latest + sparkline.
|
||||
const byType = new Map<MeasurementType, { value: number; at: Date }[]>();
|
||||
|
||||
@@ -58,13 +58,36 @@ const layoutSchema = z.object({
|
||||
// v1.4.18 — per-chart overlay prefs (3 toggles per chart card).
|
||||
// Optional so older clients that don't know the field can still PUT;
|
||||
// the resolver coerces malformed values away from the layout blob.
|
||||
//
|
||||
// v1.4.25 W6 — switched from `z.record(enum, …)` to `z.partialRecord(…)`
|
||||
// because Zod v4 changed the semantics of `z.record(enum, …)`: it now
|
||||
// requires every enum key to be present (a breaking change from
|
||||
// Zod v3 which behaved like a partial record). With the strict variant
|
||||
// any PUT that carried `chartOverlayPrefs` with fewer than ALL nine
|
||||
// chart keys (i.e. every real-world Save click once a user had touched
|
||||
// a per-chart overlay popover) 422'd with
|
||||
// `expected: object, path: ["chartOverlayPrefs", "<missing-key>"]` and
|
||||
// surfaced as the toast `Layout konnte nicht gespeichert werden`.
|
||||
// Partial-record matches the original intent — overlay prefs are
|
||||
// per-chart opt-in, the resolver fills in defaults for missing keys.
|
||||
//
|
||||
// The inner object also documents `comparisonBaseline` so the
|
||||
// per-chart `<ChartOverlayControls>` popover (which calls
|
||||
// `/api/dashboard/chart-overlay-prefs`) and a full-layout PUT from
|
||||
// Settings → Dashboard can both round-trip the field. Without it Zod
|
||||
// would silently strip the per-chart `comparisonBaseline` on every
|
||||
// Save click in Settings, wiping any per-chart toggle the user had
|
||||
// set via the chart-card popover.
|
||||
chartOverlayPrefs: z
|
||||
.record(
|
||||
.partialRecord(
|
||||
z.enum(CHART_OVERLAY_KEYS),
|
||||
z.object({
|
||||
showTrendIndicator: z.boolean(),
|
||||
showTrendArrow: z.boolean(),
|
||||
showTargetRange: z.boolean(),
|
||||
comparisonBaseline: z
|
||||
.enum(["none", "lastMonth", "lastYear"])
|
||||
.optional(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* v1.4.25 W6c — doctor-report section availability probe.
|
||||
*
|
||||
* Returns one boolean per toggleable section so the dialog can hide
|
||||
* checkboxes for data types that have zero rows in the user-selected
|
||||
* date range (Marc's "hide-when-empty" rule). Pure presence check —
|
||||
* `count` queries on small indexed tables — so this is cheap enough to
|
||||
* run on every date-range change in the dialog without driving cost.
|
||||
*
|
||||
* Body shape (mirrors `/api/doctor-report`):
|
||||
* { startDate?: ISO, endDate?: ISO, days?: number }
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { normaliseDateRange } from "@/lib/doctor-report-data";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export interface DoctorReportAvailability {
|
||||
/** Section flags — `true` when at least one row exists in the range. */
|
||||
bp: boolean;
|
||||
weight: boolean;
|
||||
pulse: boolean;
|
||||
/** BMI requires both a weight reading AND a configured height. */
|
||||
bmi: boolean;
|
||||
mood: boolean;
|
||||
/** Medication compliance — derived from `MedicationIntakeEvent` rows. */
|
||||
compliance: boolean;
|
||||
sleep: boolean;
|
||||
}
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "doctor-report.availability" } });
|
||||
|
||||
const { data: body, error } = await safeJson(request);
|
||||
if (error) return error;
|
||||
const range = normaliseDateRange(body ?? undefined);
|
||||
const { start, end } = range;
|
||||
|
||||
// `count` queries are cheaper than `findMany` for a presence check —
|
||||
// we never read the rows themselves. The user has indexes on
|
||||
// `(userId, measuredAt)`, `(userId, moodLoggedAt)`, and
|
||||
// `(userId, scheduledFor)` so these stay sub-millisecond even with
|
||||
// multi-year ranges.
|
||||
const [
|
||||
bpCount,
|
||||
weightCount,
|
||||
pulseCount,
|
||||
moodCount,
|
||||
complianceCount,
|
||||
sleepCount,
|
||||
profile,
|
||||
] = await Promise.all([
|
||||
prisma.measurement.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
measuredAt: { gte: start, lte: end },
|
||||
type: { in: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"] },
|
||||
},
|
||||
}),
|
||||
prisma.measurement.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
measuredAt: { gte: start, lte: end },
|
||||
type: "WEIGHT",
|
||||
},
|
||||
}),
|
||||
prisma.measurement.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
measuredAt: { gte: start, lte: end },
|
||||
type: "PULSE",
|
||||
},
|
||||
}),
|
||||
prisma.moodEntry.count({
|
||||
where: { userId: user.id, moodLoggedAt: { gte: start, lte: end } },
|
||||
}),
|
||||
prisma.medicationIntakeEvent.count({
|
||||
where: { userId: user.id, scheduledFor: { gte: start, lte: end } },
|
||||
}),
|
||||
prisma.measurement.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
measuredAt: { gte: start, lte: end },
|
||||
type: "SLEEP_DURATION",
|
||||
},
|
||||
}),
|
||||
prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { heightCm: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
// BMI is derived (latest weight × profile height) so it only shows up
|
||||
// when BOTH inputs are available — otherwise the user would tick a
|
||||
// box that produces a silently-empty section.
|
||||
const bmi = weightCount > 0 && (profile?.heightCm ?? null) !== null;
|
||||
|
||||
const availability: DoctorReportAvailability = {
|
||||
bp: bpCount > 0,
|
||||
weight: weightCount > 0,
|
||||
pulse: pulseCount > 0,
|
||||
bmi,
|
||||
mood: moodCount > 0,
|
||||
compliance: complianceCount > 0,
|
||||
sleep: sleepCount > 0,
|
||||
};
|
||||
|
||||
annotate({
|
||||
meta: {
|
||||
report_days: range.days,
|
||||
sections_available: Object.values(availability).filter(Boolean).length,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(availability);
|
||||
});
|
||||
@@ -11,9 +11,15 @@ import {
|
||||
} from "@/lib/doctor-report-data";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
|
||||
import {
|
||||
DEFAULT_DOCTOR_REPORT_PREFS,
|
||||
doctorReportPrefsSchema,
|
||||
type DoctorReportPrefs,
|
||||
} from "@/lib/validations/doctor-report-prefs";
|
||||
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
||||
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
/**
|
||||
* Server-rendered PDF doctor report.
|
||||
@@ -47,6 +53,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const rawPracticeName = body?.practiceName;
|
||||
const practiceName = sanitisePracticeName(rawPracticeName);
|
||||
|
||||
// v1.4.25 W6c — section toggles. Malformed shapes silently fall back
|
||||
// to documented defaults so a forward-compat client never lands a
|
||||
// 422 on a benign drift.
|
||||
const sectionsParsed = doctorReportPrefsSchema.safeParse(
|
||||
body?.sections ?? {},
|
||||
);
|
||||
const sectionsInput = sectionsParsed.success ? sectionsParsed.data : {};
|
||||
const sections: DoctorReportPrefs = {
|
||||
...DEFAULT_DOCTOR_REPORT_PREFS,
|
||||
...sectionsInput,
|
||||
};
|
||||
|
||||
if (typeof rawPracticeName === "string" && practiceName !== null) {
|
||||
try {
|
||||
await prisma.user.update({
|
||||
@@ -58,10 +76,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
const data = await collectDoctorReportData(user.id, range, { practiceName });
|
||||
const [data, userTz] = await Promise.all([
|
||||
collectDoctorReportData(user.id, range, { practiceName, sections }),
|
||||
resolveUserTimezone(user.id),
|
||||
]);
|
||||
|
||||
const { t } = getServerTranslator(locale);
|
||||
const pdfBytes = renderDoctorReportPdfBytes(data, { t, locale });
|
||||
const pdfBytes = renderDoctorReportPdfBytes(data, { t, locale, userTz });
|
||||
|
||||
await auditLog("doctor-report.pdf.generate", {
|
||||
userId: user.id,
|
||||
@@ -72,6 +93,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
endDate: range.end.toISOString(),
|
||||
locale,
|
||||
practiceNameProvided: practiceName !== null,
|
||||
// v1.4.25 W6c — record exactly which sections this report
|
||||
// rendered, so a future privacy review can confirm mood was
|
||||
// never rendered when the user opted out.
|
||||
sections,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -107,6 +132,8 @@ interface PdfRequestBody {
|
||||
endDate?: unknown;
|
||||
locale?: unknown;
|
||||
practiceName?: unknown;
|
||||
/** v1.4.25 W6c — per-section visibility toggles. */
|
||||
sections?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
sanitisePracticeName,
|
||||
} from "@/lib/doctor-report-data";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { doctorReportPrefsSchema } from "@/lib/validations/doctor-report-prefs";
|
||||
|
||||
/**
|
||||
* Collect data for doctor report PDF generation (client-side).
|
||||
@@ -51,6 +52,13 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
?.practiceName;
|
||||
const practiceName = sanitisePracticeName(rawPracticeName);
|
||||
|
||||
// v1.4.25 W6c — section toggles. Malformed shapes silently fall
|
||||
// through to the documented defaults so a forward-compat client
|
||||
// never lands a 422 on a benign drift.
|
||||
const rawSections = (body as Record<string, unknown> | null)?.sections;
|
||||
const sectionsParsed = doctorReportPrefsSchema.safeParse(rawSections ?? {});
|
||||
const sections = sectionsParsed.success ? sectionsParsed.data : {};
|
||||
|
||||
// Persist the most-recent practice name as a user preference so the
|
||||
// dialog can pre-fill it next time. We only write when the caller
|
||||
// actually supplied a non-empty string — passing `null`/empty does NOT
|
||||
@@ -67,7 +75,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
const data = await collectDoctorReportData(user.id, range, { practiceName });
|
||||
const data = await collectDoctorReportData(user.id, range, {
|
||||
practiceName,
|
||||
sections,
|
||||
});
|
||||
|
||||
await auditLog("doctor-report.generate", {
|
||||
userId: user.id,
|
||||
@@ -77,6 +88,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
startDate: range.start.toISOString(),
|
||||
endDate: range.end.toISOString(),
|
||||
practiceNameProvided: practiceName !== null,
|
||||
// v1.4.25 W6c — record which sections were included. The audit
|
||||
// trail proves mood data was never aggregated when the toggle
|
||||
// was off (privacy contract).
|
||||
sections,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiError, getClientIp } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { toCSV, formatMeasurementsForExport } from "@/lib/export";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
@@ -37,12 +38,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { since, until } = parseRange(request.url);
|
||||
const where = buildWhere(user.id, { since, until });
|
||||
|
||||
const measurements = await prisma.measurement.findMany({
|
||||
where,
|
||||
orderBy: { measuredAt: "desc" },
|
||||
});
|
||||
const [measurements, userTz] = await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where,
|
||||
orderBy: { measuredAt: "desc" },
|
||||
}),
|
||||
resolveUserTimezone(user.id),
|
||||
]);
|
||||
|
||||
const csv = toCSV(formatMeasurementsForExport(measurements));
|
||||
const csv = toCSV(formatMeasurementsForExport(measurements, userTz));
|
||||
|
||||
await auditLog("user.export.measurements", {
|
||||
userId: user.id,
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
formatMedicationsForExport,
|
||||
formatIntakeEventsForExport,
|
||||
} from "@/lib/export";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
@@ -48,23 +49,26 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
// without a matching migration); a wildcard `include: { schedules: true }`
|
||||
// would 500 on the missing column. Pin the columns instead — the export
|
||||
// shape doesn't need every future field anyway.
|
||||
const medications = await prisma.medication.findMany({
|
||||
where: { userId: user.id },
|
||||
select: {
|
||||
name: true,
|
||||
dose: true,
|
||||
active: true,
|
||||
schedules: {
|
||||
select: {
|
||||
windowStart: true,
|
||||
windowEnd: true,
|
||||
label: true,
|
||||
dose: true,
|
||||
const [medications, userTz] = await Promise.all([
|
||||
prisma.medication.findMany({
|
||||
where: { userId: user.id },
|
||||
select: {
|
||||
name: true,
|
||||
dose: true,
|
||||
active: true,
|
||||
schedules: {
|
||||
select: {
|
||||
windowStart: true,
|
||||
windowEnd: true,
|
||||
label: true,
|
||||
dose: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
resolveUserTimezone(user.id),
|
||||
]);
|
||||
|
||||
const sections: string[] = [
|
||||
"# Medications",
|
||||
@@ -91,7 +95,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
intakeCount = events.length;
|
||||
sections.push("# Intake history");
|
||||
sections.push(toCSV(formatIntakeEventsForExport(events)));
|
||||
sections.push(toCSV(formatIntakeEventsForExport(events, userTz)));
|
||||
}
|
||||
|
||||
const body = sections.join("\n\n");
|
||||
|
||||
@@ -19,6 +19,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiError, getClientIp } from "@/lib/api-response";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { toCSV, formatMoodEntriesForExport } from "@/lib/export";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
@@ -50,12 +51,15 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
if (until) where.moodLoggedAt.lte = until;
|
||||
}
|
||||
|
||||
const entries = await prisma.moodEntry.findMany({
|
||||
where,
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
});
|
||||
const [entries, userTz] = await Promise.all([
|
||||
prisma.moodEntry.findMany({
|
||||
where,
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
}),
|
||||
resolveUserTimezone(user.id),
|
||||
]);
|
||||
|
||||
const csv = toCSV(formatMoodEntriesForExport(entries));
|
||||
const csv = toCSV(formatMoodEntriesForExport(entries, userTz));
|
||||
|
||||
await auditLog("user.export.mood", {
|
||||
userId: user.id,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
formatIntakeEventsForExport,
|
||||
formatMoodEntriesForExport,
|
||||
} from "@/lib/export";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
@@ -48,6 +49,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
}
|
||||
|
||||
const userId = user.id;
|
||||
const userTz = await resolveUserTimezone(userId);
|
||||
|
||||
const data: Record<string, unknown> = {};
|
||||
|
||||
@@ -58,8 +60,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
data.measurements =
|
||||
format === "csv"
|
||||
? toCSV(formatMeasurementsForExport(measurements))
|
||||
: formatMeasurementsForExport(measurements);
|
||||
? toCSV(formatMeasurementsForExport(measurements, userTz))
|
||||
: formatMeasurementsForExport(measurements, userTz);
|
||||
}
|
||||
|
||||
if (type === "medications" || type === "all") {
|
||||
@@ -82,8 +84,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
data.intakeEvents =
|
||||
format === "csv"
|
||||
? toCSV(formatIntakeEventsForExport(events))
|
||||
: formatIntakeEventsForExport(events);
|
||||
? toCSV(formatIntakeEventsForExport(events, userTz))
|
||||
: formatIntakeEventsForExport(events, userTz);
|
||||
}
|
||||
|
||||
if (type === "mood" || type === "all") {
|
||||
@@ -93,8 +95,8 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
});
|
||||
data.moodEntries =
|
||||
format === "csv"
|
||||
? toCSV(formatMoodEntriesForExport(moodEntries))
|
||||
: formatMoodEntriesForExport(moodEntries);
|
||||
? toCSV(formatMoodEntriesForExport(moodEntries, userTz))
|
||||
: formatMoodEntriesForExport(moodEntries, userTz);
|
||||
}
|
||||
|
||||
await auditLog("export.download", {
|
||||
|
||||
@@ -206,12 +206,22 @@ async function handleChatRequest(request: NextRequest): Promise<Response> {
|
||||
// the new user message. v1.4.23 H4 — fold per-user prefs into the
|
||||
// system-prompt prefix; the snapshot builder reads the same prefs
|
||||
// separately so excluded metrics never even leave the DB.
|
||||
//
|
||||
// v1.4.25 W5 — `coachPrefs.defaultWindow` is the user's saved
|
||||
// analysis-window preference. Merge it into the snapshot scope when
|
||||
// the client didn't supply a per-conversation override; the override
|
||||
// (header pill / sources rail) always wins. Keep the merge cheap so
|
||||
// we don't accidentally widen narrow per-call scopes.
|
||||
const prefsRow = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { coachPrefsJson: true },
|
||||
});
|
||||
const coachPrefs = parseCoachPrefs(prefsRow?.coachPrefsJson);
|
||||
const snapshot = await buildCoachSnapshot(userId, scope);
|
||||
const effectiveScope =
|
||||
scope?.window === undefined && coachPrefs.defaultWindow
|
||||
? { ...(scope ?? {}), window: coachPrefs.defaultWindow }
|
||||
: scope;
|
||||
const snapshot = await buildCoachSnapshot(userId, effectiveScope);
|
||||
const systemPrompt = getCoachSystemPrompt(locale, coachPrefs);
|
||||
const window = buildHistoryWindow([
|
||||
...priorTurns,
|
||||
@@ -265,7 +275,19 @@ Reply now as the assistant, in ${locale === "de" ? "German" : "English"}.`;
|
||||
firstStatus: err.attempts[0]?.httpStatus ?? null,
|
||||
},
|
||||
});
|
||||
return streamProviderError({ code: "coach.provider.unavailable" });
|
||||
// v1.4.25 W5 — distinguish provider rate-limit (every attempt
|
||||
// landed on 429) from generic unavailability. The drawer's
|
||||
// error-decoder surfaces the rate-limit copy with a warning
|
||||
// toast instead of the generic provider-down message, so the
|
||||
// user understands the limit is transient.
|
||||
const allRateLimited =
|
||||
err.attempts.length > 0 &&
|
||||
err.attempts.every((a) => a.httpStatus === 429);
|
||||
return streamProviderError({
|
||||
code: allRateLimited
|
||||
? "coach.provider.rate_limited"
|
||||
: "coach.provider.unavailable",
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
generateGeneralStatusForUser,
|
||||
resolveGeneralStatusLocale,
|
||||
} from "@/lib/insights/general-status";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { resolveServerLocale } from "@/lib/i18n/server-locale";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const localeParam = request.nextUrl.searchParams.get("locale");
|
||||
const resolved = await resolveServerLocale({
|
||||
request,
|
||||
userLocale: user.locale ?? null,
|
||||
override: localeParam,
|
||||
});
|
||||
const locale = resolveGeneralStatusLocale(resolved);
|
||||
|
||||
const result = await generateGeneralStatusForUser(user.id, {
|
||||
locale,
|
||||
force: false,
|
||||
});
|
||||
|
||||
annotate({ action: { name: "insights.general-status" } });
|
||||
|
||||
return apiSuccess(result);
|
||||
});
|
||||
@@ -2,6 +2,10 @@ import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { extractFeatures } from "@/lib/insights/features";
|
||||
import {
|
||||
detectGlp1Plateau,
|
||||
buildGlp1PlateauPrompt,
|
||||
} from "@/lib/insights/glp1-plateau";
|
||||
import {
|
||||
buildUserPrompt,
|
||||
type ComparisonSnapshot,
|
||||
@@ -266,12 +270,23 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
// narrative is default-on whenever the toggle is on per research §7
|
||||
// Q4 — the pulldown is the single affordance.
|
||||
const comparisonSnapshot = await buildComparisonSnapshotForUser(userId);
|
||||
const userPrompt = buildUserPrompt(
|
||||
// v1.4.25 W4d — GLP-1 plateau detector. Appends a SYSTEM CONTEXT
|
||||
// block to the user prompt when the active GLP-1 medication has been
|
||||
// on a stable dose for ≥21 days with weight delta within ±0.5 kg.
|
||||
// The block instructs the model to emit a glp1_plateau keyFinding
|
||||
// framed observationally (no dose recommendation per GROUND RULE 14).
|
||||
// Returns null when no GLP-1 is active OR no plateau condition;
|
||||
// 99% of users land in the null branch and pay zero token cost.
|
||||
const plateauContext = await detectGlp1Plateau(userId);
|
||||
let userPrompt = buildUserPrompt(
|
||||
featuresJson,
|
||||
dbUser?.insightsPrivacyMode ?? "aggregated",
|
||||
locale,
|
||||
comparisonSnapshot ?? undefined,
|
||||
);
|
||||
if (plateauContext) {
|
||||
userPrompt += buildGlp1PlateauPrompt(plateauContext, locale);
|
||||
}
|
||||
|
||||
let result;
|
||||
let workingProviderType: string;
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* v1.4.25 W4d — GLP-1 therapy-timeline aggregator.
|
||||
*
|
||||
* Returns a chronological merge of every GLP-1-relevant event the user
|
||||
* has logged: dose changes, injections (with site if recorded),
|
||||
* inventory events, and side-effect-tagged mood entries from the
|
||||
* trailing 90 days. Used by /insights/medikamente's TherapyTimeline
|
||||
* component. Web-only users without a GLP-1 medication get
|
||||
* `hasGlp1: false` so the component hides cleanly.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
const DEFAULT_LIMIT = 60;
|
||||
const SIDE_EFFECT_TAGS = new Set([
|
||||
"nausea",
|
||||
"constipation",
|
||||
"diarrhea",
|
||||
"fatigue",
|
||||
"appetite-loss",
|
||||
"heartburn",
|
||||
"headache",
|
||||
"übelkeit",
|
||||
"verstopfung",
|
||||
"durchfall",
|
||||
"müdigkeit",
|
||||
"appetitlosigkeit",
|
||||
"sodbrennen",
|
||||
"kopfschmerzen",
|
||||
]);
|
||||
|
||||
interface TimelineEntry {
|
||||
date: string;
|
||||
kind: "dose-change" | "injection" | "inventory" | "side-effect";
|
||||
medicationName?: string;
|
||||
doseValue?: number;
|
||||
doseUnit?: string;
|
||||
doseDelta?: "up" | "down" | null;
|
||||
note?: string | null;
|
||||
injectionSite?: string | null;
|
||||
inventoryDelta?: number;
|
||||
reason?: string;
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
function parseTagList(raw: string | null): string[] {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
.map((v) => (typeof v === "string" ? v.trim() : ""))
|
||||
.filter(Boolean);
|
||||
}
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return raw
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
const url = new URL(request.url);
|
||||
const rawLimit = Number(url.searchParams.get("limit"));
|
||||
const limit =
|
||||
Number.isInteger(rawLimit) && rawLimit > 0 && rawLimit <= 200
|
||||
? rawLimit
|
||||
: DEFAULT_LIMIT;
|
||||
|
||||
const meds = await prisma.medication.findMany({
|
||||
where: { userId: user.id, treatmentClass: "GLP1" },
|
||||
include: {
|
||||
doseChanges: { orderBy: { effectiveFrom: "asc" } },
|
||||
intakeEvents: {
|
||||
where: { takenAt: { not: null } },
|
||||
orderBy: { takenAt: "desc" },
|
||||
take: 50,
|
||||
},
|
||||
inventoryEvents: { orderBy: { occurredAt: "desc" }, take: 20 },
|
||||
},
|
||||
});
|
||||
|
||||
if (meds.length === 0) {
|
||||
return apiSuccess({ hasGlp1: false, entries: [] });
|
||||
}
|
||||
|
||||
const ninetyDaysAgo = new Date();
|
||||
ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90);
|
||||
const moods = await prisma.moodEntry.findMany({
|
||||
where: { userId: user.id, moodLoggedAt: { gte: ninetyDaysAgo } },
|
||||
select: { moodLoggedAt: true, tags: true },
|
||||
orderBy: { moodLoggedAt: "desc" },
|
||||
});
|
||||
|
||||
const entries: TimelineEntry[] = [];
|
||||
|
||||
for (const med of meds) {
|
||||
const sortedChanges = [...med.doseChanges];
|
||||
for (let i = 0; i < sortedChanges.length; i += 1) {
|
||||
const dc = sortedChanges[i];
|
||||
const prev = i > 0 ? sortedChanges[i - 1] : null;
|
||||
let delta: "up" | "down" | null = null;
|
||||
if (prev) {
|
||||
if (dc.doseValue > prev.doseValue) delta = "up";
|
||||
else if (dc.doseValue < prev.doseValue) delta = "down";
|
||||
}
|
||||
entries.push({
|
||||
date: dc.effectiveFrom.toISOString(),
|
||||
kind: "dose-change",
|
||||
medicationName: med.name,
|
||||
doseValue: dc.doseValue,
|
||||
doseUnit: dc.doseUnit,
|
||||
doseDelta: delta,
|
||||
note: dc.note,
|
||||
});
|
||||
}
|
||||
for (const intake of med.intakeEvents) {
|
||||
if (!intake.takenAt) continue;
|
||||
entries.push({
|
||||
date: intake.takenAt.toISOString(),
|
||||
kind: "injection",
|
||||
medicationName: med.name,
|
||||
injectionSite: intake.injectionSite,
|
||||
});
|
||||
}
|
||||
for (const inv of med.inventoryEvents) {
|
||||
entries.push({
|
||||
date: inv.occurredAt.toISOString(),
|
||||
kind: "inventory",
|
||||
medicationName: med.name,
|
||||
inventoryDelta: inv.delta,
|
||||
reason: inv.reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Side-effect days — collapse to one entry per day (most recent
|
||||
// first) listing every recognised tag picked up that day.
|
||||
const sideEffectByDay = new Map<string, string[]>();
|
||||
for (const mood of moods) {
|
||||
const tags = parseTagList(mood.tags).filter((t) =>
|
||||
SIDE_EFFECT_TAGS.has(t.toLowerCase()),
|
||||
);
|
||||
if (tags.length === 0) continue;
|
||||
const dayKey = mood.moodLoggedAt.toISOString().slice(0, 10);
|
||||
const existing = sideEffectByDay.get(dayKey) ?? [];
|
||||
for (const t of tags) {
|
||||
if (!existing.includes(t)) existing.push(t);
|
||||
}
|
||||
sideEffectByDay.set(dayKey, existing);
|
||||
}
|
||||
for (const [day, tags] of sideEffectByDay) {
|
||||
entries.push({
|
||||
date: `${day}T12:00:00Z`,
|
||||
kind: "side-effect",
|
||||
tags,
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort((a, b) => b.date.localeCompare(a.date));
|
||||
|
||||
return apiSuccess({ hasGlp1: true, entries: entries.slice(0, limit) });
|
||||
});
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { calculateCompliance } from "@/lib/analytics/compliance";
|
||||
import { pairByTimestamp } from "@/lib/analytics/correlations";
|
||||
import { isBpReadingInTarget } from "@/lib/analytics/bp-in-target";
|
||||
import { berlinDayKey } from "@/lib/analytics/berlin-day";
|
||||
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
import {
|
||||
classifyPulseByTarget,
|
||||
getPersonalizedPulseTarget,
|
||||
@@ -58,18 +58,38 @@ interface TargetItem {
|
||||
classification: { category: string; color: string } | null;
|
||||
source: string;
|
||||
/**
|
||||
* v1.4.22 C1 — sparkline support on the Zielwerte page. `points30d`
|
||||
* is the last 30 days of values (chronological, oldest → newest) for
|
||||
* the target's primary metric, rounded to one decimal. Derived metrics
|
||||
* (BMI, BP-in-target, mood stability, glucose-by-context) skip the
|
||||
* field when the page already renders a richer status surface.
|
||||
* `deltaVsLastMonth` is the signed difference between the current
|
||||
* 30-day average and the prior 30-day average (current − prior),
|
||||
* null when either window has fewer than 3 points to keep the
|
||||
* comparison honest.
|
||||
* v1.4.25 W3e — consistency strip support. Berlin-tz day buckets over
|
||||
* the last 7 days. `daysInRange7d` is the count of days whose mean
|
||||
* reading landed inside the target's green band; `daysLogged7d` is
|
||||
* how many of those days had at least one reading (so the strip can
|
||||
* render filled / hollow / dimmed dots without ambiguity).
|
||||
*
|
||||
* `lastMetGoalAt` is the most recent Berlin-tz day whose mean reading
|
||||
* was in the green band (ISO date YYYY-MM-DD), or null if the user
|
||||
* has not hit goal in the last 30 days.
|
||||
*
|
||||
* `streakDays` is the count of consecutive Berlin days ending today
|
||||
* where the day's mean was in the green band. Capped at 365.
|
||||
*
|
||||
* `insufficientData` is true when the target has fewer than 3
|
||||
* readings over the last 30 days OR fewer than 1 day of data in the
|
||||
* last 7 days. The page hides the strip + percentage when this is
|
||||
* true.
|
||||
*/
|
||||
points30d?: number[] | null;
|
||||
deltaVsLastMonth?: number | null;
|
||||
daysInRange7d: number;
|
||||
daysLogged7d: number;
|
||||
daysInRange30d: number;
|
||||
daysLogged30d: number;
|
||||
lastMetGoalAt: string | null;
|
||||
streakDays: number;
|
||||
insufficientData: boolean;
|
||||
/**
|
||||
* v1.4.25 W3e — per-day classification of the last 7 Berlin-tz days
|
||||
* for the consistency strip. Index 0 is six days ago, index 6 is
|
||||
* today. `null` slots represent days with no readings; non-null
|
||||
* slots represent days whose mean reading landed in the named band.
|
||||
*/
|
||||
consistency7d: ReadonlyArray<"in" | "near" | "out" | null>;
|
||||
details?: {
|
||||
medications?: Array<{
|
||||
name: string;
|
||||
@@ -79,11 +99,26 @@ interface TargetItem {
|
||||
};
|
||||
}
|
||||
|
||||
interface TargetPageSummary {
|
||||
targetsMetThisWeek: number;
|
||||
totalTargets: number;
|
||||
streakHighlight: { metric: string; days: number } | null;
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const userId = user.id;
|
||||
|
||||
// v1.4.25 W7b — every per-day bucket key in this route resolves
|
||||
// against the user's display timezone so a Pacific/Auckland user gets
|
||||
// their Auckland-day streaks, their Auckland-day "last met goal" date,
|
||||
// and their Auckland-day consistency strip. Falls back to
|
||||
// Europe/Berlin when the column is somehow missing (defensive — the
|
||||
// schema's NOT NULL default normally pins it).
|
||||
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
const dayKey = (d: Date): string => userDayKey(d, userTz);
|
||||
|
||||
// Fetch user profile
|
||||
const dbUser = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
@@ -112,28 +147,17 @@ export const GET = apiHandler(async () => {
|
||||
];
|
||||
|
||||
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
||||
// v1.4.22 C1 — second 30-day window (days 31–60) so each target card
|
||||
// can render a "Δ vs. last month" caption alongside its sparkline.
|
||||
// We fetch 60 days in one query to keep the route round-trip count
|
||||
// identical to the previous version.
|
||||
const sixtyDaysAgo = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Fetch all measurements in the last 60 days + the latest for each type
|
||||
const windowedMeasurements = await prisma.measurement.findMany({
|
||||
// Fetch all measurements in the last 30 days + the latest for each type
|
||||
const recentMeasurements = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId,
|
||||
type: { in: types },
|
||||
measuredAt: { gte: sixtyDaysAgo },
|
||||
measuredAt: { gte: thirtyDaysAgo },
|
||||
},
|
||||
orderBy: { measuredAt: "desc" },
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
});
|
||||
const recentMeasurements = windowedMeasurements.filter(
|
||||
(m) => m.measuredAt >= thirtyDaysAgo,
|
||||
);
|
||||
const priorMeasurements = windowedMeasurements.filter(
|
||||
(m) => m.measuredAt < thirtyDaysAgo,
|
||||
);
|
||||
|
||||
// Also get the absolute latest measurement per type (even if older than 30 days).
|
||||
// Single grouped query replaces the previous N×findFirst loop — DISTINCT ON
|
||||
@@ -188,50 +212,321 @@ export const GET = apiHandler(async () => {
|
||||
return "stable";
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// v1.4.25 W3e — consistency helper. Buckets readings by Berlin-tz day
|
||||
// and classifies each day's mean against the target's green band.
|
||||
// Single helper shared by every target type so the rule stays stable
|
||||
// (mean reading per day → in-range / near-range / out-of-range).
|
||||
// --------------------------------------------------------------------
|
||||
const last7DayKeys: string[] = [];
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
|
||||
last7DayKeys.push(dayKey(d));
|
||||
}
|
||||
|
||||
type DayBand = "in" | "near" | "out";
|
||||
|
||||
interface ConsistencyOutput {
|
||||
daysInRange7d: number;
|
||||
daysLogged7d: number;
|
||||
daysInRange30d: number;
|
||||
daysLogged30d: number;
|
||||
lastMetGoalAt: string | null;
|
||||
streakDays: number;
|
||||
insufficientData: boolean;
|
||||
consistency7d: ReadonlyArray<DayBand | null>;
|
||||
}
|
||||
|
||||
const EMPTY_CONSISTENCY: ConsistencyOutput = {
|
||||
daysInRange7d: 0,
|
||||
daysLogged7d: 0,
|
||||
daysInRange30d: 0,
|
||||
daysLogged30d: 0,
|
||||
lastMetGoalAt: null,
|
||||
streakDays: 0,
|
||||
insufficientData: true,
|
||||
consistency7d: [null, null, null, null, null, null, null] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* v1.4.22 C1 — sparkline + delta helpers. The sparkline collapses
|
||||
* the last 30 days of values for a measurement type into a daily
|
||||
* mean series (chronological, oldest → newest). The delta is the
|
||||
* signed difference between the current 30-day window's average
|
||||
* and the prior 30-day window's average. Both helpers skip the
|
||||
* computation when there are fewer than 3 points in a window so
|
||||
* the comparison stays honest on cold-start accounts.
|
||||
* Bucket a series of `{ measuredAt, value }` events by Berlin-tz day,
|
||||
* classify each day's mean against the target band, and roll up the
|
||||
* 7-day strip + 30-day stats + recency + streak.
|
||||
*
|
||||
* `classify(meanValue)` returns the band the day's mean falls into.
|
||||
* Pass `null` to mean "no band — skip this day for classification".
|
||||
*/
|
||||
function sparklinePoints(type: MeasurementType): number[] | null {
|
||||
const data = recentMeasurements
|
||||
.filter((m) => m.type === type)
|
||||
.sort((a, b) => a.measuredAt.getTime() - b.measuredAt.getTime());
|
||||
if (data.length < 3) return null;
|
||||
function rollupConsistency(
|
||||
events: ReadonlyArray<{ measuredAt: Date; value: number }>,
|
||||
classify: (mean: number) => DayBand | null,
|
||||
totalReadingsThreshold = 3,
|
||||
): ConsistencyOutput {
|
||||
if (events.length < totalReadingsThreshold) {
|
||||
// Cold-start path. Still emit the per-day strip if we have any
|
||||
// recent readings, but mark insufficient so the UI hides the %.
|
||||
const partial = computeStrip(events, classify);
|
||||
return {
|
||||
...EMPTY_CONSISTENCY,
|
||||
consistency7d: partial.consistency7d,
|
||||
daysLogged7d: partial.daysLogged7d,
|
||||
daysInRange7d: partial.daysInRange7d,
|
||||
insufficientData: true,
|
||||
};
|
||||
}
|
||||
|
||||
// v1.4.22 W5 reconcile (Code-MED-3) — bucket by Berlin calendar
|
||||
// day, not UTC. A 23:30-Berlin reading on Tuesday otherwise
|
||||
// lands in Wednesday's UTC bucket and the sparkline drifts a
|
||||
// day on cross-DST boundaries. Aligns with `berlinDayKey()`
|
||||
// already used by the dashboard analytics route.
|
||||
const byDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const m of data) {
|
||||
const day = berlinDayKey(m.measuredAt);
|
||||
for (const ev of events) {
|
||||
const day = dayKey(ev.measuredAt);
|
||||
const bucket = byDay.get(day) ?? { sum: 0, count: 0 };
|
||||
bucket.sum += m.value;
|
||||
bucket.sum += ev.value;
|
||||
bucket.count += 1;
|
||||
byDay.set(day, bucket);
|
||||
}
|
||||
return Array.from(byDay.values()).map(
|
||||
(b) => Math.round((b.sum / b.count) * 10) / 10,
|
||||
);
|
||||
|
||||
const stripPart = computeStrip(events, classify);
|
||||
|
||||
// 30-day stats: walk the last 30 Berlin-tz day keys.
|
||||
let daysInRange30d = 0;
|
||||
let daysLogged30d = 0;
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
|
||||
const key = dayKey(d);
|
||||
const bucket = byDay.get(key);
|
||||
if (!bucket) continue;
|
||||
daysLogged30d += 1;
|
||||
const mean = bucket.sum / bucket.count;
|
||||
if (classify(mean) === "in") daysInRange30d += 1;
|
||||
}
|
||||
|
||||
// lastMetGoalAt: walk back from today until a day is in range.
|
||||
let lastMetGoalAt: string | null = null;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
|
||||
const key = dayKey(d);
|
||||
const bucket = byDay.get(key);
|
||||
if (!bucket) continue;
|
||||
const mean = bucket.sum / bucket.count;
|
||||
if (classify(mean) === "in") {
|
||||
lastMetGoalAt = key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// streakDays: consecutive days ending today that are in-range. A
|
||||
// day with no reading breaks the streak (the user did not meet the
|
||||
// goal that day, even if today is in-range).
|
||||
let streakDays = 0;
|
||||
for (let i = 0; i < 365; i++) {
|
||||
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
|
||||
const key = dayKey(d);
|
||||
const bucket = byDay.get(key);
|
||||
if (!bucket) break;
|
||||
const mean = bucket.sum / bucket.count;
|
||||
if (classify(mean) !== "in") break;
|
||||
streakDays += 1;
|
||||
}
|
||||
|
||||
// insufficientData: < 3 readings total OR < 1 day in the last 7.
|
||||
const insufficientData =
|
||||
events.length < totalReadingsThreshold || stripPart.daysLogged7d < 1;
|
||||
|
||||
return {
|
||||
daysInRange7d: stripPart.daysInRange7d,
|
||||
daysLogged7d: stripPart.daysLogged7d,
|
||||
daysInRange30d,
|
||||
daysLogged30d,
|
||||
lastMetGoalAt,
|
||||
streakDays,
|
||||
insufficientData,
|
||||
consistency7d: stripPart.consistency7d,
|
||||
};
|
||||
}
|
||||
|
||||
function deltaVsLastMonth(type: MeasurementType): number | null {
|
||||
const recentOfType = recentMeasurements.filter((m) => m.type === type);
|
||||
const priorOfType = priorMeasurements.filter((m) => m.type === type);
|
||||
if (recentOfType.length < 3 || priorOfType.length < 3) return null;
|
||||
const recentAvg =
|
||||
recentOfType.reduce((s, m) => s + m.value, 0) / recentOfType.length;
|
||||
const priorAvg =
|
||||
priorOfType.reduce((s, m) => s + m.value, 0) / priorOfType.length;
|
||||
return Math.round((recentAvg - priorAvg) * 10) / 10;
|
||||
function computeStrip(
|
||||
events: ReadonlyArray<{ measuredAt: Date; value: number }>,
|
||||
classify: (mean: number) => DayBand | null,
|
||||
): {
|
||||
consistency7d: ReadonlyArray<DayBand | null>;
|
||||
daysInRange7d: number;
|
||||
daysLogged7d: number;
|
||||
} {
|
||||
const byDay = new Map<string, { sum: number; count: number }>();
|
||||
for (const ev of events) {
|
||||
const day = dayKey(ev.measuredAt);
|
||||
const bucket = byDay.get(day) ?? { sum: 0, count: 0 };
|
||||
bucket.sum += ev.value;
|
||||
bucket.count += 1;
|
||||
byDay.set(day, bucket);
|
||||
}
|
||||
const strip: Array<DayBand | null> = [];
|
||||
let daysInRange7d = 0;
|
||||
let daysLogged7d = 0;
|
||||
for (const key of last7DayKeys) {
|
||||
const bucket = byDay.get(key);
|
||||
if (!bucket) {
|
||||
strip.push(null);
|
||||
continue;
|
||||
}
|
||||
daysLogged7d += 1;
|
||||
const mean = bucket.sum / bucket.count;
|
||||
const band = classify(mean);
|
||||
strip.push(band);
|
||||
if (band === "in") daysInRange7d += 1;
|
||||
}
|
||||
return { consistency7d: strip, daysInRange7d, daysLogged7d };
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: classify a value against a target range with an orange band
|
||||
* symmetrical to the green band (the same heuristic RangeBar uses
|
||||
* client-side). When `min`/`max` is null we treat the input as
|
||||
* unclassifiable and return null.
|
||||
*/
|
||||
function makeRangeClassifier(
|
||||
range: { min: number; max: number } | null,
|
||||
options?: { orangeMin?: number; orangeMax?: number },
|
||||
): (mean: number) => DayBand | null {
|
||||
if (!range) return () => null;
|
||||
const span = range.max - range.min;
|
||||
const orangeMin = options?.orangeMin ?? range.min - span * 0.3;
|
||||
const orangeMax = options?.orangeMax ?? range.max + span * 0.3;
|
||||
return (mean: number) => {
|
||||
if (mean >= range.min && mean <= range.max) return "in";
|
||||
if (mean >= orangeMin && mean <= orangeMax) return "near";
|
||||
return "out";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-day BP-in-target classifier: a day is "in" if every BP pair on
|
||||
* that day (sys+dia matched within 5 min) passes `isBpReadingInTarget`.
|
||||
* "near" if at least one pair passes; "out" otherwise.
|
||||
*
|
||||
* Returns a function (day-key, dayEvents) → band. We pre-compute the
|
||||
* by-day map once because the route already paginates BP twice (sys
|
||||
* + dia) and we want to avoid quadratic work.
|
||||
*/
|
||||
function buildBpPairsByDay(): Map<string, DayBand | null> {
|
||||
if (!bpRange) return new Map();
|
||||
const sysByDay = new Map<string, Array<{ date: Date; value: number }>>();
|
||||
const diaByDay = new Map<string, Array<{ date: Date; value: number }>>();
|
||||
for (const m of recentMeasurements) {
|
||||
if (m.type === "BLOOD_PRESSURE_SYS") {
|
||||
const k = dayKey(m.measuredAt);
|
||||
const arr = sysByDay.get(k) ?? [];
|
||||
arr.push({ date: m.measuredAt, value: m.value });
|
||||
sysByDay.set(k, arr);
|
||||
} else if (m.type === "BLOOD_PRESSURE_DIA") {
|
||||
const k = dayKey(m.measuredAt);
|
||||
const arr = diaByDay.get(k) ?? [];
|
||||
arr.push({ date: m.measuredAt, value: m.value });
|
||||
diaByDay.set(k, arr);
|
||||
}
|
||||
}
|
||||
const out = new Map<string, DayBand | null>();
|
||||
const allKeys = new Set([...sysByDay.keys(), ...diaByDay.keys()]);
|
||||
for (const key of allKeys) {
|
||||
const sys = sysByDay.get(key) ?? [];
|
||||
const dia = diaByDay.get(key) ?? [];
|
||||
const pairs = pairByTimestamp(sys, dia, 5 * 60 * 1000);
|
||||
if (pairs.length === 0) {
|
||||
out.set(key, null);
|
||||
continue;
|
||||
}
|
||||
const inCount = pairs.filter((p) =>
|
||||
isBpReadingInTarget(p.a, p.b, bpRange),
|
||||
).length;
|
||||
if (inCount === pairs.length) out.set(key, "in");
|
||||
else if (inCount > 0) out.set(key, "near");
|
||||
else out.set(key, "out");
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// We need bpRange before the per-target loop runs so the helpers above
|
||||
// can use it; reorder the local so it's available.
|
||||
const bpRange = age != null ? getBpTargetsByAge(age, gender) : null;
|
||||
|
||||
/**
|
||||
* Roll up consistency for a target whose bands come from a pre-mapped
|
||||
* `dayBandByKey` map (used for BP-in-target and medication compliance
|
||||
* which have day-level classification rather than mean-classification).
|
||||
*/
|
||||
function rollupFromDayMap(
|
||||
dayBandByKey: Map<string, DayBand | null>,
|
||||
totalDaysThreshold = 3,
|
||||
): ConsistencyOutput {
|
||||
const loggedKeys = Array.from(dayBandByKey.entries()).filter(
|
||||
([, v]) => v !== null,
|
||||
);
|
||||
if (loggedKeys.length < totalDaysThreshold) {
|
||||
// Cold-start: still emit the strip.
|
||||
const strip: Array<DayBand | null> = last7DayKeys.map(
|
||||
(k) => dayBandByKey.get(k) ?? null,
|
||||
);
|
||||
const daysLogged7d = strip.filter((b) => b !== null).length;
|
||||
const daysInRange7d = strip.filter((b) => b === "in").length;
|
||||
return {
|
||||
...EMPTY_CONSISTENCY,
|
||||
consistency7d: strip,
|
||||
daysLogged7d,
|
||||
daysInRange7d,
|
||||
insufficientData: true,
|
||||
};
|
||||
}
|
||||
|
||||
const strip: Array<DayBand | null> = last7DayKeys.map(
|
||||
(k) => dayBandByKey.get(k) ?? null,
|
||||
);
|
||||
const daysLogged7d = strip.filter((b) => b !== null).length;
|
||||
const daysInRange7d = strip.filter((b) => b === "in").length;
|
||||
|
||||
let daysInRange30d = 0;
|
||||
let daysLogged30d = 0;
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
|
||||
const key = dayKey(d);
|
||||
const band = dayBandByKey.get(key);
|
||||
if (!band) continue;
|
||||
daysLogged30d += 1;
|
||||
if (band === "in") daysInRange30d += 1;
|
||||
}
|
||||
|
||||
let lastMetGoalAt: string | null = null;
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
|
||||
const key = dayKey(d);
|
||||
if (dayBandByKey.get(key) === "in") {
|
||||
lastMetGoalAt = key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let streakDays = 0;
|
||||
for (let i = 0; i < 365; i++) {
|
||||
const d = new Date(Date.now() - i * 24 * 60 * 60 * 1000);
|
||||
const key = dayKey(d);
|
||||
const band = dayBandByKey.get(key);
|
||||
if (band === undefined || band === null) break;
|
||||
if (band !== "in") break;
|
||||
streakDays += 1;
|
||||
}
|
||||
|
||||
return {
|
||||
daysInRange7d,
|
||||
daysLogged7d,
|
||||
daysInRange30d,
|
||||
daysLogged30d,
|
||||
lastMetGoalAt,
|
||||
streakDays,
|
||||
insufficientData: loggedKeys.length < totalDaysThreshold,
|
||||
consistency7d: strip,
|
||||
};
|
||||
}
|
||||
|
||||
// Memo: pre-compute the BP-pairs day map once.
|
||||
const bpPairsByDay = buildBpPairsByDay();
|
||||
|
||||
// Build target items
|
||||
const targets: TargetItem[] = [];
|
||||
|
||||
@@ -244,22 +539,28 @@ export const GET = apiHandler(async () => {
|
||||
const cls = classifyBMI(bmi);
|
||||
weightClassification = { category: cls.category, color: cls.color };
|
||||
}
|
||||
targets.push({
|
||||
type: "WEIGHT",
|
||||
label: "Weight",
|
||||
current: latestByType.WEIGHT ?? null,
|
||||
average30: avg30ByType.WEIGHT ?? null,
|
||||
trend: computeTrend("WEIGHT"),
|
||||
unit: "kg",
|
||||
range: weightRange,
|
||||
classification: weightClassification,
|
||||
source: "WHO BMI",
|
||||
points30d: sparklinePoints("WEIGHT"),
|
||||
deltaVsLastMonth: deltaVsLastMonth("WEIGHT"),
|
||||
});
|
||||
{
|
||||
const consistency = rollupConsistency(
|
||||
recentMeasurements.filter((m) => m.type === "WEIGHT"),
|
||||
makeRangeClassifier(weightRange),
|
||||
);
|
||||
targets.push({
|
||||
type: "WEIGHT",
|
||||
label: "Weight",
|
||||
current: latestByType.WEIGHT ?? null,
|
||||
average30: avg30ByType.WEIGHT ?? null,
|
||||
trend: computeTrend("WEIGHT"),
|
||||
unit: "kg",
|
||||
range: weightRange,
|
||||
classification: weightClassification,
|
||||
source: "WHO BMI",
|
||||
...consistency,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Blood Pressure (sys/dia combined)
|
||||
const bpRange = age != null ? getBpTargetsByAge(age, gender) : null;
|
||||
// `bpRange` is hoisted to the helpers block above so the BP-pairs
|
||||
// day-map can use it; do not redeclare here.
|
||||
let bpClassification: { category: string; color: string } | null = null;
|
||||
if (
|
||||
latestByType.BLOOD_PRESSURE_SYS != null &&
|
||||
@@ -271,20 +572,26 @@ export const GET = apiHandler(async () => {
|
||||
);
|
||||
bpClassification = { category: cls.category, color: cls.color };
|
||||
}
|
||||
targets.push({
|
||||
type: "BLOOD_PRESSURE",
|
||||
label: "Blood pressure",
|
||||
current: latestByType.BLOOD_PRESSURE_SYS ?? null,
|
||||
average30: avg30ByType.BLOOD_PRESSURE_SYS ?? null,
|
||||
trend: computeTrend("BLOOD_PRESSURE_SYS"),
|
||||
unit: "mmHg",
|
||||
range: bpRange ? { min: bpRange.sysLow, max: bpRange.sysHigh } : null,
|
||||
classification: bpClassification,
|
||||
source: "ESH 2023",
|
||||
points30d: sparklinePoints("BLOOD_PRESSURE_SYS"),
|
||||
deltaVsLastMonth: deltaVsLastMonth("BLOOD_PRESSURE_SYS"),
|
||||
// Extra fields for diastolic
|
||||
} as TargetItem);
|
||||
{
|
||||
// Per-day BP classifier reuses the sys+dia paired classifier from
|
||||
// `bpPairsByDay`. This is the right semantic for "in target band"
|
||||
// because a systolic in range with a diastolic out of range is
|
||||
// still an out-of-target reading.
|
||||
const consistency = rollupFromDayMap(bpPairsByDay);
|
||||
targets.push({
|
||||
type: "BLOOD_PRESSURE",
|
||||
label: "Blood pressure",
|
||||
current: latestByType.BLOOD_PRESSURE_SYS ?? null,
|
||||
average30: avg30ByType.BLOOD_PRESSURE_SYS ?? null,
|
||||
trend: computeTrend("BLOOD_PRESSURE_SYS"),
|
||||
unit: "mmHg",
|
||||
range: bpRange ? { min: bpRange.sysLow, max: bpRange.sysHigh } : null,
|
||||
classification: bpClassification,
|
||||
source: "ESH 2023",
|
||||
...consistency,
|
||||
// Extra fields for diastolic
|
||||
} as TargetItem);
|
||||
}
|
||||
|
||||
// 2b. Blood pressure target-hit rate over 30 days
|
||||
if (bpRange) {
|
||||
@@ -312,6 +619,9 @@ export const GET = apiHandler(async () => {
|
||||
? Math.round((bpInTargetCount / bpPairs.length) * 100 * 10) / 10
|
||||
: null;
|
||||
|
||||
// BP-in-target consistency mirrors the BP pair-by-day map above:
|
||||
// a day is "in" when every pair on that day clears the BP target.
|
||||
const inTargetConsistency = rollupFromDayMap(bpPairsByDay);
|
||||
targets.push({
|
||||
type: "BLOOD_PRESSURE_IN_TARGET",
|
||||
label: "Blood pressure on target",
|
||||
@@ -329,6 +639,7 @@ export const GET = apiHandler(async () => {
|
||||
: { category: "Low", color: "#ff5555" }
|
||||
: null,
|
||||
source: "ESH 2023",
|
||||
...inTargetConsistency,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -339,19 +650,31 @@ export const GET = apiHandler(async () => {
|
||||
const cls = classifyPulseByTarget(latestByType.PULSE, pulseTarget);
|
||||
pulseClassification = { category: cls.category, color: cls.color };
|
||||
}
|
||||
targets.push({
|
||||
type: "PULSE",
|
||||
label: "Resting pulse",
|
||||
current: latestByType.PULSE ?? null,
|
||||
average30: avg30ByType.PULSE ?? null,
|
||||
trend: computeTrend("PULSE"),
|
||||
unit: "bpm",
|
||||
range: { min: pulseTarget.greenMin, max: pulseTarget.greenMax },
|
||||
classification: pulseClassification,
|
||||
source: pulseTarget.source,
|
||||
points30d: sparklinePoints("PULSE"),
|
||||
deltaVsLastMonth: deltaVsLastMonth("PULSE"),
|
||||
});
|
||||
{
|
||||
const pulseRange = {
|
||||
min: pulseTarget.greenMin,
|
||||
max: pulseTarget.greenMax,
|
||||
};
|
||||
const consistency = rollupConsistency(
|
||||
recentMeasurements.filter((m) => m.type === "PULSE"),
|
||||
makeRangeClassifier(pulseRange, {
|
||||
orangeMin: pulseTarget.orangeMin,
|
||||
orangeMax: pulseTarget.orangeMax,
|
||||
}),
|
||||
);
|
||||
targets.push({
|
||||
type: "PULSE",
|
||||
label: "Resting pulse",
|
||||
current: latestByType.PULSE ?? null,
|
||||
average30: avg30ByType.PULSE ?? null,
|
||||
trend: computeTrend("PULSE"),
|
||||
unit: "bpm",
|
||||
range: pulseRange,
|
||||
classification: pulseClassification,
|
||||
source: pulseTarget.source,
|
||||
...consistency,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Sleep Duration
|
||||
const sleepRange = getSleepDurationRange();
|
||||
@@ -360,19 +683,24 @@ export const GET = apiHandler(async () => {
|
||||
const cls = classifySleepDuration(latestByType.SLEEP_DURATION);
|
||||
sleepClassification = { category: cls.category, color: cls.color };
|
||||
}
|
||||
targets.push({
|
||||
type: "SLEEP_DURATION",
|
||||
label: "Sleep duration",
|
||||
current: latestByType.SLEEP_DURATION ?? null,
|
||||
average30: avg30ByType.SLEEP_DURATION ?? null,
|
||||
trend: computeTrend("SLEEP_DURATION"),
|
||||
unit: "h",
|
||||
range: sleepRange,
|
||||
classification: sleepClassification,
|
||||
source: "AASM/SRS",
|
||||
points30d: sparklinePoints("SLEEP_DURATION"),
|
||||
deltaVsLastMonth: deltaVsLastMonth("SLEEP_DURATION"),
|
||||
});
|
||||
{
|
||||
const consistency = rollupConsistency(
|
||||
recentMeasurements.filter((m) => m.type === "SLEEP_DURATION"),
|
||||
makeRangeClassifier(sleepRange),
|
||||
);
|
||||
targets.push({
|
||||
type: "SLEEP_DURATION",
|
||||
label: "Sleep duration",
|
||||
current: latestByType.SLEEP_DURATION ?? null,
|
||||
average30: avg30ByType.SLEEP_DURATION ?? null,
|
||||
trend: computeTrend("SLEEP_DURATION"),
|
||||
unit: "h",
|
||||
range: sleepRange,
|
||||
classification: sleepClassification,
|
||||
source: "AASM/SRS",
|
||||
...consistency,
|
||||
});
|
||||
}
|
||||
|
||||
// 5. BMI (derived from weight + height)
|
||||
if (heightCm) {
|
||||
@@ -395,19 +723,17 @@ export const GET = apiHandler(async () => {
|
||||
bmiClassification = { category: cls.category, color: cls.color };
|
||||
}
|
||||
|
||||
// BMI sparkline / delta derive from the weight series \u2014 divide each
|
||||
// point by height\u00B2 so the y-axis matches the BMI range bar above.
|
||||
const weightPoints = sparklinePoints("WEIGHT");
|
||||
const bmiPoints =
|
||||
weightPoints != null
|
||||
? weightPoints.map((v) => Math.round((v / heightSq) * 10) / 10)
|
||||
: null;
|
||||
const weightDelta = deltaVsLastMonth("WEIGHT");
|
||||
const bmiDelta =
|
||||
weightDelta != null
|
||||
? Math.round((weightDelta / heightSq) * 10) / 10
|
||||
: null;
|
||||
|
||||
// BMI consistency derives from the weight series: divide each
|
||||
// bucket by height\u00B2 so the day's mean BMI is classified against
|
||||
// the BMI range bar (18.5 \u2014 24.9).
|
||||
const bmiRange = { min: 18.5, max: 24.9 };
|
||||
const bmiEvents = recentMeasurements
|
||||
.filter((m) => m.type === "WEIGHT")
|
||||
.map((m) => ({ measuredAt: m.measuredAt, value: m.value / heightSq }));
|
||||
const consistency = rollupConsistency(
|
||||
bmiEvents,
|
||||
makeRangeClassifier(bmiRange),
|
||||
);
|
||||
targets.push({
|
||||
type: "BMI",
|
||||
label: "BMI",
|
||||
@@ -415,11 +741,10 @@ export const GET = apiHandler(async () => {
|
||||
average30: avgBmi,
|
||||
trend: computeTrend("WEIGHT"), // BMI trend follows weight trend
|
||||
unit: "kg/m\u00B2",
|
||||
range: { min: 18.5, max: 24.9 },
|
||||
range: bmiRange,
|
||||
classification: bmiClassification,
|
||||
source: "WHO",
|
||||
points30d: bmiPoints,
|
||||
deltaVsLastMonth: bmiDelta,
|
||||
...consistency,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -435,19 +760,24 @@ export const GET = apiHandler(async () => {
|
||||
// marathon consolidated them onto `getBodyFatTargetRange` (ACE
|
||||
// fitness + acceptable bands).
|
||||
const bodyFatRange = age != null ? getBodyFatTargetRange(gender) : null;
|
||||
targets.push({
|
||||
type: "BODY_FAT",
|
||||
label: "Body fat",
|
||||
current: latestByType.BODY_FAT ?? null,
|
||||
average30: avg30ByType.BODY_FAT ?? null,
|
||||
trend: computeTrend("BODY_FAT"),
|
||||
unit: "%",
|
||||
range: bodyFatRange,
|
||||
classification: bodyFatClassification,
|
||||
source: "ACE",
|
||||
points30d: sparklinePoints("BODY_FAT"),
|
||||
deltaVsLastMonth: deltaVsLastMonth("BODY_FAT"),
|
||||
});
|
||||
{
|
||||
const consistency = rollupConsistency(
|
||||
recentMeasurements.filter((m) => m.type === "BODY_FAT"),
|
||||
makeRangeClassifier(bodyFatRange),
|
||||
);
|
||||
targets.push({
|
||||
type: "BODY_FAT",
|
||||
label: "Body fat",
|
||||
current: latestByType.BODY_FAT ?? null,
|
||||
average30: avg30ByType.BODY_FAT ?? null,
|
||||
trend: computeTrend("BODY_FAT"),
|
||||
unit: "%",
|
||||
range: bodyFatRange,
|
||||
classification: bodyFatClassification,
|
||||
source: "ACE",
|
||||
...consistency,
|
||||
});
|
||||
}
|
||||
|
||||
// 7. Activity Steps
|
||||
const stepsRange = getStepsRange();
|
||||
@@ -456,25 +786,31 @@ export const GET = apiHandler(async () => {
|
||||
const cls = classifySteps(avg30ByType.ACTIVITY_STEPS);
|
||||
stepsClassification = { category: cls.category, color: cls.color };
|
||||
}
|
||||
targets.push({
|
||||
type: "ACTIVITY_STEPS",
|
||||
label: "Steps/day",
|
||||
current: latestByType.ACTIVITY_STEPS ?? null,
|
||||
average30: avg30ByType.ACTIVITY_STEPS ?? null,
|
||||
trend: computeTrend("ACTIVITY_STEPS"),
|
||||
unit: "steps",
|
||||
range: stepsRange,
|
||||
classification: stepsClassification,
|
||||
points30d: sparklinePoints("ACTIVITY_STEPS"),
|
||||
deltaVsLastMonth: deltaVsLastMonth("ACTIVITY_STEPS"),
|
||||
// WHO publishes activity *time* (150–300 min/wk moderate),
|
||||
// not a step quota. The closest peer-reviewed dose-response
|
||||
// for the 8 000–15 000 band is Saint-Maurice JAMA 2020. The
|
||||
// AI prompts at src/lib/insights/prompts/{base-system,
|
||||
// general-status}.ts already enforce this attribution; this
|
||||
// surface label was the last "WHO" mislabel in the codebase.
|
||||
source: "Saint-Maurice JAMA 2020",
|
||||
});
|
||||
{
|
||||
const consistency = rollupConsistency(
|
||||
recentMeasurements.filter((m) => m.type === "ACTIVITY_STEPS"),
|
||||
makeRangeClassifier(stepsRange),
|
||||
);
|
||||
targets.push({
|
||||
type: "ACTIVITY_STEPS",
|
||||
label: "Steps/day",
|
||||
current: latestByType.ACTIVITY_STEPS ?? null,
|
||||
average30: avg30ByType.ACTIVITY_STEPS ?? null,
|
||||
trend: computeTrend("ACTIVITY_STEPS"),
|
||||
unit: "steps",
|
||||
range: stepsRange,
|
||||
classification: stepsClassification,
|
||||
// WHO publishes activity *time* (150–300 min/wk moderate),
|
||||
// not a step quota. The closest peer-reviewed dose-response
|
||||
// for the 8 000–15 000 band is Saint-Maurice JAMA 2020. The
|
||||
// AI prompts at src/lib/ai/prompts/base-system.ts and
|
||||
// src/lib/ai/prompts/general-status.ts already enforce this
|
||||
// attribution; this surface label was the last "WHO" mislabel
|
||||
// in the codebase.
|
||||
source: "Saint-Maurice JAMA 2020",
|
||||
...consistency,
|
||||
});
|
||||
}
|
||||
|
||||
// 8. Medication Compliance (average across active medications)
|
||||
const activeMedications = await prisma.medication.findMany({
|
||||
@@ -563,6 +899,33 @@ export const GET = apiHandler(async () => {
|
||||
) / 10
|
||||
: complianceRate7;
|
||||
|
||||
// Per-day compliance: for each Berlin-tz day, classify as "in" if
|
||||
// every scheduled dose that day was taken; "near" if at least one
|
||||
// was taken; "out" if all were skipped/missed; null if there were
|
||||
// no scheduled doses (the user's regimen excluded that day).
|
||||
const dayCountsByKey = new Map<
|
||||
string,
|
||||
{ taken: number; expected: number }
|
||||
>();
|
||||
for (const event of intakeEvents) {
|
||||
const key = dayKey(event.scheduledFor);
|
||||
const cur = dayCountsByKey.get(key) ?? { taken: 0, expected: 0 };
|
||||
cur.expected += 1;
|
||||
if (event.takenAt && !event.skipped) cur.taken += 1;
|
||||
dayCountsByKey.set(key, cur);
|
||||
}
|
||||
const dayBandByKey = new Map<string, DayBand | null>();
|
||||
for (const [key, counts] of dayCountsByKey.entries()) {
|
||||
if (counts.expected === 0) {
|
||||
dayBandByKey.set(key, null);
|
||||
continue;
|
||||
}
|
||||
const ratio = counts.taken / counts.expected;
|
||||
if (ratio >= 0.99) dayBandByKey.set(key, "in");
|
||||
else if (ratio >= 0.5) dayBandByKey.set(key, "near");
|
||||
else dayBandByKey.set(key, "out");
|
||||
}
|
||||
const consistency = rollupFromDayMap(dayBandByKey);
|
||||
targets.push({
|
||||
type: "MEDICATION_COMPLIANCE",
|
||||
label: "Medication compliance",
|
||||
@@ -587,6 +950,7 @@ export const GET = apiHandler(async () => {
|
||||
compliance30: medication.compliance30,
|
||||
})),
|
||||
},
|
||||
...consistency,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -641,6 +1005,15 @@ export const GET = apiHandler(async () => {
|
||||
: { category: "Low", color: "#ff5555" }
|
||||
: null;
|
||||
|
||||
const moodRange = { min: 3.5, max: 5 };
|
||||
const moodEventsForConsistency = recentMood.map((entry) => ({
|
||||
measuredAt: entry.moodLoggedAt,
|
||||
value: entry.score,
|
||||
}));
|
||||
const moodConsistency = rollupConsistency(
|
||||
moodEventsForConsistency,
|
||||
makeRangeClassifier(moodRange, { orangeMin: 2, orangeMax: 5 }),
|
||||
);
|
||||
targets.push({
|
||||
type: "MOOD_SCORE",
|
||||
label: "Mood",
|
||||
@@ -648,9 +1021,10 @@ export const GET = apiHandler(async () => {
|
||||
average30: moodAvg30,
|
||||
trend: moodTrend,
|
||||
unit: "/ 5",
|
||||
range: { min: 3.5, max: 5 },
|
||||
range: moodRange,
|
||||
classification: moodClassification,
|
||||
source: "moodLog",
|
||||
...moodConsistency,
|
||||
});
|
||||
|
||||
// Mood stability: standard deviation of recent scores (lower = more stable)
|
||||
@@ -668,6 +1042,10 @@ export const GET = apiHandler(async () => {
|
||||
? { category: "Stable", color: "#f1fa8c" }
|
||||
: { category: "Fluctuating", color: "#ff5555" };
|
||||
|
||||
// Mood stability is computed from the same recent-mood window;
|
||||
// consistency mirrors MOOD_SCORE so the strip aligns with the
|
||||
// user's logging cadence rather than introducing a synthetic
|
||||
// "\u03C3 in range per day" rule that would be hard to reason about.
|
||||
targets.push({
|
||||
type: "MOOD_STABILITY",
|
||||
label: "Mood stability",
|
||||
@@ -678,6 +1056,7 @@ export const GET = apiHandler(async () => {
|
||||
range: { min: 0, max: 0.5 },
|
||||
classification: stabilityClassification,
|
||||
source: "moodLog",
|
||||
...moodConsistency,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -744,6 +1123,15 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const consistency = rollupConsistency(
|
||||
recent.map((r) => ({ measuredAt: r.measuredAt, value: r.value })),
|
||||
makeRangeClassifier(
|
||||
range,
|
||||
eff.range
|
||||
? { orangeMin: eff.range.orangeMin, orangeMax: eff.range.orangeMax }
|
||||
: undefined,
|
||||
),
|
||||
);
|
||||
targets.push({
|
||||
type: `BLOOD_GLUCOSE_${ctx}`,
|
||||
label: labelKeyByContext[ctx],
|
||||
@@ -754,16 +1142,46 @@ export const GET = apiHandler(async () => {
|
||||
range,
|
||||
classification,
|
||||
source: eff.isOverride ? "Custom" : "ADA 2024 / DDG",
|
||||
...consistency,
|
||||
});
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
// v1.4.25 W3e — page summary. "X of Y targets met this week" is the
|
||||
// single most-asked-about glanceable answer. We define "met this
|
||||
// week" as the target having `daysInRange7d >= 4` AND not flagged as
|
||||
// `insufficientData`. The streak highlight surfaces the metric with
|
||||
// the longest current ≥ 3 day streak; nothing renders client-side
|
||||
// when no target hits the bar.
|
||||
// --------------------------------------------------------------------
|
||||
const targetsMetThisWeek = targets.filter(
|
||||
(target) => !target.insufficientData && target.daysInRange7d >= 4,
|
||||
).length;
|
||||
let streakHighlight: TargetPageSummary["streakHighlight"] = null;
|
||||
for (const target of targets) {
|
||||
if (target.streakDays < 3) continue;
|
||||
if (!streakHighlight || target.streakDays > streakHighlight.days) {
|
||||
streakHighlight = { metric: target.type, days: target.streakDays };
|
||||
}
|
||||
}
|
||||
const pageSummary: TargetPageSummary = {
|
||||
targetsMetThisWeek,
|
||||
totalTargets: targets.length,
|
||||
streakHighlight,
|
||||
};
|
||||
|
||||
annotate({
|
||||
action: { name: "insights.targets" },
|
||||
meta: { targetCount: targets.length },
|
||||
meta: {
|
||||
targetCount: targets.length,
|
||||
targetsMetThisWeek,
|
||||
streakHighlightMetric: streakHighlight?.metric ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
targets,
|
||||
pageSummary,
|
||||
// Extra diastolic data for BP display
|
||||
bpDiastolic: {
|
||||
current: latestByType.BLOOD_PRESSURE_DIA ?? null,
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* v1.4.25 W16c — PR detection enqueue coverage for
|
||||
* `POST /api/measurements/batch`. The full per-entry envelope is
|
||||
* exercised by the integration suite; this file checks the hook fires
|
||||
* with the right `silent` flag for the small / large batch cases and
|
||||
* stays out of the way when nothing landed.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
measurement: {
|
||||
findMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(async (fn: unknown) => {
|
||||
if (typeof fn === "function") {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (fn as any)(prisma as unknown as { measurement: unknown });
|
||||
}
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/pr-detection", () => ({
|
||||
enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
function makeRequest(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/measurements/batch", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function validStepEntry(externalId: string) {
|
||||
return {
|
||||
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
|
||||
value: 1200,
|
||||
unit: "count",
|
||||
startDate: "2026-05-14T06:30:00.000Z",
|
||||
endDate: "2026-05-14T06:45:00.000Z",
|
||||
externalId,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 60,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
vi.mocked(prisma.measurement.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 0 });
|
||||
});
|
||||
|
||||
describe("POST /api/measurements/batch — PR detection enqueue (v1.4.25 W16c)", () => {
|
||||
it("enqueues with silent=false for a small healthy batch", async () => {
|
||||
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 1 });
|
||||
const res = await POST(makeRequest({ entries: [validStepEntry("ext-a")] }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(enqueuePrDetection).toHaveBeenCalledTimes(1);
|
||||
expect(enqueuePrDetection).toHaveBeenCalledWith("user-1", {
|
||||
silent: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("sets silent=true once the batch crosses the 50-entry threshold", async () => {
|
||||
const big = Array.from({ length: 51 }, (_, i) =>
|
||||
validStepEntry(`ext-${i}`),
|
||||
);
|
||||
vi.mocked(prisma.measurement.createMany).mockResolvedValue({ count: 51 });
|
||||
const res = await POST(makeRequest({ entries: big }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(enqueuePrDetection).toHaveBeenCalledWith("user-1", {
|
||||
silent: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -34,10 +34,30 @@ import {
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { mapAppleHealthEntry } from "@/lib/measurements/apple-health-mapping";
|
||||
import { validateMeasurementRange } from "@/lib/validations/measurement";
|
||||
import { deviceTypeEnum } from "@/lib/validations/source-priority";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
// v1.4.25 W16c — historical-backfill threshold for PR push
|
||||
// suppression. A batch larger than this fires the detection job with
|
||||
// `silent: true` so a multi-year Apple Health backfill writes records
|
||||
// without spamming the user with hundreds of pushes. Tuned generously
|
||||
// — a healthy daily-sync batch is typically well under 50 entries.
|
||||
const PR_DETECTION_SILENT_THRESHOLD = 50;
|
||||
|
||||
const MAX_BATCH_ENTRIES = 500;
|
||||
|
||||
// v1.4.25 W10 reconcile (security H-2): cap batch ingest at 60
|
||||
// batches per user per minute. Healthy iOS sync drains its
|
||||
// HealthKit observer queue in well under one batch per minute (the
|
||||
// observer pattern coalesces), so 60/min × 500 entries/batch =
|
||||
// 30 000 rows/min headroom — generous for legitimate use, and a
|
||||
// hard stop for a leaked wildcard token trying to saturate the
|
||||
// write pipeline.
|
||||
const BATCH_RATE_LIMIT_MAX = 60;
|
||||
const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
const batchEntrySchema = z.object({
|
||||
hkIdentifier: z.string().min(1).max(120),
|
||||
value: z.number().finite(),
|
||||
@@ -47,6 +67,14 @@ const batchEntrySchema = z.object({
|
||||
sleepStage: z.number().int().min(0).max(20).optional(),
|
||||
externalId: z.string().min(1).max(120),
|
||||
externalSourceVersion: z.string().min(1).max(120).optional(),
|
||||
// v1.4.25 W8c — optional device-type tag. The iOS client maps the
|
||||
// `HKDevice.model` of each sample to one of the canonical device
|
||||
// classes (watch | band | ring | phone | scale | other | unknown).
|
||||
// Backward-compatible: every pre-W8c iOS build skips the field and
|
||||
// the row is stored with `deviceType = null`; the canonical picker
|
||||
// treats null as `unknown` and only uses it as a tiebreaker when a
|
||||
// ranked device-type coexists in the same daily bucket.
|
||||
deviceType: deviceTypeEnum.nullable().optional(),
|
||||
});
|
||||
|
||||
const batchPayloadSchema = z.object({
|
||||
@@ -75,6 +103,25 @@ export const POST = apiHandler(withIdempotency<[NextRequest]>(postBatch));
|
||||
async function postBatch(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// v1.4.25 W10 reconcile (security H-2): per-user rate limit. Without
|
||||
// it, a leaked wildcard iOS token can sustain unbounded writes
|
||||
// (500 rows/batch × N batches/sec) and degrade the database for
|
||||
// every other user on the host. 60 batches/min/user is generous
|
||||
// for healthy iOS sync and tight enough that a misbehaving client
|
||||
// bottoms out within a minute.
|
||||
const rl = await checkRateLimit(
|
||||
`measurements:batch:${user.id}`,
|
||||
BATCH_RATE_LIMIT_MAX,
|
||||
BATCH_RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
annotate({
|
||||
action: { name: "measurement.batch.ingest" },
|
||||
meta: { outcome: "rate_limited" },
|
||||
});
|
||||
return apiError("Too many batch submissions, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson<unknown>(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
@@ -90,7 +137,7 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
(rawBody as { entries: unknown[] }).entries.length > MAX_BATCH_ENTRIES
|
||||
) {
|
||||
return apiError(`Batch exceeds the ${MAX_BATCH_ENTRIES}-entry limit`, 422, {
|
||||
errorCode: "coach.batch.too_large",
|
||||
errorCode: "measurement.batch.too_large",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -156,6 +203,11 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
externalId: entry.externalId,
|
||||
externalSourceVersion: entry.externalSourceVersion ?? null,
|
||||
sleepStage: mapped.sleepStage ?? null,
|
||||
// v1.4.25 W8c — pass the iOS-supplied device-type through to
|
||||
// the row. Stays null for pre-W8c iOS builds; the canonical
|
||||
// picker treats null as `unknown` so legacy ingest keeps
|
||||
// working without a server-side default.
|
||||
deviceType: entry.deviceType ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -223,44 +275,39 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
}
|
||||
});
|
||||
|
||||
// If `skipDuplicates` quietly absorbed a row (race with another
|
||||
// batch), reconcile the per-entry status so the inserted +
|
||||
// duplicate counts still equal `prepared.length`.
|
||||
// v1.4.25 W10 reconcile (senior-dev H-1): the previous "stored
|
||||
// vs not-stored" check was an effective no-op. Under standard
|
||||
// Postgres semantics, `skipDuplicates` absorbs duplicate-key
|
||||
// conflicts but the row is STILL present in the table (written
|
||||
// by the other batch that won the race). So every row we
|
||||
// attempted is in the DB after the call, and the
|
||||
// `!stored.has(...)` branch never fired — leaving the aggregate
|
||||
// `inserted` / `duplicate` counts inconsistent with the per-
|
||||
// entry statuses under contention.
|
||||
//
|
||||
// Pragmatic fix: trust the `createMany` return count. The DB
|
||||
// round-trip cannot distinguish a row this request wrote from
|
||||
// a row the racing request wrote (the unique index sees both
|
||||
// as the same key), so we cannot identify the SPECIFIC raced
|
||||
// rows. What we CAN do is preserve count integrity for the
|
||||
// iOS sync cursor: `insertedCount` is already the truth (it
|
||||
// came from `createMany.count`); we only need to downgrade
|
||||
// enough per-entry "inserted" statuses to "duplicate" so the
|
||||
// per-entry envelope sums match. Order doesn't matter — the
|
||||
// client checkpoints past both statuses, and the DB state for
|
||||
// either outcome is identical (the row is now stored,
|
||||
// single-copy).
|
||||
const racedDuplicates = toInsert.length - insertedCount;
|
||||
if (racedDuplicates > 0) {
|
||||
// We can't tell *which* rows raced from the createMany return
|
||||
// value — recheck the DB and downgrade matching `inserted`
|
||||
// rows to `duplicate`. This is a rare path so the extra
|
||||
// round-trip is acceptable.
|
||||
const recheck = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
source: "APPLE_HEALTH",
|
||||
OR: toInsert.map((row) => ({
|
||||
type: row.type,
|
||||
externalId: row.externalId as string,
|
||||
})),
|
||||
},
|
||||
select: { type: true, externalId: true },
|
||||
});
|
||||
const stored = new Set(
|
||||
recheck.map((row) => `${row.type}::${row.externalId}`),
|
||||
);
|
||||
// Anything `prepared` flagged as `inserted` but isn't in
|
||||
// `stored` was never written and was raced by another batch —
|
||||
// surface as duplicate.
|
||||
let downgraded = 0;
|
||||
for (const p of prepared) {
|
||||
if (
|
||||
results[p.index]?.status === "inserted" &&
|
||||
!stored.has(`${p.row.type}::${p.row.externalId}`)
|
||||
) {
|
||||
if (downgraded >= racedDuplicates) break;
|
||||
if (results[p.index]?.status === "inserted") {
|
||||
results[p.index] = { index: p.index, status: "duplicate" };
|
||||
duplicateCount += 1;
|
||||
downgraded += 1;
|
||||
}
|
||||
}
|
||||
insertedCount -= downgraded;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -280,6 +327,35 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
},
|
||||
});
|
||||
|
||||
// v1.4.25 W16c — kick off PR detection for this user. We always
|
||||
// enqueue when at least one row was written (or the batch had any
|
||||
// measurements to consider) so a single off-day reading still gets
|
||||
// evaluated; the warm-up gate inside the detector decides whether
|
||||
// it's a record. Suppress push notifications for historical
|
||||
// backfills above the silent threshold.
|
||||
if (insertedCount > 0 || duplicateCount > 0) {
|
||||
const silent = entries.length > PR_DETECTION_SILENT_THRESHOLD;
|
||||
try {
|
||||
await enqueuePrDetection(user.id, { silent });
|
||||
await auditLog("personal_records.detection_enqueued", {
|
||||
userId: user.id,
|
||||
details: {
|
||||
source: "measurement.batch",
|
||||
batchSize: entries.length,
|
||||
silent,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
// Enqueue failure is non-fatal — the 30-minute fallback cron
|
||||
// picks the user up in the next slot. Log so the operator
|
||||
// notices repeated failures in Wide-Event traffic.
|
||||
annotate({
|
||||
action: { name: "personal_records.detection_enqueue_failed" },
|
||||
meta: { error: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "measurement.batch.ingest" },
|
||||
meta: {
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* `DELETE /api/measurements/by-external-ids` — iOS deletion reconciliation.
|
||||
*
|
||||
* HealthKit's API does not surface a stream of deleted UUIDs, so the
|
||||
* iOS client performs a periodic 30-day window reconciliation: it pulls
|
||||
* the current set of HealthKit sample UUIDs in the window and posts the
|
||||
* externalIds the server holds that are no longer present. This endpoint
|
||||
* accepts that batch-delete list and removes every matching row owned by
|
||||
* the calling user.
|
||||
*
|
||||
* Idempotency contract:
|
||||
* - Cross-user safety: rows owned by another user are silently skipped
|
||||
* (Prisma's `deleteMany` with `userId` in the where-clause guarantees
|
||||
* this), so a duplicate / replayed delete is a no-op rather than a
|
||||
* 401.
|
||||
* - Empty arrays return `{ deletedCount: 0 }` with 200 — the iOS
|
||||
* client should not consider that a failure.
|
||||
* - The batch cap mirrors the ingest path (`/api/measurements/batch`)
|
||||
* so the two surfaces share the same operational ceiling.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
|
||||
const MAX_BATCH_ENTRIES = 500;
|
||||
|
||||
const payloadSchema = z.object({
|
||||
externalIds: z
|
||||
.array(z.string().min(1).max(120))
|
||||
.min(0)
|
||||
.max(MAX_BATCH_ENTRIES),
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(deleteByExternalIds);
|
||||
|
||||
async function deleteByExternalIds(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson<unknown>(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
// Distinguish "too many entries" from "validation failed" so the iOS
|
||||
// client surfaces a useful diagnostic — same shape the ingest endpoint
|
||||
// uses for its symmetric cap.
|
||||
if (
|
||||
typeof rawBody === "object" &&
|
||||
rawBody !== null &&
|
||||
"externalIds" in rawBody &&
|
||||
Array.isArray((rawBody as { externalIds: unknown }).externalIds) &&
|
||||
(rawBody as { externalIds: unknown[] }).externalIds.length >
|
||||
MAX_BATCH_ENTRIES
|
||||
) {
|
||||
return apiError(`Batch exceeds the ${MAX_BATCH_ENTRIES}-entry limit`, 422, {
|
||||
errorCode: "measurement.delete.too_large",
|
||||
});
|
||||
}
|
||||
|
||||
const parsed = payloadSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0]?.message ?? "Invalid batch", 422);
|
||||
}
|
||||
|
||||
const { externalIds } = parsed.data;
|
||||
|
||||
if (externalIds.length === 0) {
|
||||
annotate({
|
||||
action: { name: "measurement.delete.by_external_ids" },
|
||||
meta: { processed: 0, deleted: 0 },
|
||||
});
|
||||
return apiSuccess({ deletedCount: 0 });
|
||||
}
|
||||
|
||||
// Prisma `deleteMany` with the userId predicate is the cross-user
|
||||
// 404 guard: rows belonging to other users are simply not matched.
|
||||
// No need for a pre-flight findMany — the unique index on
|
||||
// `(userId, type, source, externalId)` keeps the matching set small
|
||||
// even when the user has overlapping HealthKit history.
|
||||
const result = await prisma.measurement.deleteMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
externalId: { in: externalIds },
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("measurement.delete.by_external_ids", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { processed: externalIds.length, deleted: result.count },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "measurement.delete.by_external_ids" },
|
||||
meta: { processed: externalIds.length, deleted: result.count },
|
||||
});
|
||||
|
||||
return apiSuccess({ deletedCount: result.count });
|
||||
}
|
||||
@@ -96,6 +96,10 @@ async function postMeasurement(request: NextRequest) {
|
||||
notes: m.notes ?? null,
|
||||
glucoseContext:
|
||||
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
// v1.4.25 W10 reconcile (code-review M4): mirror the
|
||||
// single-entry path so the multi-entry batch persists
|
||||
// `deviceType` instead of silently dropping it.
|
||||
deviceType: m.deviceType ?? null,
|
||||
},
|
||||
}),
|
||||
),
|
||||
@@ -127,7 +131,7 @@ async function postMeasurement(request: NextRequest) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const { type, value, measuredAt, notes, source, glucoseContext } =
|
||||
const { type, value, measuredAt, notes, source, glucoseContext, deviceType } =
|
||||
parsed.data;
|
||||
|
||||
// Handle unique constraint violation
|
||||
@@ -143,6 +147,11 @@ async function postMeasurement(request: NextRequest) {
|
||||
measuredAt,
|
||||
notes: notes ?? null,
|
||||
glucoseContext: (glucoseContext as GlucoseContext | undefined) ?? null,
|
||||
// v1.4.25 W10 reconcile (code-review M4): the deviceType column
|
||||
// already accepts client metadata from the batch route. Mirror
|
||||
// the behaviour here so single-entry POST persists the tag
|
||||
// instead of silently dropping it.
|
||||
deviceType: deviceType ?? null,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { auditLog } from "@/lib/auth/audit";
|
||||
import { hashToken } from "@/lib/auth/hmac";
|
||||
import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
|
||||
import { isApiGloballyEnabled } from "@/lib/app-settings";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
@@ -22,10 +23,9 @@ export const GET = apiHandler(
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const medication = await prisma.medication.findUnique({ where: { id } });
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const scope = medicationScope(id);
|
||||
const now = new Date();
|
||||
@@ -63,8 +63,12 @@ export const PUT = apiHandler(
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const medication = await prisma.medication.findUnique({ where: { id } });
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
if (!medication) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* v1.4.25 W19e — cadence + compliance API route tests.
|
||||
*
|
||||
* Mirrors the W19d side-effect test fixture pattern: external
|
||||
* dependencies are mocked at module boundaries (Prisma, session,
|
||||
* logging transport), and each test pins one contract case.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: { findUnique: vi.fn() },
|
||||
medicationIntakeEvent: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/medications/[id]/cadence", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/cadence"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 404 when the medication is not owned by the caller", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
userId: "OTHER",
|
||||
createdAt: new Date(),
|
||||
schedules: [],
|
||||
} as never);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/cadence"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 422 for an out-of-range days parameter", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
userId: "user-1",
|
||||
createdAt: new Date(),
|
||||
schedules: [],
|
||||
} as never);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest(
|
||||
"http://localhost/api/medications/med-1/cadence?days=9999",
|
||||
),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns the timeline + chips for an owned medication", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
userId: "user-1",
|
||||
createdAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
|
||||
schedules: [
|
||||
{
|
||||
id: "sch-1",
|
||||
medicationId: "med-1",
|
||||
windowStart: "08:00",
|
||||
windowEnd: "09:00",
|
||||
label: null,
|
||||
dose: null,
|
||||
daysOfWeek: null,
|
||||
},
|
||||
],
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([]);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest(
|
||||
"http://localhost/api/medications/med-1/cadence?days=14",
|
||||
),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.data.windowDays).toBe(14);
|
||||
expect(Array.isArray(json.data.timeline)).toBe(true);
|
||||
expect(json.data.chips).toMatchObject({
|
||||
adherenceRate: expect.anything(),
|
||||
currentStreak: expect.any(Number),
|
||||
longestStreak: expect.any(Number),
|
||||
missedLast30: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults to a 30-day window when no `days` query param is supplied", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
userId: "user-1",
|
||||
createdAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
|
||||
schedules: [],
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([]);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/cadence"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.data.windowDays).toBe(30);
|
||||
});
|
||||
|
||||
it("returns a `null` next-dose when no schedule expands in the lookahead", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
userId: "user-1",
|
||||
createdAt: new Date(Date.now() - 60 * 24 * 60 * 60 * 1000),
|
||||
schedules: [],
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([]);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/cadence"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.data.next).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* v1.4.25 W19e — GLP-1 cadence + compliance read endpoint.
|
||||
*
|
||||
* GET /api/medications/[id]/cadence?days=30
|
||||
* - returns the schedule's 30-day (configurable) expected-vs-actual
|
||||
* dose timeline plus the four compliance chip values that drive
|
||||
* the detail-page section.
|
||||
*
|
||||
* Pure computation — no writes. The route delegates math to the
|
||||
* `src/lib/medications/scheduling/{cadence,compliance}` pure modules
|
||||
* so the chart, the chips, and the server agree dose-for-dose.
|
||||
*
|
||||
* Auth: cookie-session via requireAuth(); the medication is verified
|
||||
* to belong to the caller before any read.
|
||||
*/
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
buildCadenceTimeline,
|
||||
computeNextDose,
|
||||
} from "@/lib/medications/scheduling/cadence";
|
||||
import { complianceChips } from "@/lib/medications/scheduling/compliance";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { resolveUserTimezone } from "@/lib/tz/resolver";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
const querySchema = z.object({
|
||||
days: z.coerce.number().int().min(1).max(180).optional(),
|
||||
});
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const med = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
include: { schedules: true },
|
||||
});
|
||||
if (!med) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const parsed = querySchema.safeParse({
|
||||
days: url.searchParams.get("days") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
const windowDays = parsed.data.days ?? 30;
|
||||
const asOf = new Date();
|
||||
|
||||
// Pull intake events covering the window + a small buffer so a dose
|
||||
// logged at the edge can still pair with its slot.
|
||||
const bufferMs = 24 * 60 * 60 * 1000;
|
||||
const from = new Date(asOf.getTime() - windowDays * 24 * 60 * 60 * 1000 - bufferMs);
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
medicationId: id,
|
||||
userId: user.id,
|
||||
scheduledFor: { gte: from },
|
||||
},
|
||||
select: { scheduledFor: true, takenAt: true, skipped: true },
|
||||
orderBy: { scheduledFor: "asc" },
|
||||
});
|
||||
|
||||
// The cadence-anchor is the medication's createdAt for stable
|
||||
// intervalWeeks=N phasing. Falling back to `from` would re-anchor
|
||||
// every window-size change, which would make a 30-day vs 90-day
|
||||
// chart disagree on the bi-weekly grid.
|
||||
const anchor = med.createdAt;
|
||||
|
||||
// v1.4.25 W21 Fix-O — resolve the user's IANA zone so the cadence
|
||||
// helpers compute every local-day boundary, window-time application,
|
||||
// and streak day-key in the user's clock instead of the host's
|
||||
// system time. A Tokyo user reading the same medication gets the
|
||||
// same chip values as a Berlin user — the host-relative day flip
|
||||
// was a sneaky bug at the 08:00-Berlin / 16:00-Tokyo intersection.
|
||||
const userTz = await resolveUserTimezone(user.id);
|
||||
|
||||
const timeline = buildCadenceTimeline(
|
||||
med.schedules,
|
||||
events,
|
||||
asOf,
|
||||
windowDays,
|
||||
anchor,
|
||||
userTz,
|
||||
);
|
||||
const chips = complianceChips(
|
||||
med.schedules,
|
||||
events,
|
||||
asOf,
|
||||
windowDays,
|
||||
anchor,
|
||||
userTz,
|
||||
);
|
||||
const next = computeNextDose(med.schedules, asOf, 14, anchor, userTz);
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.cadence",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: {
|
||||
window_days: windowDays,
|
||||
slots: timeline.length,
|
||||
adherence: chips.adherenceRate,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
windowDays,
|
||||
anchorIso: anchor.toISOString(),
|
||||
next: next
|
||||
? {
|
||||
windowStart: next.windowStart.toISOString(),
|
||||
windowEnd: next.windowEnd.toISOString(),
|
||||
scheduleIndex: next.scheduleIndex,
|
||||
}
|
||||
: null,
|
||||
chips,
|
||||
timeline: timeline.map((p) => ({
|
||||
day: p.day.toISOString(),
|
||||
windowStart: p.windowStart.toISOString(),
|
||||
windowEnd: p.windowEnd.toISOString(),
|
||||
scheduleIndex: p.scheduleIndex,
|
||||
status: p.status,
|
||||
})),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
classifyIntakeTiming,
|
||||
} from "@/lib/analytics/compliance";
|
||||
import type { DailyComplianceEntry } from "@/lib/analytics/compliance";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
@@ -15,12 +16,16 @@ export const GET = apiHandler(
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const medication = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
include: { schedules: true },
|
||||
});
|
||||
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
if (!medication) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
/**
|
||||
* v1.4.25 W21 Fix-K — GLP-1 convenience-route POST tests.
|
||||
*
|
||||
* Pins the sec-H1 hardening pass: Zod parse, 30/min/user rate-limit,
|
||||
* `medication.glp1.update` audit row, bounded `doseValue` / `note` /
|
||||
* `effectiveFrom`, XOR refinement between the doseChange / inventory
|
||||
* branches. Mirrors the sibling inventory + side-effect fixture
|
||||
* pattern so the four routes share one test shape.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: { findUnique: vi.fn() },
|
||||
medicationDoseChange: { create: vi.fn() },
|
||||
medicationInventoryEvent: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: vi.fn(() => ({ "X-RateLimit-Remaining": "0" })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
const MED_OK = { id: "med-1", userId: "user-1" };
|
||||
|
||||
function jsonReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/medications/med-1/glp1", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/medications/[id]/glp1", () => {
|
||||
it("rejects unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await POST(
|
||||
jsonReq({
|
||||
doseChange: {
|
||||
effectiveFrom: "2026-05-14T08:00:00Z",
|
||||
doseValue: 0.5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 404 when the medication belongs to a different user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
userId: "OTHER",
|
||||
} as never);
|
||||
const res = await POST(
|
||||
jsonReq({
|
||||
doseChange: {
|
||||
effectiveFrom: "2026-05-14T08:00:00Z",
|
||||
doseValue: 0.5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 422 when neither doseChange nor inventory is provided", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
const res = await POST(jsonReq({}), {
|
||||
params: Promise.resolve({ id: "med-1" }),
|
||||
});
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when both doseChange and inventory are provided", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
const res = await POST(
|
||||
jsonReq({
|
||||
doseChange: {
|
||||
effectiveFrom: "2026-05-14T08:00:00Z",
|
||||
doseValue: 0.5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
inventory: { delta: 1, reason: "refill" },
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when doseValue is negative", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
const res = await POST(
|
||||
jsonReq({
|
||||
doseChange: {
|
||||
effectiveFrom: "2026-05-14T08:00:00Z",
|
||||
doseValue: -1,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when doseValue is NaN (not a finite number)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
// JSON cannot encode NaN, but Number.NaN reaches the schema if the
|
||||
// client base64-encodes or hand-crafts the body. Simulate the
|
||||
// post-decode shape by passing `null` (which fails the finite gate)
|
||||
// and `1e400` (which JSON renders as null too) via the same path.
|
||||
const res = await POST(
|
||||
jsonReq({
|
||||
doseChange: {
|
||||
effectiveFrom: "2026-05-14T08:00:00Z",
|
||||
doseValue: null,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when doseValue exceeds 100 mg cap", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
const res = await POST(
|
||||
jsonReq({
|
||||
doseChange: {
|
||||
effectiveFrom: "2026-05-14T08:00:00Z",
|
||||
doseValue: 250,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when note exceeds 500 chars", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
const res = await POST(
|
||||
jsonReq({
|
||||
doseChange: {
|
||||
effectiveFrom: "2026-05-14T08:00:00Z",
|
||||
doseValue: 0.5,
|
||||
doseUnit: "mg",
|
||||
note: "x".repeat(501),
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when effectiveFrom predates 2020", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
const res = await POST(
|
||||
jsonReq({
|
||||
doseChange: {
|
||||
effectiveFrom: "1995-01-01T00:00:00Z",
|
||||
doseValue: 0.5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 422 when inventory delta is zero", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
const res = await POST(
|
||||
jsonReq({ inventory: { delta: 0, reason: "noop" } }),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 429 when the rate limit is exceeded", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await POST(
|
||||
jsonReq({
|
||||
doseChange: {
|
||||
effectiveFrom: "2026-05-14T08:00:00Z",
|
||||
doseValue: 0.5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(res.headers.get("X-RateLimit-Remaining")).toBe("0");
|
||||
});
|
||||
|
||||
it("creates a doseChange row and writes a medication.glp1.update audit entry", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
vi.mocked(prisma.medicationDoseChange.create).mockResolvedValue({
|
||||
id: "dc-new",
|
||||
effectiveFrom: new Date("2026-05-14T08:00:00Z"),
|
||||
doseValue: 0.5,
|
||||
doseUnit: "mg",
|
||||
note: null,
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonReq({
|
||||
doseChange: {
|
||||
effectiveFrom: "2026-05-14T08:00:00Z",
|
||||
doseValue: 0.5,
|
||||
doseUnit: "mg",
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.medicationDoseChange.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
medicationId: "med-1",
|
||||
effectiveFrom: new Date("2026-05-14T08:00:00Z"),
|
||||
doseValue: 0.5,
|
||||
doseUnit: "mg",
|
||||
note: null,
|
||||
}),
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"medication.glp1.update",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
medicationId: "med-1",
|
||||
kind: "doseChange",
|
||||
doseChangeId: "dc-new",
|
||||
doseValue: 0.5,
|
||||
doseUnit: "mg",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("creates an inventory row and writes a medication.glp1.update audit entry", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
vi.mocked(prisma.medicationInventoryEvent.create).mockResolvedValue({
|
||||
id: "iv-new",
|
||||
delta: 1,
|
||||
reason: "refill",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonReq({ inventory: { delta: 1, reason: "refill" } }),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.medicationInventoryEvent.create).toHaveBeenCalledWith({
|
||||
data: { medicationId: "med-1", delta: 1, reason: "refill" },
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledWith(
|
||||
"medication.glp1.update",
|
||||
expect.objectContaining({
|
||||
userId: "user-1",
|
||||
details: expect.objectContaining({
|
||||
medicationId: "med-1",
|
||||
kind: "inventory",
|
||||
inventoryEventId: "iv-new",
|
||||
delta: 1,
|
||||
reason: "refill",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* v1.4.25 W4d — GLP-1 medication details endpoint.
|
||||
*
|
||||
* Returns per-medication extras the GLP-1 card variant + the dashboard
|
||||
* tile + the doctor-report PDF section read: chronological dose-change
|
||||
* history, the last 12 injection events (with optional site
|
||||
* rotation data), and the running pen-inventory math. The base
|
||||
* /api/medications/[id] GET is unchanged so v1.4.24 consumers keep
|
||||
* working untouched.
|
||||
*
|
||||
* v1.4.25 W21 Fix-K — POST hardened with Zod parse, audit-log, 30/min
|
||||
* rate-limit, finite-number guard on `doseValue`, length cap on `note`,
|
||||
* and sane bounds on `effectiveFrom`. Mirrors the sibling
|
||||
* `/inventory` + `/side-effects` route shape.
|
||||
*/
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { glp1PostBodySchema } from "@/lib/validations/medication";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
const LOW_STOCK_DOSE_THRESHOLD = 4;
|
||||
const POST_RATE_LIMIT = 30;
|
||||
const POST_WINDOW_MS = 60_000;
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const medication = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
doseChanges: { orderBy: { effectiveFrom: "asc" } },
|
||||
inventoryEvents: { orderBy: { occurredAt: "asc" } },
|
||||
intakeEvents: {
|
||||
where: { takenAt: { not: null } },
|
||||
orderBy: { takenAt: "desc" },
|
||||
take: 12,
|
||||
select: { takenAt: true, injectionSite: true },
|
||||
},
|
||||
schedules: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
// Inventory math: running sum of every inventory event. Negative
|
||||
// when more doses were consumed than purchased — surfaces as a
|
||||
// low-stock warning rather than a hard error so the UI keeps
|
||||
// working when the user backdates an inventory event.
|
||||
let inventory: {
|
||||
pensRemaining: number | null;
|
||||
dosesRemaining: number | null;
|
||||
weeksOfSupply: number | null;
|
||||
lowStock: boolean;
|
||||
} | null = null;
|
||||
if (medication.dosesPerUnit && medication.inventoryEvents.length > 0) {
|
||||
const pens = medication.inventoryEvents.reduce(
|
||||
(sum, ev) => sum + ev.delta,
|
||||
0,
|
||||
);
|
||||
const pensRemaining = Math.max(0, pens);
|
||||
const dosesRemaining = pensRemaining * medication.dosesPerUnit;
|
||||
// Approximate weeks-of-supply assuming weekly cadence (the
|
||||
// canonical GLP-1 case). The Coach snapshot does the same.
|
||||
const weeksOfSupply = dosesRemaining;
|
||||
const lowStock = dosesRemaining < LOW_STOCK_DOSE_THRESHOLD;
|
||||
inventory = {
|
||||
pensRemaining,
|
||||
dosesRemaining,
|
||||
weeksOfSupply,
|
||||
lowStock,
|
||||
};
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
doseChanges: medication.doseChanges.map((dc) => ({
|
||||
id: dc.id,
|
||||
effectiveFrom: dc.effectiveFrom.toISOString(),
|
||||
doseValue: dc.doseValue,
|
||||
doseUnit: dc.doseUnit,
|
||||
note: dc.note,
|
||||
})),
|
||||
recentIntakes: medication.intakeEvents.map((iv) => ({
|
||||
takenAt: iv.takenAt ? iv.takenAt.toISOString() : null,
|
||||
injectionSite: iv.injectionSite,
|
||||
})),
|
||||
inventory,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST creates a new dose change OR inventory event (the body picks
|
||||
* one — caller specifies which). Convenience endpoint so the
|
||||
* medication-card disclosure can write rows without dispatching to
|
||||
* /api/medications/[id]/dose-change + /api/medications/[id]/inventory
|
||||
* separately for the v1.4.25 cut.
|
||||
*
|
||||
* Validation: `glp1PostBodySchema` (XOR of `doseChange` / `inventory`),
|
||||
* with finite-number guards on `doseValue` + `delta`, bounded `note`
|
||||
* + `reason`, and a ±5-year window on `effectiveFrom`.
|
||||
* Rate-limit: 30/min/user (same as sibling POST routes).
|
||||
* Audit: `medication.glp1.update` for every successful mutation.
|
||||
*/
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
// Per-user POST rate-limit — matches the 30/min sibling routes
|
||||
// (inventory, side-effects). Generous for a hand-driven session,
|
||||
// tight enough to cut off the spam case.
|
||||
const rl = await checkRateLimit(
|
||||
`medication-glp1:post:${user.id}`,
|
||||
POST_RATE_LIMIT,
|
||||
POST_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many requests", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = glp1PostBodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const ip = getClientIp(request);
|
||||
|
||||
if (parsed.data.doseChange) {
|
||||
const { effectiveFrom, doseValue, doseUnit, note } =
|
||||
parsed.data.doseChange;
|
||||
const created = await prisma.medicationDoseChange.create({
|
||||
data: {
|
||||
medicationId: id,
|
||||
effectiveFrom,
|
||||
doseValue,
|
||||
doseUnit,
|
||||
note: note ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("medication.glp1.update", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: {
|
||||
medicationId: id,
|
||||
kind: "doseChange",
|
||||
doseChangeId: created.id,
|
||||
doseValue,
|
||||
doseUnit,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.glp1.doseChange.create",
|
||||
entity_type: "medication_dose_change",
|
||||
entity_id: created.id,
|
||||
},
|
||||
meta: { medication_id: id, doseValue, doseUnit },
|
||||
});
|
||||
|
||||
return apiSuccess({ doseChange: created }, 201);
|
||||
}
|
||||
|
||||
// The schema's XOR refinement guarantees `inventory` is present
|
||||
// when `doseChange` is not, so the non-null assertion is safe.
|
||||
const { delta, reason } = parsed.data.inventory!;
|
||||
const created = await prisma.medicationInventoryEvent.create({
|
||||
data: { medicationId: id, delta, reason },
|
||||
});
|
||||
|
||||
await auditLog("medication.glp1.update", {
|
||||
userId: user.id,
|
||||
ipAddress: ip,
|
||||
details: {
|
||||
medicationId: id,
|
||||
kind: "inventory",
|
||||
inventoryEventId: created.id,
|
||||
delta,
|
||||
reason,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.glp1.inventory.create",
|
||||
entity_type: "medication_inventory_event",
|
||||
entity_id: created.id,
|
||||
},
|
||||
meta: { medication_id: id, delta },
|
||||
});
|
||||
|
||||
return apiSuccess({ inventory: created }, 201);
|
||||
},
|
||||
);
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
listIntakeEventsSchema,
|
||||
} from "@/lib/validations/medication";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { consumeOneDose } from "@/lib/medications/inventory/service";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
@@ -25,10 +27,9 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
const medication = await prisma.medication.findUnique({ where: { id } });
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
|
||||
@@ -94,10 +95,45 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
: []),
|
||||
]);
|
||||
|
||||
// v1.4.25 W19b — pen-inventory dose decrement. Only fires for
|
||||
// non-skipped intakes; a skipped event is not a consumption event.
|
||||
// No-op when the medication has no tracked pens (most non-GLP-1
|
||||
// meds). Failures here must never block the intake write, so
|
||||
// errors are swallowed and logged — the intake is the source of
|
||||
// truth, the inventory is an opt-in companion.
|
||||
let inventoryOutcome: Awaited<ReturnType<typeof consumeOneDose>> = null;
|
||||
if (!skipped) {
|
||||
try {
|
||||
inventoryOutcome = await consumeOneDose({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
intakeAt: event.takenAt ?? event.scheduledFor,
|
||||
});
|
||||
} catch (err) {
|
||||
annotate({
|
||||
action: { name: "medication.inventory.consume_error" },
|
||||
meta: {
|
||||
medication_id: id,
|
||||
message: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await auditLog("medication.intake", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { medicationId: id, eventId: event.id, skipped },
|
||||
details: {
|
||||
medicationId: id,
|
||||
eventId: event.id,
|
||||
skipped,
|
||||
...(inventoryOutcome
|
||||
? {
|
||||
inventoryItemId: inventoryOutcome.itemId,
|
||||
inventoryChange: inventoryOutcome.change,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
@@ -106,7 +142,16 @@ async function postIntake(request: NextRequest, { params }: RouteParams) {
|
||||
entity_type: "intake_event",
|
||||
entity_id: event.id,
|
||||
},
|
||||
meta: { medication_id: id, skipped },
|
||||
meta: {
|
||||
medication_id: id,
|
||||
skipped,
|
||||
...(inventoryOutcome
|
||||
? {
|
||||
inventory_item_id: inventoryOutcome.itemId,
|
||||
inventory_change: inventoryOutcome.change,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(event, 201);
|
||||
@@ -117,10 +162,9 @@ export const GET = apiHandler(
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
const medication = await prisma.medication.findUnique({ where: { id } });
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const searchParams = Object.fromEntries(request.nextUrl.searchParams);
|
||||
const parsed = listIntakeEventsSchema.safeParse(searchParams);
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* v1.4.25 W19b — per-pen inventory item operations.
|
||||
*
|
||||
* PATCH /api/medications/[id]/inventory/[itemId]
|
||||
* - mark-as-first-use (starts the 30-day clock manually)
|
||||
* - mark-as-used-up (terminal — operator override when a pen is
|
||||
* physically discarded but the dose ledger hasn't reached zero)
|
||||
* - update printed expiry (e.g. carton label correction)
|
||||
* - update notes
|
||||
*
|
||||
* DELETE /api/medications/[id]/inventory/[itemId]
|
||||
* - hard delete; admin / cleanup path. The audit log captures
|
||||
* the before-state so a row can be reconstructed if needed.
|
||||
*/
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { updateInventoryItemSchema } from "@/lib/validations/medication";
|
||||
import {
|
||||
computeExpiresAt,
|
||||
computeInventoryState,
|
||||
} from "@/lib/medications/inventory/state-machine";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string; itemId: string }> };
|
||||
|
||||
async function loadOwnedItem(
|
||||
medicationId: string,
|
||||
itemId: string,
|
||||
userId: string,
|
||||
) {
|
||||
const item = await prisma.medicationInventoryItem.findUnique({
|
||||
where: { id: itemId },
|
||||
});
|
||||
if (!item || item.userId !== userId || item.medicationId !== medicationId) {
|
||||
return null;
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
export const PATCH = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id, itemId } = await params;
|
||||
|
||||
const existing = await loadOwnedItem(id, itemId, user.id);
|
||||
if (!existing) return apiError("Inventory item not found", 404);
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = updateInventoryItemSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const { markAsFirstUseAt, markAsUsedUp, printedExpiry, notes } =
|
||||
parsed.data;
|
||||
|
||||
// Compose the next-row shape. Each mutation field is optional and
|
||||
// commutative — applying them in any order produces the same row.
|
||||
let nextFirstUseAt = existing.firstUseAt;
|
||||
let nextState = existing.state;
|
||||
let nextDosesRemaining = existing.dosesRemaining;
|
||||
let nextPrintedExpiry = existing.printedExpiry;
|
||||
|
||||
if (markAsFirstUseAt) {
|
||||
// The clock can be set manually if the user opened the pen
|
||||
// without logging an intake event.
|
||||
nextFirstUseAt = markAsFirstUseAt;
|
||||
if (nextState === "ACTIVE") nextState = "IN_USE";
|
||||
}
|
||||
|
||||
if (printedExpiry !== undefined) {
|
||||
nextPrintedExpiry = printedExpiry;
|
||||
}
|
||||
|
||||
if (markAsUsedUp === true) {
|
||||
nextDosesRemaining = 0;
|
||||
nextState = "USED_UP";
|
||||
}
|
||||
|
||||
const nextExpiresAt = computeExpiresAt(nextFirstUseAt, nextPrintedExpiry);
|
||||
|
||||
// Re-run the canonical state machine over the composed next-row
|
||||
// view. The clause-by-clause updates above set the state ad-hoc
|
||||
// (`ACTIVE → IN_USE` on first use, `* → USED_UP` on mark-as-used),
|
||||
// but a back-dated `markAsFirstUseAt` whose 30-day window already
|
||||
// elapsed should land at EXPIRED, not IN_USE. The state machine is
|
||||
// pure and idempotent — running it once more with the composed view
|
||||
// collapses every edge case onto the same decision tree the intake
|
||||
// hook and the daily expire cron already share. USED_UP is terminal
|
||||
// (dosesRemaining === 0 ⇒ USED_UP wins at clause 1), so the manual
|
||||
// override remains sticky.
|
||||
nextState = computeInventoryState(
|
||||
{
|
||||
state: nextState,
|
||||
dosesTotal: existing.dosesTotal,
|
||||
dosesRemaining: nextDosesRemaining,
|
||||
firstUseAt: nextFirstUseAt,
|
||||
printedExpiry: nextPrintedExpiry,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
const updated = await prisma.medicationInventoryItem.update({
|
||||
where: { id: itemId },
|
||||
data: {
|
||||
state: nextState,
|
||||
firstUseAt: nextFirstUseAt,
|
||||
dosesRemaining: nextDosesRemaining,
|
||||
printedExpiry: nextPrintedExpiry,
|
||||
expiresAt: nextExpiresAt,
|
||||
notes: notes === undefined ? existing.notes : notes,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("medication.inventory.update", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
medicationId: id,
|
||||
itemId,
|
||||
prevState: existing.state,
|
||||
nextState: updated.state,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.inventory.update",
|
||||
entity_type: "inventory_item",
|
||||
entity_id: itemId,
|
||||
},
|
||||
meta: { medication_id: id, prev: existing.state, next: updated.state },
|
||||
});
|
||||
|
||||
return apiSuccess(updated);
|
||||
},
|
||||
);
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id, itemId } = await params;
|
||||
|
||||
const existing = await loadOwnedItem(id, itemId, user.id);
|
||||
if (!existing) return apiError("Inventory item not found", 404);
|
||||
|
||||
await prisma.medicationInventoryItem.delete({ where: { id: itemId } });
|
||||
|
||||
await auditLog("medication.inventory.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
medicationId: id,
|
||||
itemId,
|
||||
finalState: existing.state,
|
||||
dosesRemaining: existing.dosesRemaining,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.inventory.delete",
|
||||
entity_type: "inventory_item",
|
||||
entity_id: itemId,
|
||||
},
|
||||
meta: { medication_id: id },
|
||||
});
|
||||
|
||||
return apiSuccess({ id: itemId, deleted: true });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,351 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: { findUnique: vi.fn() },
|
||||
medicationInventoryItem: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, POST } from "../route";
|
||||
import { PATCH, DELETE } from "../[itemId]/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
const MED_OK = { id: "med-1", userId: "user-1" };
|
||||
|
||||
function jsonReq(url: string, body: unknown, method = "POST"): NextRequest {
|
||||
return new NextRequest(url, {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/medications/[id]/inventory", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/inventory"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 404 when the medication is not owned by the caller", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
userId: "OTHER",
|
||||
} as never);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/inventory"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns the inventory list for the owned medication", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
vi.mocked(prisma.medicationInventoryItem.findMany).mockResolvedValue([
|
||||
{ id: "inv-1", state: "ACTIVE" },
|
||||
{ id: "inv-2", state: "IN_USE" },
|
||||
] as never);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/inventory"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { items: unknown[]; meta: { total: number } };
|
||||
};
|
||||
expect(body.data.items).toHaveLength(2);
|
||||
expect(body.data.meta.total).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/medications/[id]/inventory", () => {
|
||||
it("rejects unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/inventory", {
|
||||
dosesTotal: 4,
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 422 when dosesTotal is missing or invalid", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/inventory", {
|
||||
dosesTotal: 0,
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("creates an ACTIVE pen with computed expiresAt", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
vi.mocked(prisma.medicationInventoryItem.create).mockResolvedValue({
|
||||
id: "inv-new",
|
||||
state: "ACTIVE",
|
||||
} as never);
|
||||
|
||||
const printed = "2027-06-01T00:00:00Z";
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/inventory", {
|
||||
dosesTotal: 4,
|
||||
printedExpiry: printed,
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.medicationInventoryItem.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
state: "ACTIVE",
|
||||
dosesTotal: 4,
|
||||
dosesRemaining: 4,
|
||||
firstUseAt: null,
|
||||
printedExpiry: new Date(printed),
|
||||
expiresAt: new Date(printed),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 429 when the rate limit is exceeded", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/inventory", {
|
||||
dosesTotal: 4,
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/medications/[id]/inventory/[itemId]", () => {
|
||||
const existingActive = {
|
||||
id: "inv-1",
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
state: "ACTIVE" as const,
|
||||
dosesTotal: 4,
|
||||
dosesRemaining: 4,
|
||||
firstUseAt: null,
|
||||
printedExpiry: null,
|
||||
purchasedAt: null,
|
||||
notes: null,
|
||||
expiresAt: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
it("flips ACTIVE → IN_USE on markAsFirstUseAt", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue(
|
||||
existingActive as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
|
||||
...existingActive,
|
||||
state: "IN_USE",
|
||||
} as never);
|
||||
|
||||
const intake = "2026-05-14T12:00:00Z";
|
||||
const res = await PATCH(
|
||||
jsonReq(
|
||||
"http://localhost/api/medications/med-1/inventory/inv-1",
|
||||
{ markAsFirstUseAt: intake },
|
||||
"PATCH",
|
||||
),
|
||||
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock
|
||||
.calls[0][0];
|
||||
expect(updateCall.data).toMatchObject({
|
||||
state: "IN_USE",
|
||||
firstUseAt: new Date(intake),
|
||||
});
|
||||
});
|
||||
|
||||
it("flips state to USED_UP and zeros remaining on markAsUsedUp", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
|
||||
...existingActive,
|
||||
state: "IN_USE",
|
||||
firstUseAt: new Date("2026-05-01"),
|
||||
dosesRemaining: 2,
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
|
||||
...existingActive,
|
||||
state: "USED_UP",
|
||||
dosesRemaining: 0,
|
||||
} as never);
|
||||
|
||||
const res = await PATCH(
|
||||
jsonReq(
|
||||
"http://localhost/api/medications/med-1/inventory/inv-1",
|
||||
{ markAsUsedUp: true },
|
||||
"PATCH",
|
||||
),
|
||||
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock
|
||||
.calls[0][0];
|
||||
expect(updateCall.data).toMatchObject({
|
||||
state: "USED_UP",
|
||||
dosesRemaining: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it("flips ACTIVE → EXPIRED when markAsFirstUseAt is more than 30 days back-dated", async () => {
|
||||
// Regression for the W19b state-machine bypass: composing the next
|
||||
// state by hand sent ACTIVE → IN_USE on any first-use stamp, but a
|
||||
// back-dated stamp whose 30-day window already lapsed must land at
|
||||
// EXPIRED. Re-running `computeInventoryState` after the composed
|
||||
// view closes the gap.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue(
|
||||
existingActive as never,
|
||||
);
|
||||
vi.mocked(prisma.medicationInventoryItem.update).mockResolvedValue({
|
||||
...existingActive,
|
||||
state: "EXPIRED",
|
||||
} as never);
|
||||
|
||||
// 45 days in the past — well past the 30-day in-use window.
|
||||
const backdated = new Date(
|
||||
Date.now() - 45 * 24 * 60 * 60 * 1000,
|
||||
).toISOString();
|
||||
const res = await PATCH(
|
||||
jsonReq(
|
||||
"http://localhost/api/medications/med-1/inventory/inv-1",
|
||||
{ markAsFirstUseAt: backdated },
|
||||
"PATCH",
|
||||
),
|
||||
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const updateCall = vi.mocked(prisma.medicationInventoryItem.update).mock
|
||||
.calls[0][0];
|
||||
expect(updateCall.data).toMatchObject({
|
||||
state: "EXPIRED",
|
||||
firstUseAt: new Date(backdated),
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects 404 when the item belongs to another user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
|
||||
...existingActive,
|
||||
userId: "OTHER",
|
||||
} as never);
|
||||
|
||||
const res = await PATCH(
|
||||
jsonReq(
|
||||
"http://localhost/api/medications/med-1/inventory/inv-1",
|
||||
{ markAsUsedUp: true },
|
||||
"PATCH",
|
||||
),
|
||||
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/medications/[id]/inventory/[itemId]", () => {
|
||||
it("hard-deletes the row and audit-logs the final state", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medicationInventoryItem.findUnique).mockResolvedValue({
|
||||
id: "inv-1",
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
state: "EXPIRED",
|
||||
dosesRemaining: 2,
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationInventoryItem.delete).mockResolvedValue({
|
||||
id: "inv-1",
|
||||
} as never);
|
||||
|
||||
const res = await DELETE(
|
||||
new NextRequest("http://localhost/api/medications/med-1/inventory/inv-1", {
|
||||
method: "DELETE",
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1", itemId: "inv-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationInventoryItem.delete).toHaveBeenCalledWith({
|
||||
where: { id: "inv-1" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* v1.4.25 W19b — pen / vial inventory CRUD.
|
||||
*
|
||||
* GET /api/medications/[id]/inventory — list (user-scoped, all states)
|
||||
* POST /api/medications/[id]/inventory — register a new pen / vial
|
||||
*
|
||||
* Per-item operations live on the `[itemId]` sub-route.
|
||||
*
|
||||
* Auth: cookie-session via requireAuth(); the medication is verified
|
||||
* to belong to the caller before any read/write touches inventory.
|
||||
* Rate-limit 30/min/user on the POST path (creation is the only
|
||||
* surface that can spam writes; GET is read-only and benign).
|
||||
* Audit-log every mutation with the affected itemId.
|
||||
*/
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { createInventoryItemSchema } from "@/lib/validations/medication";
|
||||
import { buildCreateInventoryInput } from "@/lib/medications/inventory/service";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
const POST_RATE_LIMIT = 30;
|
||||
const POST_WINDOW_MS = 60_000;
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const items = await prisma.medicationInventoryItem.findMany({
|
||||
where: { userId: user.id, medicationId: id },
|
||||
orderBy: [{ state: "asc" }, { expiresAt: "asc" }, { createdAt: "asc" }],
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "medication.inventory.list" },
|
||||
meta: { medication_id: id, total: items.length },
|
||||
});
|
||||
|
||||
return apiSuccess({ items, meta: { total: items.length } });
|
||||
},
|
||||
);
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
// Per-user POST rate-limit — 30/min is generous for normal pen
|
||||
// registrations but cuts off the spam case.
|
||||
const rl = await checkRateLimit(
|
||||
`medication-inventory:post:${user.id}`,
|
||||
POST_RATE_LIMIT,
|
||||
POST_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many requests", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = createInventoryItemSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const { dosesTotal, printedExpiry, purchasedAt, notes } = parsed.data;
|
||||
|
||||
const created = await prisma.medicationInventoryItem.create({
|
||||
data: buildCreateInventoryInput({
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
dosesTotal,
|
||||
printedExpiry: printedExpiry ?? null,
|
||||
purchasedAt: purchasedAt ?? null,
|
||||
notes: notes ?? null,
|
||||
}),
|
||||
});
|
||||
|
||||
await auditLog("medication.inventory.create", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
medicationId: id,
|
||||
itemId: created.id,
|
||||
dosesTotal,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.inventory.create",
|
||||
entity_type: "inventory_item",
|
||||
entity_id: created.id,
|
||||
},
|
||||
meta: { medication_id: id },
|
||||
});
|
||||
|
||||
return apiSuccess(created, 201);
|
||||
},
|
||||
);
|
||||
@@ -3,6 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { phaseConfigSchema } from "@/lib/validations/phase-config";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
@@ -12,13 +13,9 @@ export const GET = apiHandler(
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
const medication = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const config = await prisma.reminderPhaseConfig.findUnique({
|
||||
where: { medicationId: id },
|
||||
@@ -53,13 +50,9 @@ export const PUT = apiHandler(
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
const medication = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
|
||||
@@ -95,13 +88,9 @@ export const DELETE = apiHandler(
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { id } = await params;
|
||||
const medication = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
select: { userId: true },
|
||||
});
|
||||
if (!medication || medication.userId !== user.id) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
await prisma.reminderPhaseConfig.deleteMany({
|
||||
where: { medicationId: id },
|
||||
|
||||
@@ -71,8 +71,16 @@ export const PUT = apiHandler(
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const { name, dose, category, active, notificationsEnabled, schedules } =
|
||||
parsed.data;
|
||||
const {
|
||||
name,
|
||||
dose,
|
||||
category,
|
||||
treatmentClass,
|
||||
dosesPerUnit,
|
||||
active,
|
||||
notificationsEnabled,
|
||||
schedules,
|
||||
} = parsed.data;
|
||||
|
||||
const pausedAtPatch =
|
||||
active === undefined
|
||||
@@ -93,6 +101,8 @@ export const PUT = apiHandler(
|
||||
const baseUpdateData = {
|
||||
...(name !== undefined && { name }),
|
||||
...(dose !== undefined && { dose }),
|
||||
...(treatmentClass !== undefined && { treatmentClass }),
|
||||
...(dosesPerUnit !== undefined && { dosesPerUnit }),
|
||||
...(active !== undefined && { active }),
|
||||
...(notificationsEnabled !== undefined && { notificationsEnabled }),
|
||||
...(schedules && {
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* v1.4.25 W19d — per-row side-effect operations.
|
||||
*
|
||||
* DELETE /api/medications/[id]/side-effects/[logId]
|
||||
* - hard delete; the row is user-owned, non-clinical, and the
|
||||
* audit log captures enough state to reconstruct it if needed.
|
||||
*
|
||||
* Marc-decision (from the W19d brief): allow delete at any time
|
||||
* rather than a 24-hour retraction window. Side-effect rows are not
|
||||
* a clinical record; the user owns them and a stale mis-entry should
|
||||
* never be undeletable. The audit trail preserves "what was deleted
|
||||
* when" for any forensic reconstruction.
|
||||
*/
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string; logId: string }> };
|
||||
|
||||
async function loadOwnedRow(
|
||||
medicationId: string,
|
||||
logId: string,
|
||||
userId: string,
|
||||
) {
|
||||
const row = await prisma.medicationSideEffect.findUnique({
|
||||
where: { id: logId },
|
||||
});
|
||||
if (!row || row.userId !== userId || row.medicationId !== medicationId) {
|
||||
return null;
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id, logId } = await params;
|
||||
|
||||
const existing = await loadOwnedRow(id, logId, user.id);
|
||||
if (!existing) return apiError("Side-effect log not found", 404);
|
||||
|
||||
await prisma.medicationSideEffect.delete({ where: { id: logId } });
|
||||
|
||||
await auditLog("medication.sideEffect.delete", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
medicationId: id,
|
||||
sideEffectId: logId,
|
||||
entry: existing.entry,
|
||||
severity: existing.severity,
|
||||
occurredAt: existing.occurredAt.toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.sideEffect.delete",
|
||||
entity_type: "medication_side_effect",
|
||||
entity_id: logId,
|
||||
},
|
||||
meta: { medication_id: id, entry: existing.entry },
|
||||
});
|
||||
|
||||
return apiSuccess({ id: logId, deleted: true });
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* v1.4.25 W19d — side-effect API route tests.
|
||||
*
|
||||
* Mirrors the inventory route test fixture pattern: every external
|
||||
* dependency is mocked at module boundaries (Prisma, session, audit,
|
||||
* rate-limit, logging transport), and each describe block exercises
|
||||
* one HTTP verb's contract.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: { findUnique: vi.fn() },
|
||||
medicationSideEffect: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET, POST } from "../route";
|
||||
import { DELETE } from "../[logId]/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
const MED_OK = { id: "med-1", userId: "user-1" };
|
||||
|
||||
function jsonReq(url: string, body: unknown, method = "POST"): NextRequest {
|
||||
return new NextRequest(url, {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/medications/[id]/side-effects", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/side-effects"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 404 when the medication is not owned by the caller", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue({
|
||||
id: "med-1",
|
||||
userId: "OTHER",
|
||||
} as never);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/side-effects"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns the logs for the owned medication", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
vi.mocked(prisma.medicationSideEffect.findMany).mockResolvedValue([
|
||||
{ id: "se-1", entry: "NAUSEA", severity: 2 },
|
||||
{ id: "se-2", entry: "DIARRHEA", severity: 3 },
|
||||
] as never);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/side-effects"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { items: unknown[]; meta: { total: number } };
|
||||
};
|
||||
expect(body.data.items).toHaveLength(2);
|
||||
expect(body.data.meta.total).toBe(2);
|
||||
});
|
||||
|
||||
it("applies from / to / limit query params", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
vi.mocked(prisma.medicationSideEffect.findMany).mockResolvedValue(
|
||||
[] as never,
|
||||
);
|
||||
|
||||
const from = "2026-05-01T00:00:00Z";
|
||||
const to = "2026-05-14T00:00:00Z";
|
||||
await GET(
|
||||
new NextRequest(
|
||||
`http://localhost/api/medications/med-1/side-effects?from=${from}&to=${to}&limit=10`,
|
||||
),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
|
||||
const call = vi.mocked(prisma.medicationSideEffect.findMany).mock.calls[0][0];
|
||||
expect(call).toMatchObject({
|
||||
where: {
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
occurredAt: { gte: new Date(from), lt: new Date(to) },
|
||||
},
|
||||
take: 10,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/medications/[id]/side-effects", () => {
|
||||
it("rejects unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/side-effects", {
|
||||
category: "GI",
|
||||
entry: "NAUSEA",
|
||||
severity: 2,
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 422 when severity is out of range", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/side-effects", {
|
||||
category: "GI",
|
||||
entry: "NAUSEA",
|
||||
severity: 7,
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("derives category server-side and ignores any client-supplied category (v1.4.25 W21 Fix-N / code-M6)", async () => {
|
||||
// Client maliciously sends category=INJECTION_SITE for a
|
||||
// NAUSEA entry. The server now derives the canonical category
|
||||
// from `entry`, so the row lands with category="GI" regardless
|
||||
// of the client claim. Backwards-compatible with older clients
|
||||
// that still send `category`.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
vi.mocked(prisma.medicationSideEffect.create).mockResolvedValue({
|
||||
id: "se-new",
|
||||
entry: "NAUSEA",
|
||||
category: "GI",
|
||||
severity: 2,
|
||||
} as never);
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/side-effects", {
|
||||
category: "INJECTION_SITE",
|
||||
entry: "NAUSEA",
|
||||
severity: 2,
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
const callArgs = vi.mocked(prisma.medicationSideEffect.create).mock
|
||||
.calls[0][0] as { data: { category: string } };
|
||||
expect(callArgs.data.category).toBe("GI");
|
||||
});
|
||||
|
||||
it("creates a row with the server-derived category", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
vi.mocked(prisma.medicationSideEffect.create).mockResolvedValue({
|
||||
id: "se-new",
|
||||
entry: "NAUSEA",
|
||||
category: "GI",
|
||||
severity: 2,
|
||||
} as never);
|
||||
|
||||
const occurredAt = "2026-05-14T08:00:00Z";
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/side-effects", {
|
||||
category: "GI",
|
||||
entry: "NAUSEA",
|
||||
severity: 2,
|
||||
occurredAt,
|
||||
notes: "after breakfast",
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
expect(prisma.medicationSideEffect.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
category: "GI",
|
||||
entry: "NAUSEA",
|
||||
severity: 2,
|
||||
occurredAt: new Date(occurredAt),
|
||||
notes: "after breakfast",
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults occurredAt to now when omitted", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
vi.mocked(prisma.medicationSideEffect.create).mockResolvedValue({
|
||||
id: "se-new",
|
||||
} as never);
|
||||
|
||||
const before = Date.now();
|
||||
await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/side-effects", {
|
||||
category: "GLP1_SPECIFIC",
|
||||
entry: "EARLY_SATIETY",
|
||||
severity: 3,
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
const after = Date.now();
|
||||
|
||||
const call = vi.mocked(prisma.medicationSideEffect.create).mock.calls[0][0];
|
||||
const created = call.data.occurredAt as Date;
|
||||
expect(created.getTime()).toBeGreaterThanOrEqual(before);
|
||||
expect(created.getTime()).toBeLessThanOrEqual(after);
|
||||
});
|
||||
|
||||
it("returns 429 when the rate limit is exceeded", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(MED_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await POST(
|
||||
jsonReq("http://localhost/api/medications/med-1/side-effects", {
|
||||
category: "GI",
|
||||
entry: "NAUSEA",
|
||||
severity: 2,
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/medications/[id]/side-effects/[logId]", () => {
|
||||
it("returns 401 unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await DELETE(
|
||||
new NextRequest(
|
||||
"http://localhost/api/medications/med-1/side-effects/se-1",
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
{ params: Promise.resolve({ id: "med-1", logId: "se-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 404 when the row belongs to another user", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medicationSideEffect.findUnique).mockResolvedValue({
|
||||
id: "se-1",
|
||||
userId: "OTHER",
|
||||
medicationId: "med-1",
|
||||
entry: "NAUSEA",
|
||||
severity: 2,
|
||||
occurredAt: new Date(),
|
||||
} as never);
|
||||
|
||||
const res = await DELETE(
|
||||
new NextRequest(
|
||||
"http://localhost/api/medications/med-1/side-effects/se-1",
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
{ params: Promise.resolve({ id: "med-1", logId: "se-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("hard-deletes the row and audit-logs entry + severity", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medicationSideEffect.findUnique).mockResolvedValue({
|
||||
id: "se-1",
|
||||
userId: "user-1",
|
||||
medicationId: "med-1",
|
||||
entry: "NAUSEA",
|
||||
severity: 3,
|
||||
occurredAt: new Date("2026-05-14T08:00:00Z"),
|
||||
} as never);
|
||||
vi.mocked(prisma.medicationSideEffect.delete).mockResolvedValue({
|
||||
id: "se-1",
|
||||
} as never);
|
||||
|
||||
const res = await DELETE(
|
||||
new NextRequest(
|
||||
"http://localhost/api/medications/med-1/side-effects/se-1",
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
{ params: Promise.resolve({ id: "med-1", logId: "se-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.medicationSideEffect.delete).toHaveBeenCalledWith({
|
||||
where: { id: "se-1" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* v1.4.25 W19d — GLP-1 side-effect log CRUD (collection).
|
||||
*
|
||||
* GET /api/medications/[id]/side-effects?from=ISO&to=ISO&limit=50
|
||||
* - returns the user's logs for this medication, newest first,
|
||||
* optionally bounded by an [from, to) window.
|
||||
*
|
||||
* POST /api/medications/[id]/side-effects
|
||||
* - creates a new entry. Body: { category, entry, severity,
|
||||
* occurredAt?, notes? }. Category is verified against the
|
||||
* authoritative entry → category mapping; mismatch → 422.
|
||||
*
|
||||
* Auth: cookie-session via requireAuth(); the medication is verified
|
||||
* to belong to the caller before any read/write.
|
||||
* Rate-limit: 30/min/user on the POST path.
|
||||
* Audit-log every mutation with the affected row id.
|
||||
*/
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import {
|
||||
createSideEffectSchema,
|
||||
listSideEffectsSchema,
|
||||
} from "@/lib/medications/side-effects/validators";
|
||||
import { categoryForEntry } from "@/lib/medications/side-effects/taxonomy";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
const POST_RATE_LIMIT = 30;
|
||||
const POST_WINDOW_MS = 60_000;
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const url = new URL(request.url);
|
||||
const parsed = listSideEffectsSchema.safeParse({
|
||||
from: url.searchParams.get("from") ?? undefined,
|
||||
to: url.searchParams.get("to") ?? undefined,
|
||||
limit: url.searchParams.get("limit") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const { from, to, limit } = parsed.data;
|
||||
|
||||
const items = await prisma.medicationSideEffect.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
...(from || to
|
||||
? {
|
||||
occurredAt: {
|
||||
...(from ? { gte: from } : {}),
|
||||
...(to ? { lt: to } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: { occurredAt: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "medication.sideEffect.list" },
|
||||
meta: { medication_id: id, total: items.length },
|
||||
});
|
||||
|
||||
return apiSuccess({ items, meta: { total: items.length } });
|
||||
},
|
||||
);
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
// Per-user POST rate-limit — 30/min comfortably absorbs a session
|
||||
// of bulk back-fill (e.g. "log yesterday's symptoms") while cutting
|
||||
// off automated abuse.
|
||||
const rl = await checkRateLimit(
|
||||
`medication-side-effect:post:${user.id}`,
|
||||
POST_RATE_LIMIT,
|
||||
POST_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Too many requests", 429, {
|
||||
headers: rateLimitHeaders(rl),
|
||||
});
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = createSideEffectSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const { entry, severity, occurredAt, notes } = parsed.data;
|
||||
|
||||
// v1.4.25 W21 Fix-N (code-M6) — category is derived server-side
|
||||
// from the entry via the authoritative taxonomy mapping. The wire
|
||||
// schema no longer accepts `category`; older clients that still
|
||||
// send it now have it ignored by Zod's strict drop, and the row
|
||||
// lands with the correct (entry-derived) category every time.
|
||||
const category = categoryForEntry(entry);
|
||||
|
||||
const created = await prisma.medicationSideEffect.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
medicationId: id,
|
||||
category,
|
||||
entry,
|
||||
severity,
|
||||
occurredAt: occurredAt ?? new Date(),
|
||||
notes: notes ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("medication.sideEffect.create", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
medicationId: id,
|
||||
sideEffectId: created.id,
|
||||
entry,
|
||||
severity,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.sideEffect.create",
|
||||
entity_type: "medication_side_effect",
|
||||
entity_id: created.id,
|
||||
},
|
||||
meta: { medication_id: id, entry, severity },
|
||||
});
|
||||
|
||||
return apiSuccess(created, 201);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* v1.4.25 W19f — titration-ladder route tests.
|
||||
*
|
||||
* Mirrors the W19e cadence-route test fixture: external dependencies
|
||||
* are mocked at module boundaries (prisma, session, logging
|
||||
* transport) so each test pins one contract case. The pure ladder
|
||||
* helpers are covered separately in
|
||||
* `src/lib/medications/titration/__tests__/ladder.test.ts`.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
medication: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
function makeMed(
|
||||
overrides: Partial<{
|
||||
name: string;
|
||||
dose: string;
|
||||
treatmentClass: "GLP1" | "GENERIC";
|
||||
userId: string;
|
||||
doseChanges: Array<{ effectiveFrom: Date; doseValue: number }>;
|
||||
}> = {},
|
||||
) {
|
||||
return {
|
||||
id: "med-1",
|
||||
name: overrides.name ?? "Mounjaro",
|
||||
dose: overrides.dose ?? "5 mg",
|
||||
treatmentClass: overrides.treatmentClass ?? ("GLP1" as const),
|
||||
userId: overrides.userId ?? "user-1",
|
||||
createdAt: new Date(),
|
||||
doseChanges: overrides.doseChanges ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
describe("GET /api/medications/[id]/titration", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/titration"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 404 when the medication is not owned by the caller", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
makeMed({ userId: "OTHER" }) as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/titration"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when the medication is not GLP-1", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
makeMed({ treatmentClass: "GENERIC" }) as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/titration"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 when the medication brand is not in the GLP-1 catalog", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
makeMed({ name: "MysteryDrug" }) as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/titration"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("returns the ladder + current step for a Mounjaro user on 5 mg", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
makeMed({
|
||||
name: "Mounjaro",
|
||||
dose: "5 mg",
|
||||
doseChanges: [
|
||||
{
|
||||
effectiveFrom: new Date(Date.now() - 6 * 7 * 24 * 60 * 60 * 1000),
|
||||
doseValue: 2.5,
|
||||
},
|
||||
{
|
||||
effectiveFrom: new Date(Date.now() - 2 * 7 * 24 * 60 * 60 * 1000),
|
||||
doseValue: 5,
|
||||
},
|
||||
],
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/titration"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.data.drugId).toBe("tirzepatide");
|
||||
expect(json.data.ladder.length).toBe(6);
|
||||
expect(json.data.currentStep.doseMg).toBe(5);
|
||||
expect(json.data.currentStepIndex).toBe(1);
|
||||
expect(json.data.weeksOnCurrentStep).toBe(2);
|
||||
expect(json.data.nextStep.doseMg).toBe(7.5);
|
||||
expect(json.data.escalationDue).toBe(false);
|
||||
});
|
||||
|
||||
it("returns a null current step when the user dose is outside any ladder bucket", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
makeMed({
|
||||
name: "Mounjaro",
|
||||
dose: "6 mg", // outside the ±10 % window of 5 and 7.5
|
||||
doseChanges: [],
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/titration"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.data.currentStep).toBeNull();
|
||||
expect(json.data.currentStepIndex).toBeNull();
|
||||
expect(json.data.nextStep).toBeNull();
|
||||
expect(json.data.escalationDue).toBe(false);
|
||||
});
|
||||
|
||||
it("returns a null next-step at the ladder ceiling", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
makeMed({
|
||||
name: "Mounjaro",
|
||||
dose: "15 mg",
|
||||
doseChanges: [
|
||||
{
|
||||
effectiveFrom: new Date(Date.now() - 5 * 7 * 24 * 60 * 60 * 1000),
|
||||
doseValue: 15,
|
||||
},
|
||||
],
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/titration"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.data.currentStep.doseMg).toBe(15);
|
||||
expect(json.data.nextStep).toBeNull();
|
||||
expect(json.data.escalationDue).toBe(false);
|
||||
});
|
||||
|
||||
it("flags escalationDue=true past the EMA dwell-time on a non-ceiling step", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.medication.findUnique).mockResolvedValue(
|
||||
makeMed({
|
||||
name: "Mounjaro",
|
||||
dose: "2.5 mg",
|
||||
doseChanges: [
|
||||
{
|
||||
// Six weeks ago, past tirzepatide's 4-week interval.
|
||||
effectiveFrom: new Date(Date.now() - 6 * 7 * 24 * 60 * 60 * 1000),
|
||||
doseValue: 2.5,
|
||||
},
|
||||
],
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const res = await GET(
|
||||
new NextRequest("http://localhost/api/medications/med-1/titration"),
|
||||
{ params: Promise.resolve({ id: "med-1" }) },
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const json = await res.json();
|
||||
expect(json.data.currentStep.doseMg).toBe(2.5);
|
||||
expect(json.data.weeksOnCurrentStep).toBe(6);
|
||||
expect(json.data.escalationDue).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* v1.4.25 W19f — GLP-1 titration-ladder read endpoint.
|
||||
*
|
||||
* GET /api/medications/[id]/titration
|
||||
* - returns the EMA-reference ladder for the medication's drug,
|
||||
* the user's current step (matched within ±10 % tolerance to
|
||||
* their latest dose), how long they've been on that step, the
|
||||
* immediate-next step (if any), and the observational
|
||||
* escalation-due flag.
|
||||
*
|
||||
* Pure computation — no writes. Delegates math to the
|
||||
* `src/lib/medications/titration/ladder` module so the route, the
|
||||
* Coach snapshot (future wave), and the detail-page section all read
|
||||
* the same numbers.
|
||||
*
|
||||
* Auth: cookie-session via requireAuth(); medication is verified to
|
||||
* belong to the caller before any read. Defence-in-depth: returns
|
||||
* 404 for non-GLP-1 medications (the UI already gates on
|
||||
* `treatmentClass === "GLP1"`).
|
||||
*/
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiError, apiSuccess } from "@/lib/api-response";
|
||||
import {
|
||||
findDrugByBrand,
|
||||
findDrugIdByBrand,
|
||||
} from "@/lib/medications/glp1-knowledge";
|
||||
import { parseDoseMgOrNull } from "@/lib/medications/dose-string";
|
||||
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
|
||||
import {
|
||||
escalationDue,
|
||||
findCurrentStep,
|
||||
ladderFromRecord,
|
||||
nextStep,
|
||||
weeksOnCurrentStep,
|
||||
} from "@/lib/medications/titration/ladder";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
// v1.4.25 W21 Fix-N — privacy gate hoisted to the shared helper so
|
||||
// the 404 leak shape stays consistent across every medication
|
||||
// sub-route.
|
||||
const guard = await assertMedicationOwnership(id, user.id);
|
||||
if (guard) return guard;
|
||||
|
||||
const med = await prisma.medication.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
doseChanges: { orderBy: { effectiveFrom: "asc" } },
|
||||
},
|
||||
});
|
||||
if (!med) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
if (med.treatmentClass !== "GLP1") {
|
||||
// Defence-in-depth: the UI already gates the section on this
|
||||
// class, but the route refuses to surface the ladder for a
|
||||
// non-GLP-1 row so we never leak a misleading "you are here"
|
||||
// on a generic medication.
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
// Resolve the GLP-1 drug record from the medication name. Same
|
||||
// path the DrugLevelChart uses; null when the medication name
|
||||
// doesn't map to a catalog brand (e.g. user typed a generic
|
||||
// INN instead of a brand). v1.4.25 W21 Fix-N — both lookups go
|
||||
// through the shared helpers in glp1-knowledge.
|
||||
const record = findDrugByBrand(med.name);
|
||||
const drugId = findDrugIdByBrand(med.name);
|
||||
if (!record || !drugId) {
|
||||
return apiError("Medication not found", 404);
|
||||
}
|
||||
|
||||
const ladder = ladderFromRecord(record);
|
||||
|
||||
// Resolve the user's most-recent dose. Prefer the
|
||||
// MedicationDoseChange stream (the history of record); fall back
|
||||
// to the legacy free-text `medication.dose` if the stream is empty.
|
||||
const latestChange =
|
||||
med.doseChanges.length > 0
|
||||
? med.doseChanges[med.doseChanges.length - 1]
|
||||
: null;
|
||||
const latestDoseMg = latestChange
|
||||
? latestChange.doseValue
|
||||
: parseDoseMgOrNull(med.dose);
|
||||
|
||||
const currentStep = findCurrentStep(drugId, latestDoseMg);
|
||||
const next = nextStep(drugId, currentStep);
|
||||
const asOf = new Date();
|
||||
const weeks = weeksOnCurrentStep(
|
||||
drugId,
|
||||
currentStep,
|
||||
med.doseChanges.map((dc) => ({
|
||||
effectiveFrom: dc.effectiveFrom,
|
||||
doseValue: dc.doseValue,
|
||||
})),
|
||||
asOf,
|
||||
);
|
||||
const due = escalationDue(drugId, currentStep, weeks);
|
||||
|
||||
annotate({
|
||||
action: {
|
||||
name: "medication.titration",
|
||||
entity_type: "medication",
|
||||
entity_id: id,
|
||||
},
|
||||
meta: {
|
||||
drug_id: drugId,
|
||||
step_index: currentStep?.stepIndex ?? null,
|
||||
weeks_on_step: weeks,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
drugId,
|
||||
drugInn: record.inn,
|
||||
ladder,
|
||||
currentStep,
|
||||
currentStepIndex: currentStep?.stepIndex ?? null,
|
||||
weeksOnCurrentStep: weeks,
|
||||
nextStep: next,
|
||||
escalationDue: due,
|
||||
sourceEMA: record.sourceEMA,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { userDayKey, DEFAULT_TIMEZONE } from "@/lib/tz/resolver";
|
||||
|
||||
const querySchema = z.object({
|
||||
scope: z.enum(["today", "compliance"]),
|
||||
@@ -42,9 +43,9 @@ const updateSchema = z.object({
|
||||
.optional(),
|
||||
});
|
||||
|
||||
function startOfDayBerlin(date: Date): Date {
|
||||
function startOfDayInTz(date: Date, tz: string): Date {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Europe/Berlin",
|
||||
timeZone: tz,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
@@ -55,12 +56,6 @@ function startOfDayBerlin(date: Date): Date {
|
||||
return new Date(`${y}-${m}-${d}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
function berlinDayKey(date: Date): string {
|
||||
return new Intl.DateTimeFormat("sv-SE", {
|
||||
timeZone: "Europe/Berlin",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
@@ -73,8 +68,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const { scope, days } = parsed.data;
|
||||
|
||||
// v1.4.25 W7b — anchor "today" and per-day compliance buckets to the
|
||||
// user's display timezone so a 23:30 reading in Pacific/Auckland lands
|
||||
// in today's bucket rather than yesterday's Berlin one.
|
||||
const userTz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
|
||||
if (scope === "today") {
|
||||
const todayStart = startOfDayBerlin(new Date());
|
||||
const todayStart = startOfDayInTz(new Date(), userTz);
|
||||
const todayEnd = new Date(todayStart.getTime() + 86_400_000);
|
||||
const events = await prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
@@ -119,10 +119,10 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const buckets = new Map<string, { scheduled: number; taken: number }>();
|
||||
for (let i = 0; i < days; i++) {
|
||||
const d = new Date(Date.now() - i * 86_400_000);
|
||||
buckets.set(berlinDayKey(d), { scheduled: 0, taken: 0 });
|
||||
buckets.set(userDayKey(d, userTz), { scheduled: 0, taken: 0 });
|
||||
}
|
||||
for (const e of events) {
|
||||
const key = berlinDayKey(e.scheduledFor);
|
||||
const key = userDayKey(e.scheduledFor, userTz);
|
||||
const bucket = buckets.get(key);
|
||||
if (!bucket) continue;
|
||||
bucket.scheduled += 1;
|
||||
|
||||
@@ -83,6 +83,11 @@ export const GET = apiHandler(async () => {
|
||||
return apiSuccess(
|
||||
medications.map((m) => ({
|
||||
...m,
|
||||
// v1.4.25 W4d — `category` is the existing clinical taxonomy
|
||||
// (BLOOD_PRESSURE / VITAMIN / ...) from the side-table; the
|
||||
// Prisma model has its own `treatmentClass` (GENERIC | GLP1) that
|
||||
// spreads through `...m` and unlocks the GLP-1 surfaces. Two
|
||||
// orthogonal concepts, both exposed.
|
||||
category: categoryMap[m.id] ?? "OTHER",
|
||||
lastTakenAt: lastTakenAtByMedicationId[m.id] ?? null,
|
||||
todayEventCount: todayEventCountByMedId[m.id] ?? 0,
|
||||
@@ -101,13 +106,18 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
return apiError(parsed.error.issues[0].message, 422);
|
||||
}
|
||||
|
||||
const { name, dose, category, schedules } = parsed.data;
|
||||
const { name, dose, category, treatmentClass, dosesPerUnit, schedules } =
|
||||
parsed.data;
|
||||
|
||||
const medication = await prisma.medication.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
name,
|
||||
dose,
|
||||
// v1.4.25 W4d — treatmentClass + dosesPerUnit are optional in the
|
||||
// wire schema; Prisma fills the default GENERIC when omitted.
|
||||
...(treatmentClass !== undefined && { treatmentClass }),
|
||||
...(dosesPerUnit !== undefined && { dosesPerUnit }),
|
||||
schedules: {
|
||||
create: schedules.map((s) => ({
|
||||
windowStart: s.windowStart,
|
||||
|
||||
@@ -13,15 +13,10 @@ import {
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
|
||||
type RouteParams = { params: Promise<{ id: string }> };
|
||||
|
||||
function toBerlinDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat("sv-SE", {
|
||||
timeZone: "Europe/Berlin",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function parseTags(tags: string | null): string[] {
|
||||
if (!tags) return [];
|
||||
try {
|
||||
@@ -80,8 +75,15 @@ export const PUT = apiHandler(
|
||||
updateData.score = getScoreForMood(data.mood);
|
||||
}
|
||||
if (data.moodLoggedAt !== undefined) {
|
||||
// v1.4.25 W7b — re-anchor the row's `date` to the user's current
|
||||
// displayTimezone. Also refresh the `tz` column so the row's
|
||||
// attribution stays consistent with the new `date`. Legacy rows
|
||||
// promoted via this PUT therefore migrate to per-row tz without
|
||||
// a separate backfill.
|
||||
const tz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
updateData.moodLoggedAt = data.moodLoggedAt;
|
||||
updateData.date = toBerlinDate(data.moodLoggedAt);
|
||||
updateData.date = moodDateKey(data.moodLoggedAt, tz);
|
||||
updateData.tz = tz;
|
||||
}
|
||||
if (data.tags !== undefined) {
|
||||
updateData.tags = data.tags ? JSON.stringify(data.tags) : null;
|
||||
|
||||
@@ -16,12 +16,7 @@ import { Prisma } from "@/generated/prisma/client";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
|
||||
function toBerlinDate(date: Date): string {
|
||||
return new Intl.DateTimeFormat("sv-SE", {
|
||||
timeZone: "Europe/Berlin",
|
||||
}).format(date);
|
||||
}
|
||||
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
|
||||
|
||||
function parseTags(tags: string | null): string[] {
|
||||
if (!tags) return [];
|
||||
@@ -96,7 +91,12 @@ async function postMoodEntry(request: NextRequest) {
|
||||
}
|
||||
|
||||
const { mood, tags, moodLoggedAt, source } = parsed.data;
|
||||
const date = toBerlinDate(moodLoggedAt);
|
||||
// v1.4.25 W7b (Decision A) — anchor the `date` string to the user's
|
||||
// current displayTimezone and store the resolved zone on the row.
|
||||
// Legacy rows with `tz IS NULL` continue to read as Europe/Berlin
|
||||
// (see `src/lib/mood/date-key.ts`).
|
||||
const tz = user.timezone ?? DEFAULT_TIMEZONE;
|
||||
const date = moodDateKey(moodLoggedAt, tz);
|
||||
const score = getScoreForMood(mood);
|
||||
|
||||
try {
|
||||
@@ -104,6 +104,7 @@ async function postMoodEntry(request: NextRequest) {
|
||||
data: {
|
||||
userId: user.id,
|
||||
date,
|
||||
tz,
|
||||
mood,
|
||||
score,
|
||||
tags: tags ? JSON.stringify(tags) : null,
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* v1.4.25 W21 Fix-M — concurrency regression for
|
||||
* `POST /api/onboarding/step`.
|
||||
*
|
||||
* The step write was fetch-then-update: two parallel tabs both
|
||||
* reading `onboardingStep = 2` and both POSTing `step: 3` would both
|
||||
* succeed because the second write didn't re-check the precondition.
|
||||
* The route now claims the row via `updateMany` with the precondition
|
||||
* embedded in the WHERE clause, so exactly one update lands and the
|
||||
* loser sees `count = 0` and returns 409.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
findUniqueOrThrow: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
getSession: vi.fn(),
|
||||
setOnboardingPendingCookie: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn().mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 60_000,
|
||||
}),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../step/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
function req(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/onboarding/step", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 29,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/onboarding/step — happy path", () => {
|
||||
it("advances 0 → 1 on the first wizard submission", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
onboardingStep: 0,
|
||||
onboardingCompletedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
|
||||
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
|
||||
onboardingStep: 1,
|
||||
onboardingCompletedAt: null,
|
||||
} as never);
|
||||
|
||||
const res = await POST(req({ step: 1 }));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { step: number } };
|
||||
expect(body.data.step).toBe(1);
|
||||
});
|
||||
|
||||
it("marks completion on step 4 (sets onboardingCompletedAt)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
onboardingStep: 3,
|
||||
onboardingCompletedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
|
||||
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
|
||||
onboardingStep: 4,
|
||||
onboardingCompletedAt: new Date("2026-05-14T12:00:00Z"),
|
||||
} as never);
|
||||
|
||||
const res = await POST(req({ step: 4 }));
|
||||
expect(res.status).toBe(200);
|
||||
const updateCall = vi.mocked(prisma.user.updateMany).mock.calls[0][0];
|
||||
expect(updateCall.data).toMatchObject({
|
||||
onboardingStep: 4,
|
||||
});
|
||||
expect(updateCall.data).toHaveProperty("onboardingCompletedAt");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/onboarding/step — guard rails", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await POST(req({ step: 1 }));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 422 for an out-of-range step value", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await POST(req({ step: 99 }));
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("returns 409 when the user has already completed onboarding", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
onboardingStep: 4,
|
||||
onboardingCompletedAt: new Date("2026-05-01T12:00:00Z"),
|
||||
} as never);
|
||||
|
||||
const res = await POST(req({ step: 1 }));
|
||||
expect(res.status).toBe(409);
|
||||
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 409 when the requested step is out of order", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
onboardingStep: 1,
|
||||
onboardingCompletedAt: null,
|
||||
} as never);
|
||||
|
||||
// The route sees current = 1; submitting step 3 (skipping 2) must
|
||||
// be rejected before the write.
|
||||
const res = await POST(req({ step: 3 }));
|
||||
expect(res.status).toBe(409);
|
||||
expect(prisma.user.updateMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/onboarding/step — concurrent write race", () => {
|
||||
it("returns 409 when the conditional update affects zero rows", async () => {
|
||||
// Regression for Fix-M / code-M7: two parallel tabs both read
|
||||
// `onboardingStep = 2`, both POST `step: 3`. The first lands;
|
||||
// when the second runs the conditional updateMany, the WHERE
|
||||
// clause no longer matches (the column moved to 3) and Prisma
|
||||
// returns `count = 0`. The route must surface 409 so the client
|
||||
// can refresh and retry instead of double-advancing the wizard.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
onboardingStep: 2,
|
||||
onboardingCompletedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
|
||||
|
||||
const res = await POST(req({ step: 3 }));
|
||||
expect(res.status).toBe(409);
|
||||
// findUniqueOrThrow must NOT be called when the claim fails —
|
||||
// the route returns before re-reading the row.
|
||||
expect(prisma.user.findUniqueOrThrow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes the precondition in the updateMany WHERE clause", async () => {
|
||||
// The whole point of the conditional update is that the WHERE
|
||||
// clause re-asserts the precondition we read. Pin the contract
|
||||
// so a future refactor can't quietly drop the guard.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
onboardingStep: 2,
|
||||
onboardingCompletedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
|
||||
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
|
||||
onboardingStep: 3,
|
||||
onboardingCompletedAt: null,
|
||||
} as never);
|
||||
|
||||
await POST(req({ step: 3 }));
|
||||
const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0][0]
|
||||
.where as Record<string, unknown>;
|
||||
expect(whereArg).toMatchObject({
|
||||
id: "user-1",
|
||||
onboardingCompletedAt: null,
|
||||
});
|
||||
// Strict-equality precondition — Migration 0060 dropped the
|
||||
// legacy nullable path so every advance uses `{ in: [current] }`.
|
||||
expect(whereArg.onboardingStep).toEqual({ in: [2] });
|
||||
});
|
||||
|
||||
it("advances a step-0 row to step 1 using the strict-equality precondition", async () => {
|
||||
// Migration 0060 (v1.4.25 W21 Fix-O) backfilled NULL → 0 and
|
||||
// flipped `onboarding_step` to NOT NULL, so the conditional
|
||||
// update always uses the strict-equality form — there is no
|
||||
// legacy null branch left for the route to widen.
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.user.findUnique).mockResolvedValue({
|
||||
onboardingStep: 0,
|
||||
onboardingCompletedAt: null,
|
||||
} as never);
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 1 } as never);
|
||||
vi.mocked(prisma.user.findUniqueOrThrow).mockResolvedValue({
|
||||
onboardingStep: 1,
|
||||
onboardingCompletedAt: null,
|
||||
} as never);
|
||||
|
||||
const res = await POST(req({ step: 1 }));
|
||||
expect(res.status).toBe(200);
|
||||
const whereArg = vi.mocked(prisma.user.updateMany).mock.calls[0][0]
|
||||
.where as Record<string, unknown>;
|
||||
expect(whereArg.onboardingStep).toEqual({ in: [0] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/onboarding/step — rate limit", () => {
|
||||
it("returns 429 when the per-user rate limit is exceeded", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await POST(req({ step: 1 }));
|
||||
expect(res.status).toBe(429);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { setOnboardingPendingCookie } from "@/lib/auth/session";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
|
||||
/**
|
||||
* v1.4.25 W14b — POST /api/onboarding/step.
|
||||
*
|
||||
* Persists the user's progress through the rebuilt
|
||||
* `/onboarding/[step]` wizard. Body: `{ step: number }` where `step`
|
||||
* is 1..4. The endpoint enforces a strict step-by-step contract:
|
||||
*
|
||||
* - Submitted step must equal `current + 1` (no skipping ahead).
|
||||
* - Submitting step 4 marks completion — `onboardingCompletedAt`
|
||||
* flips from null to NOW() in the same write and the
|
||||
* `hl_onboarding` proxy cookie is cleared.
|
||||
* - Already-completed users (`onboardingCompletedAt != null`)
|
||||
* receive a 409. Replays must call this endpoint only while the
|
||||
* wizard is in-progress.
|
||||
*
|
||||
* Companion to:
|
||||
* - `GET ` not implemented (the User row is already loaded into
|
||||
* every server-rendered step page).
|
||||
* - `POST /api/onboarding/complete` remains the legacy v1.4.20
|
||||
* completion path used by the old single-file wizard; the new
|
||||
* flow uses this endpoint with `step: 4` instead.
|
||||
*
|
||||
* Rate limit: 30 writes / 10 min / user — generous for a 4-step flow
|
||||
* but tight enough to defang a stuck retry loop.
|
||||
*/
|
||||
|
||||
const stepBodySchema = z.object({
|
||||
step: z.number().int().min(1).max(4),
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "onboarding.step" } });
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`onboarding-step:${user.id}`,
|
||||
30,
|
||||
10 * 60 * 1000,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
annotate({
|
||||
action: { name: "onboarding.step" },
|
||||
meta: { outcome: "rate_limited" },
|
||||
});
|
||||
return apiError("Too many onboarding writes, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson<unknown>(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = stepBodySchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
annotate({
|
||||
action: { name: "onboarding.step" },
|
||||
meta: { outcome: "validation_failed" },
|
||||
});
|
||||
return apiError("Invalid step payload", 422);
|
||||
}
|
||||
const { step } = parsed.data;
|
||||
|
||||
// Re-fetch the fresh User row so concurrent step submissions in
|
||||
// separate tabs can't race past each other — the session.user
|
||||
// snapshot might be a request-old.
|
||||
const fresh = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
onboardingStep: true,
|
||||
onboardingCompletedAt: true,
|
||||
},
|
||||
});
|
||||
if (!fresh) {
|
||||
return apiError("User not found", 404);
|
||||
}
|
||||
|
||||
if (fresh.onboardingCompletedAt) {
|
||||
annotate({
|
||||
action: { name: "onboarding.step" },
|
||||
meta: { outcome: "already_completed" },
|
||||
});
|
||||
return apiError("Onboarding already completed", 409);
|
||||
}
|
||||
|
||||
const current = fresh.onboardingStep;
|
||||
if (step !== current + 1) {
|
||||
annotate({
|
||||
action: { name: "onboarding.step" },
|
||||
meta: { outcome: "out_of_order", current, requested: step },
|
||||
});
|
||||
return apiError(
|
||||
`Step out of order — current step is ${current}`,
|
||||
409,
|
||||
);
|
||||
}
|
||||
|
||||
const completing = step === 4;
|
||||
|
||||
// Conditional update — the WHERE clause re-asserts the
|
||||
// precondition we read above so the write can only land on a row
|
||||
// whose state still matches what we validated. Two parallel tabs
|
||||
// both reading `onboardingStep = 2` and both POSTing `step: 3`
|
||||
// would otherwise both succeed (the read-then-write race); with
|
||||
// this guard exactly one update sees `count = 1`, the second sees
|
||||
// `count = 0` and returns 409. `onboardingCompletedAt: null` also
|
||||
// catches a race with `POST /api/onboarding/complete`. Migration
|
||||
// 0060 (v1.4.25 W21) backfilled NULL → 0 and flipped the column
|
||||
// to NOT NULL, so the legacy null branch this code carried before
|
||||
// is dropped — every row now matches the strict-equality form.
|
||||
const claimed = await prisma.user.updateMany({
|
||||
where: {
|
||||
id: user.id,
|
||||
onboardingCompletedAt: null,
|
||||
onboardingStep: { in: [current] },
|
||||
},
|
||||
data: {
|
||||
onboardingStep: step,
|
||||
...(completing ? { onboardingCompletedAt: new Date() } : {}),
|
||||
},
|
||||
});
|
||||
if (claimed.count !== 1) {
|
||||
annotate({
|
||||
action: { name: "onboarding.step" },
|
||||
meta: { outcome: "concurrent_write", current, requested: step },
|
||||
});
|
||||
return apiError(
|
||||
"Onboarding step changed concurrently, refresh and retry",
|
||||
409,
|
||||
);
|
||||
}
|
||||
const updated = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
onboardingStep: true,
|
||||
onboardingCompletedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (completing) {
|
||||
// Mirror /api/onboarding/complete — clear the proxy-readable
|
||||
// pending cookie so the next navigation drops the /onboarding
|
||||
// redirect immediately.
|
||||
await setOnboardingPendingCookie(false);
|
||||
}
|
||||
|
||||
await auditLog("onboarding.step", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
step,
|
||||
completed: completing,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "onboarding.step" },
|
||||
meta: { outcome: completing ? "completed" : "advanced", step },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
step: updated.onboardingStep,
|
||||
onboardingCompletedAt: updated.onboardingCompletedAt,
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
personalRecord: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
function req(url: string): NextRequest {
|
||||
return new NextRequest(url, { method: "GET" });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.personalRecord.findMany).mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("GET /api/personal-records", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await GET(req("http://localhost/api/personal-records"));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns an empty list for a user with no records", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await GET(req("http://localhost/api/personal-records"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: unknown[]; error: null };
|
||||
expect(body.data).toEqual([]);
|
||||
expect(body.error).toBeNull();
|
||||
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1" },
|
||||
orderBy: { achievedAt: "desc" },
|
||||
take: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("passes through a valid metricType filter", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await GET(
|
||||
req("http://localhost/api/personal-records?metricType=VO2_MAX"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", metricType: "VO2_MAX" },
|
||||
orderBy: { achievedAt: "desc" },
|
||||
take: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("drops an unknown metricType silently (loose-typed filter contract)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await GET(
|
||||
req("http://localhost/api/personal-records?metricType=BOGUS"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1" },
|
||||
orderBy: { achievedAt: "desc" },
|
||||
take: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("honours an explicit ?limit param", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await GET(req("http://localhost/api/personal-records?limit=25"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1" },
|
||||
orderBy: { achievedAt: "desc" },
|
||||
take: 25,
|
||||
});
|
||||
});
|
||||
|
||||
it("clamps ?limit to the project-wide ceiling of 500", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await GET(
|
||||
req("http://localhost/api/personal-records?limit=999999"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1" },
|
||||
orderBy: { achievedAt: "desc" },
|
||||
take: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to default on garbage ?limit (defence-in-depth)", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const res = await GET(req("http://localhost/api/personal-records?limit=abc"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.personalRecord.findMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1" },
|
||||
orderBy: { achievedAt: "desc" },
|
||||
take: 100,
|
||||
});
|
||||
});
|
||||
|
||||
it("seeded record round-trips through the response", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
const fakeRecord = {
|
||||
id: "pr-1",
|
||||
userId: "user-1",
|
||||
metricType: "VO2_MAX",
|
||||
metricSlot: null,
|
||||
direction: "MAX",
|
||||
value: 47.2,
|
||||
unit: "mL/(kg·min)",
|
||||
achievedAt: new Date("2026-05-01T07:00:00.000Z"),
|
||||
sourceMeasurementId: "m-1",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: null,
|
||||
createdAt: new Date(),
|
||||
};
|
||||
vi.mocked(prisma.personalRecord.findMany).mockResolvedValue([
|
||||
fakeRecord,
|
||||
] as never);
|
||||
const res = await GET(req("http://localhost/api/personal-records"));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: Array<{ value: number; metricType: string }>;
|
||||
};
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0].value).toBe(47.2);
|
||||
expect(body.data[0].metricType).toBe("VO2_MAX");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* GET /api/personal-records
|
||||
*
|
||||
* v1.4.25 W8d — schema-only release of the PersonalRecord feature.
|
||||
* The detection worker that actually populates rows lands in a later
|
||||
* release (v1.4.26 or v1.5 — TBD). This route exists today so the
|
||||
* v1.5 iOS-Swift app can build its query path against a stable
|
||||
* contract from day one.
|
||||
*
|
||||
* Query params:
|
||||
* - metricType: optional MeasurementType filter (e.g. ?metricType=VO2_MAX)
|
||||
* - limit: optional pagination cap (default 100, max 500)
|
||||
*
|
||||
* Response envelope (matches the project-wide `apiSuccess` contract):
|
||||
* { data: PersonalRecord[], error: null }
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { measurementTypeEnum } from "@/lib/validations/measurement";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// v1.4.25 W10 reconcile (Sr-M4) — pagination bounds. Matches the
|
||||
// `listMeasurementsSchema` ceiling (max 500) so every ingest-and-read
|
||||
// endpoint on the route surface shares the same upper bound. Today
|
||||
// the worker hasn't populated any rows so even an uncapped findMany is
|
||||
// harmless, but a power user with multi-year Apple Health history could
|
||||
// accumulate 50+ PRs per metric × 14 PR-trackable metrics once the
|
||||
// worker lands. Add the cap now so the v1.5 iOS app's query path is
|
||||
// bounded from day one.
|
||||
const DEFAULT_LIMIT = 100;
|
||||
const MAX_LIMIT = 500;
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "personalRecords.list" } });
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const metricTypeParam = searchParams.get("metricType");
|
||||
const limitParam = searchParams.get("limit");
|
||||
|
||||
// Defensive parse — drop unknown values rather than 400 so the
|
||||
// caller's loosely-typed filter doesn't take the page down.
|
||||
const metricType: MeasurementType | null =
|
||||
metricTypeParam && measurementTypeEnum.safeParse(metricTypeParam).success
|
||||
? (metricTypeParam as MeasurementType)
|
||||
: null;
|
||||
|
||||
// Limit clamp: parse → clamp [1, MAX_LIMIT] → default fallback. A
|
||||
// garbage value (`?limit=abc`, `?limit=-1`, `?limit=999999`) silently
|
||||
// clamps to the default rather than 400-ing — same defence-in-depth
|
||||
// stance the metricType parse takes.
|
||||
let limit = DEFAULT_LIMIT;
|
||||
if (limitParam != null) {
|
||||
const parsed = Number(limitParam);
|
||||
if (Number.isInteger(parsed) && parsed > 0) {
|
||||
limit = Math.min(parsed, MAX_LIMIT);
|
||||
}
|
||||
}
|
||||
|
||||
const records = await prisma.personalRecord.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
...(metricType ? { metricType } : {}),
|
||||
},
|
||||
orderBy: { achievedAt: "desc" },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return apiSuccess(records);
|
||||
});
|
||||
@@ -3,7 +3,7 @@ import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { exchangeCode } from "@/lib/withings/client";
|
||||
import { exchangeCode, WITHINGS_OAUTH_SCOPE } from "@/lib/withings/client";
|
||||
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
|
||||
import { setupWebhook } from "@/lib/withings/sync";
|
||||
import { markReconnected } from "@/lib/integrations/status";
|
||||
@@ -72,7 +72,13 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const expiresAt = new Date(Date.now() + tokens.expires_in * 1000);
|
||||
|
||||
// Upsert connection (user may be reconnecting)
|
||||
// v1.4.25 W5d — persist the OAuth scope we requested. The token
|
||||
// response doesn't echo the granted scopes back as a top-level
|
||||
// field, but Withings honours the scope param verbatim — there's
|
||||
// no scope downgrade unless the user explicitly revokes one in
|
||||
// their Health Mate account, which would fail the token exchange
|
||||
// entirely. Storing the requested set is therefore a safe
|
||||
// representation of what the connection holds.
|
||||
await prisma.withingsConnection.upsert({
|
||||
where: { userId: user.id },
|
||||
update: {
|
||||
@@ -80,6 +86,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
accessToken: encrypt(tokens.access_token),
|
||||
refreshToken: encrypt(tokens.refresh_token),
|
||||
tokenExpiresAt: expiresAt,
|
||||
scope: WITHINGS_OAUTH_SCOPE,
|
||||
},
|
||||
create: {
|
||||
userId: user.id,
|
||||
@@ -87,6 +94,7 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
accessToken: encrypt(tokens.access_token),
|
||||
refreshToken: encrypt(tokens.refresh_token),
|
||||
tokenExpiresAt: expiresAt,
|
||||
scope: WITHINGS_OAUTH_SCOPE,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,10 @@ import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { unsubscribeWebhook } from "@/lib/withings/client";
|
||||
import { getWithingsWebhookCallbackUrl } from "@/lib/withings/sync";
|
||||
import {
|
||||
WITHINGS_NOTIFY_APPLIS,
|
||||
getWithingsWebhookCallbackUrl,
|
||||
} from "@/lib/withings/sync";
|
||||
import { markDisconnected } from "@/lib/integrations/status";
|
||||
|
||||
/**
|
||||
@@ -23,13 +26,21 @@ export const POST = apiHandler(async () => {
|
||||
return apiError("No Withings connection", 404);
|
||||
}
|
||||
|
||||
// Try to unsubscribe webhook (best-effort)
|
||||
// Best-effort unsubscribe across every appli category we subscribed
|
||||
// to in setupWebhook. A token-expired failure on one category should
|
||||
// not stop us from cleaning up the others.
|
||||
try {
|
||||
const accessToken = decrypt(connection.accessToken);
|
||||
const callbackUrl = getWithingsWebhookCallbackUrl();
|
||||
await unsubscribeWebhook(accessToken, callbackUrl);
|
||||
for (const appli of WITHINGS_NOTIFY_APPLIS) {
|
||||
try {
|
||||
await unsubscribeWebhook(accessToken, callbackUrl, appli);
|
||||
} catch {
|
||||
// Ignore — token might be expired or the appli was never subscribed.
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Ignore — token might be expired
|
||||
// Ignore — token might be undecryptable.
|
||||
}
|
||||
|
||||
await prisma.withingsConnection.delete({
|
||||
|
||||
@@ -4,7 +4,7 @@ import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { decrypt, encrypt } from "@/lib/crypto";
|
||||
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
|
||||
import { refreshAccessToken } from "@/lib/withings/client";
|
||||
import { hasActivityScope, refreshAccessToken } from "@/lib/withings/client";
|
||||
import { isWithingsRefreshReauthFailure } from "@/lib/withings/sync";
|
||||
import { markReauthRequired } from "@/lib/integrations/status";
|
||||
|
||||
@@ -37,6 +37,7 @@ export const GET = apiHandler(async () => {
|
||||
lastSyncedAt: true,
|
||||
tokenExpiresAt: true,
|
||||
createdAt: true,
|
||||
scope: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -95,5 +96,12 @@ export const GET = apiHandler(async () => {
|
||||
tokenExpired,
|
||||
tokenRefreshFailed,
|
||||
tokenExpiresAt,
|
||||
// v1.4.25 W5d — `scope` is the comma-separated OAuth scope string
|
||||
// returned during the auth flow; `hasActivityScope` is the
|
||||
// pre-computed convenience flag the Settings reconnect banner
|
||||
// reads. Null `scope` means a legacy v1.4.24 connection that's
|
||||
// never re-authed since v1.4.25 — it counts as "missing activity".
|
||||
scope: connection.scope,
|
||||
hasActivityScope: hasActivityScope(connection.scope),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// --- Module-boundary mocks must come before importing the route. ---
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
withingsConnection: { findFirst: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/withings/sync", () => ({
|
||||
syncUserMeasurements: vi.fn(),
|
||||
}));
|
||||
|
||||
// v1.4.25 W17b/c — activity / sleep paths dispatch through pg-boss.
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/withings/sync-activity", () => ({
|
||||
syncUserActivity: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/withings/sync-sleep", () => ({
|
||||
syncUserSleep: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => ({
|
||||
setAuth: vi.fn(),
|
||||
addWarning: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST, GET, HEAD } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { syncUserMeasurements } from "@/lib/withings/sync";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
|
||||
const ORIGINAL_SECRET = process.env.WITHINGS_WEBHOOK_SECRET;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
process.env.WITHINGS_WEBHOOK_SECRET = "test-secret";
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 30,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(getEvent).mockReturnValue({
|
||||
setAuth: vi.fn(),
|
||||
addWarning: vi.fn(),
|
||||
} as never);
|
||||
vi.mocked(syncUserMeasurements).mockResolvedValue(undefined as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_SECRET === undefined) delete process.env.WITHINGS_WEBHOOK_SECRET;
|
||||
else process.env.WITHINGS_WEBHOOK_SECRET = ORIGINAL_SECRET;
|
||||
});
|
||||
|
||||
function jsonRequest(token: string, body: unknown): NextRequest {
|
||||
return new NextRequest(
|
||||
`http://localhost/api/withings/webhook/${encodeURIComponent(token)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function paramsFor(token: string) {
|
||||
return { params: Promise.resolve({ token }) };
|
||||
}
|
||||
|
||||
describe("POST /api/withings/webhook/[token] (path-segment secret)", () => {
|
||||
it("returns 200 + triggers sync when the path token matches WITHINGS_WEBHOOK_SECRET", async () => {
|
||||
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
|
||||
userId: "user-42",
|
||||
withingsUserId: "wu-1",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest("test-secret", { userid: "wu-1" }),
|
||||
paramsFor("test-secret"),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { status: string };
|
||||
expect(body.status).toBe("ok");
|
||||
|
||||
expect(prisma.withingsConnection.findFirst).toHaveBeenCalledWith({
|
||||
where: { withingsUserId: "wu-1" },
|
||||
});
|
||||
expect(syncUserMeasurements).toHaveBeenCalledTimes(1);
|
||||
expect(syncUserMeasurements).toHaveBeenCalledWith("user-42");
|
||||
});
|
||||
|
||||
it("returns 401 when the path token does NOT match the configured secret", async () => {
|
||||
const res = await POST(
|
||||
jsonRequest("WRONG", { userid: "wu-1" }),
|
||||
paramsFor("WRONG"),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(syncUserMeasurements).not.toHaveBeenCalled();
|
||||
expect(prisma.withingsConnection.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 401 when the path token is the empty string (missing)", async () => {
|
||||
// Next.js never routes /api/withings/webhook/ to this handler — that
|
||||
// path lands on the legacy route. The empty-string case here covers
|
||||
// direct invocations and adversarial proxies that strip the segment.
|
||||
const res = await POST(
|
||||
new NextRequest("http://localhost/api/withings/webhook/", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ userid: "wu-1" }),
|
||||
}),
|
||||
paramsFor(""),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(syncUserMeasurements).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns 401 when WITHINGS_WEBHOOK_SECRET is unset, even if the URL carries a token", async () => {
|
||||
delete process.env.WITHINGS_WEBHOOK_SECRET;
|
||||
const addWarning = vi.fn();
|
||||
vi.mocked(getEvent).mockReturnValue({
|
||||
setAuth: vi.fn(),
|
||||
addWarning,
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
jsonRequest("anything", { userid: "wu-1" }),
|
||||
paramsFor("anything"),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
expect(addWarning).toHaveBeenCalledWith(
|
||||
"WITHINGS_WEBHOOK_SECRET not configured",
|
||||
);
|
||||
});
|
||||
|
||||
it("constant-time compare rejects a token of the wrong length without short-circuiting on prefix match", async () => {
|
||||
// "test-secret" is the configured value; "test-secret-extra" shares
|
||||
// the full prefix. A non-timing-safe compare would still return false
|
||||
// here, so the assertion is on the response only — the security
|
||||
// property is exercised by the underlying timingSafeEqual call shape.
|
||||
const res = await POST(
|
||||
jsonRequest("test-secret-extra", { userid: "wu-1" }),
|
||||
paramsFor("test-secret-extra"),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 429 when rate-limited, before the auth check", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
const res = await POST(
|
||||
jsonRequest("WRONG", { userid: "wu-1" }),
|
||||
paramsFor("WRONG"),
|
||||
);
|
||||
expect(res.status).toBe(429);
|
||||
expect(syncUserMeasurements).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HEAD /api/withings/webhook/[token]", () => {
|
||||
it("returns 200 for a valid token, 401 otherwise", async () => {
|
||||
const okReq = new NextRequest(
|
||||
"http://localhost/api/withings/webhook/test-secret",
|
||||
{ method: "HEAD" },
|
||||
);
|
||||
const ok = await HEAD(okReq, paramsFor("test-secret"));
|
||||
expect(ok.status).toBe(200);
|
||||
|
||||
const badReq = new NextRequest(
|
||||
"http://localhost/api/withings/webhook/WRONG",
|
||||
{ method: "HEAD" },
|
||||
);
|
||||
const bad = await HEAD(badReq, paramsFor("WRONG"));
|
||||
expect(bad.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/withings/webhook/[token]", () => {
|
||||
it("returns 200 + ok body for a valid token, 401 otherwise", async () => {
|
||||
const okReq = new NextRequest(
|
||||
"http://localhost/api/withings/webhook/test-secret",
|
||||
);
|
||||
const ok = await GET(okReq, paramsFor("test-secret"));
|
||||
expect(ok.status).toBe(200);
|
||||
|
||||
const badReq = new NextRequest(
|
||||
"http://localhost/api/withings/webhook/WRONG",
|
||||
);
|
||||
const bad = await GET(badReq, paramsFor("WRONG"));
|
||||
expect(bad.status).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import {
|
||||
applyWebhookRateLimit,
|
||||
processWithingsNotification,
|
||||
timingSafeStringEqual,
|
||||
} from "@/lib/withings/webhook-handler";
|
||||
|
||||
/**
|
||||
* Withings webhook entrypoint (v1.4.25 W17a — path-segment secret).
|
||||
*
|
||||
* The shared secret travels in the URL path (`/api/withings/webhook/<secret>`)
|
||||
* rather than the query string. Withings has no facility for adding HTTP
|
||||
* headers or signing webhook bodies, so the only authenticity surface a
|
||||
* subscriber controls is the callback URL itself; moving the secret out of
|
||||
* the query string keeps it out of the `query_string` column most reverse
|
||||
* proxies log by default (Caddy, nginx, Coolify) and out of GlitchTip's
|
||||
* URL/breadcrumb capture path which already strips `?…` aggressively.
|
||||
*
|
||||
* The legacy `?secret=` form at `/api/withings/webhook` stays alive for one
|
||||
* release cycle so existing Withings subscriptions keep delivering while
|
||||
* users re-subscribe.
|
||||
*/
|
||||
interface RouteContext {
|
||||
params: Promise<{ token: string }>;
|
||||
}
|
||||
|
||||
async function verifyTokenSegment(
|
||||
token: string | undefined,
|
||||
): Promise<boolean> {
|
||||
const expected = process.env.WITHINGS_WEBHOOK_SECRET;
|
||||
if (!expected) {
|
||||
getEvent()?.addWarning("WITHINGS_WEBHOOK_SECRET not configured");
|
||||
return false;
|
||||
}
|
||||
if (!token) return false;
|
||||
return timingSafeStringEqual(expected, token);
|
||||
}
|
||||
|
||||
export const POST = apiHandler(
|
||||
async (request: NextRequest, context: RouteContext) => {
|
||||
annotate({ action: { name: "withings.webhook" } });
|
||||
|
||||
const limited = await applyWebhookRateLimit(request);
|
||||
if (limited) return limited;
|
||||
|
||||
const { token } = await context.params;
|
||||
if (!(await verifyTokenSegment(token))) {
|
||||
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
return processWithingsNotification(request);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Withings sends HEAD/GET to verify the URL when a subscription is
|
||||
* created.
|
||||
*/
|
||||
export const HEAD = apiHandler(
|
||||
async (_request: NextRequest, context: RouteContext) => {
|
||||
annotate({ action: { name: "withings.webhook.verify" } });
|
||||
const { token } = await context.params;
|
||||
if (!(await verifyTokenSegment(token))) {
|
||||
return new NextResponse(null, { status: 401 });
|
||||
}
|
||||
getEvent()?.setAuth({ auth_method: "webhook_secret" });
|
||||
return new NextResponse(null, { status: 200 });
|
||||
},
|
||||
);
|
||||
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, context: RouteContext) => {
|
||||
annotate({ action: { name: "withings.webhook.verify" } });
|
||||
const { token } = await context.params;
|
||||
if (!(await verifyTokenSegment(token))) {
|
||||
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
getEvent()?.setAuth({ auth_method: "webhook_secret" });
|
||||
return NextResponse.json({ status: "ok" }, { status: 200 });
|
||||
},
|
||||
);
|
||||
@@ -17,6 +17,21 @@ vi.mock("@/lib/withings/sync", () => ({
|
||||
syncUserMeasurements: vi.fn(),
|
||||
}));
|
||||
|
||||
// v1.4.25 W17b/c — activity / sleep paths dispatch through pg-boss.
|
||||
// Mock the boss-instance accessor to return a fake boss for the
|
||||
// appli=16 / 44 tests and `null` otherwise.
|
||||
vi.mock("@/lib/jobs/boss-instance", () => ({
|
||||
getGlobalBoss: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/withings/sync-activity", () => ({
|
||||
syncUserActivity: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/withings/sync-sleep", () => ({
|
||||
syncUserSleep: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
rateLimitHeaders: vi.fn(() => ({})),
|
||||
@@ -30,11 +45,20 @@ vi.mock("@/lib/logging/context", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST, GET, HEAD } from "../route";
|
||||
import {
|
||||
POST,
|
||||
GET,
|
||||
HEAD,
|
||||
getLegacyFormTotal,
|
||||
__resetLegacyFormTotalForTests,
|
||||
} from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { syncUserMeasurements } from "@/lib/withings/sync";
|
||||
import { syncUserActivity } from "@/lib/withings/sync-activity";
|
||||
import { syncUserSleep } from "@/lib/withings/sync-sleep";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
|
||||
const ORIGINAL_SECRET = process.env.WITHINGS_WEBHOOK_SECRET;
|
||||
|
||||
@@ -51,6 +75,11 @@ beforeEach(() => {
|
||||
addWarning: vi.fn(),
|
||||
} as never);
|
||||
vi.mocked(syncUserMeasurements).mockResolvedValue(undefined as never);
|
||||
// Default: pg-boss not available → activity / sleep paths fall back
|
||||
// to inline sync. Individual tests override with a fake boss.
|
||||
vi.mocked(getGlobalBoss).mockReturnValue(null);
|
||||
vi.mocked(syncUserActivity).mockResolvedValue(0 as never);
|
||||
vi.mocked(syncUserSleep).mockResolvedValue(0 as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -144,7 +173,8 @@ describe("POST /api/withings/webhook", () => {
|
||||
expect(syncUserMeasurements).toHaveBeenCalledWith("user-42");
|
||||
});
|
||||
|
||||
it("legacy ?secret=… query path still authorises and emits a Wide Event warning", async () => {
|
||||
it("legacy ?secret=… query path still authorises and emits a Wide Event warning with the migration URL (Fix-K sec-M2)", async () => {
|
||||
__resetLegacyFormTotalForTests();
|
||||
const addWarning = vi.fn();
|
||||
vi.mocked(getEvent).mockReturnValue({
|
||||
setAuth: vi.fn(),
|
||||
@@ -163,6 +193,30 @@ describe("POST /api/withings/webhook", () => {
|
||||
expect(addWarning).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/legacy URL query/i),
|
||||
);
|
||||
// Fix-K sec-M2: warning text now spells out the migration URL so
|
||||
// anyone watching the access log knows what to re-subscribe to.
|
||||
expect(addWarning).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/api/withings/webhook/[token]"),
|
||||
);
|
||||
// Fix-K sec-M2: in-memory counter increments on every legacy
|
||||
// ?secret=… call so the release-gate can watch usage trend to zero.
|
||||
expect(getLegacyFormTotal()).toBe(1);
|
||||
});
|
||||
|
||||
it("counter stays at zero when the secret arrives via the header (Fix-K sec-M2)", async () => {
|
||||
__resetLegacyFormTotalForTests();
|
||||
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
|
||||
userId: "user-h",
|
||||
withingsUserId: "wu-h",
|
||||
} as never);
|
||||
|
||||
await POST(
|
||||
jsonRequest(
|
||||
{ userid: "wu-h" },
|
||||
{ "x-withings-webhook-secret": "test-secret" },
|
||||
),
|
||||
);
|
||||
expect(getLegacyFormTotal()).toBe(0);
|
||||
});
|
||||
|
||||
it("accepts form-encoded payloads (Withings legacy delivery format)", async () => {
|
||||
@@ -224,6 +278,120 @@ describe("POST /api/withings/webhook", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/withings/webhook — appli dispatch (W17b/c)", () => {
|
||||
it("appli=16 enqueues onto withings-activity-sync (not the measure path)", async () => {
|
||||
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
|
||||
userId: "user-act",
|
||||
withingsUserId: "wu-act",
|
||||
} as never);
|
||||
const send = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(getGlobalBoss).mockReturnValue({ send } as never);
|
||||
|
||||
const res = await POST(
|
||||
formRequest(
|
||||
{ userid: "wu-act", appli: "16" },
|
||||
{ "x-withings-webhook-secret": "test-secret" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(send.mock.calls[0][0]).toBe("withings-activity-sync");
|
||||
expect(send.mock.calls[0][1]).toMatchObject({ userId: "user-act" });
|
||||
// Critical: appli=16 must NOT trigger the measure path.
|
||||
expect(syncUserMeasurements).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("appli=44 enqueues onto withings-sleep-sync (not the measure path)", async () => {
|
||||
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
|
||||
userId: "user-sleep",
|
||||
withingsUserId: "wu-sleep",
|
||||
} as never);
|
||||
const send = vi.fn().mockResolvedValue(undefined);
|
||||
vi.mocked(getGlobalBoss).mockReturnValue({ send } as never);
|
||||
|
||||
const res = await POST(
|
||||
formRequest(
|
||||
{ userid: "wu-sleep", appli: "44" },
|
||||
{ "x-withings-webhook-secret": "test-secret" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(send.mock.calls[0][0]).toBe("withings-sleep-sync");
|
||||
expect(send.mock.calls[0][1]).toMatchObject({ userId: "user-sleep" });
|
||||
expect(syncUserMeasurements).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("appli=16 falls back to inline activity sync when pg-boss is unavailable", async () => {
|
||||
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
|
||||
userId: "user-act-inline",
|
||||
withingsUserId: "wu-act-inline",
|
||||
} as never);
|
||||
// Default beforeEach already sets `getGlobalBoss → null`.
|
||||
|
||||
const res = await POST(
|
||||
formRequest(
|
||||
{ userid: "wu-act-inline", appli: "16" },
|
||||
{ "x-withings-webhook-secret": "test-secret" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(syncUserActivity).toHaveBeenCalledWith("user-act-inline");
|
||||
expect(syncUserMeasurements).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("appli=44 falls back to inline sleep sync when pg-boss is unavailable", async () => {
|
||||
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
|
||||
userId: "user-sleep-inline",
|
||||
withingsUserId: "wu-sleep-inline",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
formRequest(
|
||||
{ userid: "wu-sleep-inline", appli: "44" },
|
||||
{ "x-withings-webhook-secret": "test-secret" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(syncUserSleep).toHaveBeenCalledWith("user-sleep-inline");
|
||||
expect(syncUserMeasurements).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("appli=4 (BP) keeps the legacy measure path", async () => {
|
||||
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
|
||||
userId: "user-bp",
|
||||
withingsUserId: "wu-bp",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
formRequest(
|
||||
{ userid: "wu-bp", appli: "4" },
|
||||
{ "x-withings-webhook-secret": "test-secret" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(syncUserMeasurements).toHaveBeenCalledWith("user-bp");
|
||||
expect(syncUserActivity).not.toHaveBeenCalled();
|
||||
expect(syncUserSleep).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a missing appli falls back to the measure path (legacy Withings subscriptions)", async () => {
|
||||
vi.mocked(prisma.withingsConnection.findFirst).mockResolvedValueOnce({
|
||||
userId: "user-legacy",
|
||||
withingsUserId: "wu-legacy",
|
||||
} as never);
|
||||
|
||||
const res = await POST(
|
||||
formRequest(
|
||||
{ userid: "wu-legacy" },
|
||||
{ "x-withings-webhook-secret": "test-secret" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(syncUserMeasurements).toHaveBeenCalledWith("user-legacy");
|
||||
});
|
||||
});
|
||||
|
||||
describe("HEAD /api/withings/webhook (Withings URL verification)", () => {
|
||||
it("returns 200 with a valid header secret", async () => {
|
||||
const req = new NextRequest("http://localhost/api/withings/webhook", {
|
||||
|
||||
@@ -1,110 +1,117 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { syncUserMeasurements } from "@/lib/withings/sync";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { getClientIp, safeJson } from "@/lib/api-response";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import {
|
||||
applyWebhookRateLimit,
|
||||
processWithingsNotification,
|
||||
timingSafeStringEqual,
|
||||
} from "@/lib/withings/webhook-handler";
|
||||
|
||||
/**
|
||||
* Audit C-3 / phase P2: Withings legacy callback URLs include the shared
|
||||
* secret as a `?secret=…` query param, which leaks into reverse-proxy
|
||||
* access logs and any error-tracking pipeline that captures `request.url`
|
||||
* (see `reportToGlitchtip`). New deployments should configure Withings to
|
||||
* send the secret via the `X-Withings-Webhook-Secret` header instead.
|
||||
* Legacy Withings webhook entrypoint.
|
||||
*
|
||||
* The query-param fallback stays for backwards compatibility with existing
|
||||
* Withings configurations; remove once all integrators have migrated.
|
||||
* Pre-v1.4.25 callback URLs accepted the shared secret in two places:
|
||||
*
|
||||
* 1. `?secret=…` — the original form, which Withings preserves on
|
||||
* every notification and which therefore lands in
|
||||
* reverse-proxy access logs (the `query_string`
|
||||
* column) and any error-tracking pipeline that
|
||||
* captures `request.url`.
|
||||
* 2. `X-Withings-Webhook-Secret` header — Withings does NOT support
|
||||
* setting custom headers on notifications, so this
|
||||
* path only fires for manual replay tests.
|
||||
*
|
||||
* v1.4.25 W17a moved the production path to
|
||||
* `/api/withings/webhook/[token]`, where the secret travels as a path
|
||||
* segment instead of a query parameter. This route stays alive for
|
||||
* one release cycle so existing Withings subscriptions keep delivering
|
||||
* while users re-subscribe (which also happens organically when they
|
||||
* reconnect for the new `user.activity` OAuth scope shipped in W5d).
|
||||
*
|
||||
* Removal target: v1.4.27 (after all live subscriptions have rotated).
|
||||
*
|
||||
* v1.4.25 W21 Fix-K — added an in-memory `withings.webhook.legacy_form_total`
|
||||
* counter the `/api/admin/status` endpoint surfaces so the release-gate
|
||||
* can read "legacy form usage trending toward zero" before the v1.4.27
|
||||
* cut. The warning text now includes the re-subscription URL so anyone
|
||||
* watching the access log knows what to migrate to.
|
||||
*/
|
||||
function hasValidWebhookSecret(request: NextRequest): boolean {
|
||||
const MIGRATION_URL = "/api/withings/webhook/[token]" as const;
|
||||
|
||||
// In-memory counter — resets on every deploy / cold-start. That is the
|
||||
// intended granularity: we want to know "how many legacy-form requests
|
||||
// did this process serve since it came up", not a forever-running
|
||||
// cumulative metric (which would belong in Prisma).
|
||||
const counters = {
|
||||
legacy_form_total: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the legacy-form counter. Exported so `/api/admin/status` can
|
||||
* surface it without a backchannel.
|
||||
*/
|
||||
export function getLegacyFormTotal(): number {
|
||||
return counters.legacy_form_total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the legacy-form counter. Test-only utility; production code
|
||||
* does not call this. Documented so a future smoke test can wire up
|
||||
* a deterministic "counter was 0 before the call" assertion.
|
||||
*/
|
||||
export function __resetLegacyFormTotalForTests(): void {
|
||||
counters.legacy_form_total = 0;
|
||||
}
|
||||
|
||||
function hasValidWebhookSecret(
|
||||
request: NextRequest,
|
||||
): Promise<{ ok: boolean; via: "header" | "query" | "none" }> {
|
||||
const expected = process.env.WITHINGS_WEBHOOK_SECRET;
|
||||
if (!expected) {
|
||||
getEvent()?.addWarning("WITHINGS_WEBHOOK_SECRET not configured");
|
||||
return false;
|
||||
return Promise.resolve({ ok: false, via: "none" });
|
||||
}
|
||||
|
||||
const fromHeader = request.headers.get("x-withings-webhook-secret");
|
||||
const fromQuery = request.nextUrl.searchParams.get("secret");
|
||||
const received = fromHeader ?? fromQuery;
|
||||
if (!received) return false;
|
||||
if (!received) return Promise.resolve({ ok: false, via: "none" });
|
||||
|
||||
if (!fromHeader && fromQuery) {
|
||||
const via: "header" | "query" = fromHeader ? "header" : "query";
|
||||
return timingSafeStringEqual(expected, received).then((ok) => ({ ok, via }));
|
||||
}
|
||||
|
||||
async function checkAndWarn(
|
||||
request: NextRequest,
|
||||
): Promise<NextResponse | null> {
|
||||
const auth = await hasValidWebhookSecret(request);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
if (auth.via === "query") {
|
||||
counters.legacy_form_total += 1;
|
||||
getEvent()?.addWarning(
|
||||
"withings webhook secret received via legacy URL query — migrate to X-Withings-Webhook-Secret header",
|
||||
`withings webhook secret received via legacy URL query — migrate to ${MIGRATION_URL} (re-subscribe with the path-segment token form)`,
|
||||
);
|
||||
}
|
||||
|
||||
const a = Buffer.from(expected, "utf8");
|
||||
const b = Buffer.from(received, "utf8");
|
||||
if (a.length !== b.length) return false;
|
||||
return timingSafeEqual(a, b);
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Withings webhook notification endpoint.
|
||||
* Withings webhook notification endpoint (legacy form).
|
||||
* Withings sends a POST when new measurements are available.
|
||||
* The webhook sends: userid, startdate, enddate, appli
|
||||
* The webhook sends: userid, startdate, enddate, appli.
|
||||
*/
|
||||
export const POST = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "withings.webhook" } });
|
||||
annotate({ action: { name: "withings.webhook.legacy" } });
|
||||
|
||||
const ip = getClientIp(request);
|
||||
const rl = await checkRateLimit(`withings-webhook:${ip}`, 30, 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return NextResponse.json(
|
||||
{ status: "rate_limited" },
|
||||
{ status: 429, headers: rateLimitHeaders(rl) },
|
||||
);
|
||||
}
|
||||
const limited = await applyWebhookRateLimit(request);
|
||||
if (limited) return limited;
|
||||
|
||||
if (!hasValidWebhookSecret(request)) {
|
||||
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
const unauth = await checkAndWarn(request);
|
||||
if (unauth) return unauth;
|
||||
|
||||
getEvent()?.setAuth({ auth_method: "webhook_secret" });
|
||||
|
||||
// Withings sends form-encoded or JSON depending on version
|
||||
const contentType = request.headers.get("content-type") ?? "";
|
||||
let withingsUserId: string | null = null;
|
||||
|
||||
if (contentType.includes("application/x-www-form-urlencoded")) {
|
||||
const formData = await request.formData();
|
||||
withingsUserId = formData.get("userid") as string;
|
||||
} else {
|
||||
const { data: body, error: jsonError } = await safeJson<{
|
||||
userid?: string | number;
|
||||
}>(request);
|
||||
if (jsonError) return jsonError;
|
||||
withingsUserId = body.userid?.toString() ?? null;
|
||||
}
|
||||
|
||||
if (!withingsUserId) {
|
||||
return NextResponse.json({ status: "ignored" }, { status: 200 });
|
||||
}
|
||||
|
||||
annotate({ meta: { withings_user_id: withingsUserId } });
|
||||
|
||||
// Find user by Withings user ID
|
||||
const connection = await prisma.withingsConnection.findFirst({
|
||||
where: { withingsUserId },
|
||||
});
|
||||
|
||||
if (!connection) {
|
||||
getEvent()?.addWarning(
|
||||
"Webhook for unknown withings user: " + withingsUserId,
|
||||
);
|
||||
return NextResponse.json({ status: "unknown_user" }, { status: 200 });
|
||||
}
|
||||
|
||||
// Sync measurements (non-blocking response for Withings)
|
||||
syncUserMeasurements(connection.userId).catch((err) => {
|
||||
getEvent()?.addWarning(
|
||||
"Sync failed for user " + connection.userId + ": " + err,
|
||||
);
|
||||
});
|
||||
|
||||
return NextResponse.json({ status: "ok" }, { status: 200 });
|
||||
return processWithingsNotification(request);
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -112,8 +119,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
*/
|
||||
export const HEAD = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "withings.webhook.verify" } });
|
||||
|
||||
if (!hasValidWebhookSecret(request)) {
|
||||
const auth = await hasValidWebhookSecret(request);
|
||||
if (!auth.ok) {
|
||||
return new NextResponse(null, { status: 401 });
|
||||
}
|
||||
getEvent()?.setAuth({ auth_method: "webhook_secret" });
|
||||
@@ -122,8 +129,8 @@ export const HEAD = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
annotate({ action: { name: "withings.webhook.verify" } });
|
||||
|
||||
if (!hasValidWebhookSecret(request)) {
|
||||
const auth = await hasValidWebhookSecret(request);
|
||||
if (!auth.ok) {
|
||||
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
getEvent()?.setAuth({ auth_method: "webhook_secret" });
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* Unit suite for `POST /api/workouts/batch`.
|
||||
*
|
||||
* Database-free coverage of the surface that doesn't require a real
|
||||
* Postgres — the per-entry status envelope, the rate-limit gate, the
|
||||
* payload-size ceiling, the Zod-parse short-circuits, and the
|
||||
* race-reconciliation downgrade pass. Full request-lifecycle assertions
|
||||
* (idempotency replay, real-DB dedup, concurrent-write race) live in
|
||||
* the matching `tests/integration/workout-batch-create.test.ts`.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
workout: {
|
||||
findMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
workoutRoute: {
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(async (fn: unknown) => {
|
||||
// Same shape as the real Prisma client — invoke the supplied
|
||||
// callback with the mocked client itself so `tx.workout.createMany`
|
||||
// calls land on the same vi.fn instances.
|
||||
if (typeof fn === "function") {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (fn as any)((prisma as unknown as { workout: unknown }));
|
||||
}
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/jobs/pr-detection", () => ({
|
||||
enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { POST } from "../batch/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: { id: "user-1", username: "marc", role: "USER" as const },
|
||||
};
|
||||
|
||||
function makeRequest(
|
||||
body: unknown,
|
||||
opts: {
|
||||
contentLength?: number | null;
|
||||
headers?: Record<string, string>;
|
||||
} = {},
|
||||
): NextRequest {
|
||||
const baseHeaders: Record<string, string> = {
|
||||
"content-type": "application/json",
|
||||
...(opts.headers ?? {}),
|
||||
};
|
||||
const serialized = JSON.stringify(body);
|
||||
if (opts.contentLength !== null) {
|
||||
baseHeaders["content-length"] = String(
|
||||
opts.contentLength ?? serialized.length,
|
||||
);
|
||||
}
|
||||
return new NextRequest("http://localhost/api/workouts/batch", {
|
||||
method: "POST",
|
||||
headers: baseHeaders,
|
||||
body: serialized,
|
||||
});
|
||||
}
|
||||
|
||||
function validWorkout(externalId: string, overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
sportType: "running",
|
||||
startedAt: "2026-05-14T06:30:00.000Z",
|
||||
endedAt: "2026-05-14T07:15:00.000Z",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 60,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
vi.mocked(prisma.workout.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 0 });
|
||||
vi.mocked(prisma.workout.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.workoutRoute.createMany).mockResolvedValue({ count: 0 });
|
||||
});
|
||||
|
||||
describe("POST /api/workouts/batch — auth + size + rate-limit", () => {
|
||||
it("returns 401 when unauthenticated", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(null);
|
||||
const res = await POST(makeRequest({ workouts: [validWorkout("a")] }));
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it("returns 413 when Content-Length exceeds the 5 MB ceiling", async () => {
|
||||
const res = await POST(
|
||||
makeRequest(
|
||||
{ workouts: [validWorkout("uuid-1")] },
|
||||
{ contentLength: 6 * 1024 * 1024 },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(413);
|
||||
const body = (await res.json()) as {
|
||||
error: string;
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.meta?.errorCode).toBe("workout.batch.payload_too_large");
|
||||
});
|
||||
|
||||
it("returns 429 when the rate-limit gate blocks the call", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
const res = await POST(makeRequest({ workouts: [validWorkout("uuid-1")] }));
|
||||
expect(res.status).toBe(429);
|
||||
// We must NOT have queried Prisma after a rate-limit miss — the
|
||||
// gate's whole point is to short-circuit before the DB.
|
||||
expect(prisma.workout.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/workouts/batch — validation", () => {
|
||||
it("returns 400 when the workouts array exceeds the per-batch cap", async () => {
|
||||
const workouts = Array.from({ length: 101 }, (_, i) =>
|
||||
validWorkout(`uuid-${i}`),
|
||||
);
|
||||
const res = await POST(makeRequest({ workouts }));
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as {
|
||||
error: string;
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.meta?.errorCode).toBe("workout.batch.too_large");
|
||||
});
|
||||
|
||||
it("returns 400 when the Zod schema rejects the payload", async () => {
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
workouts: [
|
||||
{
|
||||
// Missing required fields — Zod must reject.
|
||||
sportType: "running",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
const body = (await res.json()) as {
|
||||
error: string;
|
||||
meta?: { errorCode?: string };
|
||||
};
|
||||
expect(body.meta?.errorCode).toBe("workout.batch.invalid");
|
||||
});
|
||||
|
||||
it("returns 400 when a route exceeds the 20 000-point cap", async () => {
|
||||
// 20 001 points — schema must reject before the handler reaches
|
||||
// its DB layer. We assert no Prisma query fires.
|
||||
const coordinates: [number, number][] = [];
|
||||
for (let i = 0; i < 20_001; i++) {
|
||||
coordinates.push([11 + i * 1e-7, 49 + i * 1e-7]);
|
||||
}
|
||||
const res = await POST(
|
||||
makeRequest(
|
||||
{
|
||||
workouts: [
|
||||
validWorkout("uuid-route-cap", {
|
||||
route: { geometry: { type: "LineString", coordinates } },
|
||||
}),
|
||||
],
|
||||
},
|
||||
// Skip the Content-Length check — 20k coordinates serialised
|
||||
// is well under 5 MB and the request as built has the real
|
||||
// header anyway. The test asserts that the SCHEMA cap fires
|
||||
// even when the byte cap doesn't.
|
||||
{ contentLength: null },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(400);
|
||||
expect(prisma.workout.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/workouts/batch — per-entry status envelope", () => {
|
||||
it("marks every fresh entry as inserted", async () => {
|
||||
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 2 });
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
workouts: [
|
||||
validWorkout("uuid-fresh-1"),
|
||||
validWorkout("uuid-fresh-2"),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
processed: number;
|
||||
inserted: number;
|
||||
duplicates: number;
|
||||
entries: Array<{ status: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data.processed).toBe(2);
|
||||
expect(body.data.inserted).toBe(2);
|
||||
expect(body.data.duplicates).toBe(0);
|
||||
expect(body.data.entries.every((e) => e.status === "inserted")).toBe(true);
|
||||
});
|
||||
|
||||
it("flags entries already in the DB as duplicate without re-inserting", async () => {
|
||||
vi.mocked(prisma.workout.findMany).mockResolvedValue([
|
||||
{ source: "APPLE_HEALTH", externalId: "uuid-existing" },
|
||||
] as never);
|
||||
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
workouts: [
|
||||
validWorkout("uuid-existing"),
|
||||
validWorkout("uuid-fresh"),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
inserted: number;
|
||||
duplicates: number;
|
||||
entries: Array<{ index: number; status: string }>;
|
||||
};
|
||||
};
|
||||
expect(body.data.inserted).toBe(1);
|
||||
expect(body.data.duplicates).toBe(1);
|
||||
expect(body.data.entries[0]?.status).toBe("duplicate");
|
||||
expect(body.data.entries[1]?.status).toBe("inserted");
|
||||
});
|
||||
|
||||
it("downgrades inserted statuses to duplicate when createMany.count < attempted (race)", async () => {
|
||||
// Pre-flight findMany returns empty (no obvious duplicates), so
|
||||
// every entry is initially marked "inserted". createMany then
|
||||
// returns count=1 — the other batch already won the race for one
|
||||
// row. The race-reconciliation block must downgrade exactly one
|
||||
// "inserted" status to "duplicate" so the envelope sums match the
|
||||
// aggregate counts (the v1.4.25 W10 fix-C invariant).
|
||||
vi.mocked(prisma.workout.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
workouts: [
|
||||
validWorkout("uuid-race-1"),
|
||||
validWorkout("uuid-race-2"),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: {
|
||||
inserted: number;
|
||||
duplicates: number;
|
||||
entries: Array<{ status: string }>;
|
||||
};
|
||||
};
|
||||
const insertedEntries = body.data.entries.filter(
|
||||
(e) => e.status === "inserted",
|
||||
).length;
|
||||
const duplicateEntries = body.data.entries.filter(
|
||||
(e) => e.status === "duplicate",
|
||||
).length;
|
||||
expect(insertedEntries).toBe(body.data.inserted);
|
||||
expect(duplicateEntries).toBe(body.data.duplicates);
|
||||
expect(body.data.inserted).toBe(1);
|
||||
expect(body.data.duplicates).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/workouts/batch — nested route attachment", () => {
|
||||
it("creates a WorkoutRoute row for a workout that ships a route", async () => {
|
||||
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
|
||||
// Post-insert lookup returns the newly-inserted workout id so the
|
||||
// route can be attached by FK.
|
||||
vi.mocked(prisma.workout.findMany).mockImplementation((async (args: {
|
||||
select?: { id?: true; source?: true; externalId?: true };
|
||||
}) => {
|
||||
if (args.select?.id) {
|
||||
return [
|
||||
{
|
||||
id: "wkt-fresh-id",
|
||||
source: "APPLE_HEALTH",
|
||||
externalId: "uuid-with-route",
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}) as never);
|
||||
|
||||
const res = await POST(
|
||||
makeRequest({
|
||||
workouts: [
|
||||
validWorkout("uuid-with-route", {
|
||||
route: {
|
||||
geometry: {
|
||||
type: "LineString",
|
||||
coordinates: [
|
||||
[11.077, 49.452],
|
||||
[11.078, 49.453],
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.workoutRoute.createMany).toHaveBeenCalledTimes(1);
|
||||
const call = vi.mocked(prisma.workoutRoute.createMany).mock.calls[0]?.[0];
|
||||
const rows = (call as { data: Array<{ workoutId: string }> }).data;
|
||||
expect(rows[0]?.workoutId).toBe("wkt-fresh-id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/workouts/batch — PR detection enqueue (v1.4.25 W16c)", () => {
|
||||
it("enqueues PR detection after a successful batch with silent=false", async () => {
|
||||
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 1 });
|
||||
const res = await POST(
|
||||
makeRequest({ workouts: [validWorkout("uuid-small")] }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(enqueuePrDetection).toHaveBeenCalledTimes(1);
|
||||
expect(enqueuePrDetection).toHaveBeenCalledWith("user-1", {
|
||||
silent: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("propagates silent=true when the batch exceeds the historical threshold", async () => {
|
||||
const big = Array.from({ length: 51 }, (_, i) =>
|
||||
validWorkout(`uuid-${i}`),
|
||||
);
|
||||
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 51 });
|
||||
const res = await POST(makeRequest({ workouts: big }));
|
||||
expect(res.status).toBe(200);
|
||||
expect(enqueuePrDetection).toHaveBeenCalledWith("user-1", {
|
||||
silent: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("still enqueues for an all-duplicate batch (cursor cleanup path)", async () => {
|
||||
// Pre-flight dedup matches all incoming entries, so insertedCount
|
||||
// stays 0 but duplicateCount becomes 1. The hook fires anyway —
|
||||
// the detector is cheap and a duplicate batch can still reveal a
|
||||
// PR if the row was written by another path between dispatches.
|
||||
vi.mocked(prisma.workout.findMany).mockResolvedValue([
|
||||
{ source: "APPLE_HEALTH", externalId: "uuid-dup" } as never,
|
||||
]);
|
||||
vi.mocked(prisma.workout.createMany).mockResolvedValue({ count: 0 });
|
||||
const res = await POST(
|
||||
makeRequest({ workouts: [validWorkout("uuid-dup")] }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(enqueuePrDetection).toHaveBeenCalledTimes(1);
|
||||
expect(enqueuePrDetection).toHaveBeenCalledWith("user-1", {
|
||||
silent: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,503 @@
|
||||
/**
|
||||
* `POST /api/workouts/batch` — typed workout ingest with nested route.
|
||||
*
|
||||
* Consumers (v1.5):
|
||||
* 1. The native iOS app draining its HealthKit observer queue —
|
||||
* maps `HKWorkout` + `HKWorkoutRoute` samples into the request
|
||||
* shape locked by `createBatchWorkoutSchema` in
|
||||
* `src/lib/validations/workout.ts`.
|
||||
* 2. The Withings activity sync (W17b, deferred) — server-to-server
|
||||
* ingest with `source: "WITHINGS"` and `externalId` formatted as
|
||||
* `"${withings.id}:${withings.model_id ?? "0"}:${withings.startdate}"`.
|
||||
* Withings ships no route geometry, so the nested `route` is
|
||||
* always absent for WITHINGS-sourced entries.
|
||||
*
|
||||
* Cross-cutting concerns mirror `/api/measurements/batch` so the iOS
|
||||
* sync engine can re-use the same retry / cursor plumbing:
|
||||
* - `requireAuth()` — cookie + Bearer (narrow-scope-token safe per
|
||||
* v1.4.25 W10 fix-C: when the route declares no scope, any
|
||||
* authenticated token passes).
|
||||
* - `withIdempotency` — `Idempotency-Key` header replays the cached
|
||||
* response on retry (24h window).
|
||||
* - `checkRateLimit("workouts:batch:${user.id}", 60, 60s)` — same
|
||||
* window as the measurements batch. 60 batches/min × 100
|
||||
* workouts/batch = 6 000 workouts/min headroom (orders of
|
||||
* magnitude past any healthy iOS sync).
|
||||
* - `Content-Length` ceiling at 5 MB before parsing — anything
|
||||
* larger returns 413 so the iOS client falls back to one workout
|
||||
* per call.
|
||||
* - Per-entry status (`inserted | duplicate | skipped`) so the iOS
|
||||
* sync cursor can checkpoint accurately.
|
||||
*
|
||||
* Idempotency contract:
|
||||
* - HTTP-level via `Idempotency-Key` (replays cached envelope).
|
||||
* - Per-entry via the `@@unique([userId, source, externalId])`
|
||||
* composite index. Re-posting the same batch surfaces duplicates
|
||||
* rather than failing the call.
|
||||
*
|
||||
* Race reconciliation (mirrors the measurements batch fix from
|
||||
* v1.4.25 W10): under contention, `createMany` with `skipDuplicates`
|
||||
* absorbs duplicate-key conflicts but cannot tell us WHICH rows it
|
||||
* absorbed. We trust the `createMany.count` return value and downgrade
|
||||
* enough per-entry "inserted" statuses to "duplicate" so the envelope
|
||||
* sums stay consistent with the aggregate counts. The iOS sync cursor
|
||||
* advances past both statuses identically.
|
||||
*
|
||||
* Prompt-injection surface: `Workout.metadata` is opaque — the Coach
|
||||
* pipeline reads typed columns only (`sportType`, `durationSec`,
|
||||
* distance, HR). A future change that pulls metadata into the Coach
|
||||
* prompt must add deliberate escaping; the ingest path stores it raw.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { enqueuePrDetection } from "@/lib/jobs/pr-detection";
|
||||
import {
|
||||
createBatchWorkoutSchema,
|
||||
MAX_WORKOUTS_PER_BATCH,
|
||||
} from "@/lib/validations/workout";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
// v1.4.25 W16c — push-suppression threshold for workout PRs. A batch
|
||||
// larger than this fires the detection job with `silent: true` so a
|
||||
// multi-year HKWorkout backfill doesn't spam the user with hundreds of
|
||||
// pushes. Matches the measurements-batch threshold so behaviour stays
|
||||
// uniform across both ingest paths.
|
||||
const PR_DETECTION_SILENT_THRESHOLD = 50;
|
||||
|
||||
// v1.4.25 W10 reconcile (security H-2 parity): cap batch ingest at 60
|
||||
// batches per user per minute. Healthy iOS sync drains its HealthKit
|
||||
// observer queue in well under one batch per minute (the observer
|
||||
// pattern coalesces); 60/min × 100 workouts/batch = 6 000 workouts/
|
||||
// min headroom — generous for legitimate use, and a hard stop for a
|
||||
// leaked wildcard token trying to saturate the write pipeline.
|
||||
const BATCH_RATE_LIMIT_MAX = 60;
|
||||
const BATCH_RATE_LIMIT_WINDOW_MS = 60 * 1000;
|
||||
|
||||
// 5 MB request-body ceiling. The route geometry is the largest tail in
|
||||
// a workout payload; with the 20 000-point cap on a single LineString
|
||||
// AND the 100-workout cap per batch, the worst-case body is bounded
|
||||
// well below this ceiling for typical lon/lat encodings. A request
|
||||
// above this ceiling almost certainly indicates a misbehaving client
|
||||
// or an attempted DoS — return 413 so the iOS client falls back to
|
||||
// one-workout-per-call.
|
||||
const MAX_BODY_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Per-entry outcome the iOS client uses to advance its sync cursor.
|
||||
* `inserted` and `duplicate` both indicate the row landed (or was
|
||||
* already present); the client can checkpoint past them identically.
|
||||
* `skipped` covers entries the server cannot durably store — currently
|
||||
* unused by this endpoint (the Zod schema rejects malformed entries
|
||||
* with a 422 before we reach the per-entry pass), but reserved on the
|
||||
* envelope so the response shape parallels the measurements batch and
|
||||
* future server-side validation can surface fine-grained skips without
|
||||
* breaking the iOS DTO.
|
||||
*/
|
||||
type EntryStatus = "inserted" | "duplicate" | "skipped";
|
||||
interface EntryResult {
|
||||
index: number;
|
||||
status: EntryStatus;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postBatch));
|
||||
|
||||
async function postBatch(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
// Content-Length pre-flight. The body cap exists to bound the
|
||||
// request-pipeline cost BEFORE Node has to read the full payload —
|
||||
// a misbehaving client shipping a 50 MB body shouldn't tie up the
|
||||
// worker for a parse it'd reject anyway. We trust the header here
|
||||
// because Next.js / undici populates it for any client that sets a
|
||||
// body length; for chunked uploads (no Content-Length) we fall
|
||||
// through and rely on the schema-level point / workout caps to keep
|
||||
// memory bounded.
|
||||
const contentLength = request.headers.get("content-length");
|
||||
if (contentLength !== null) {
|
||||
const parsedLength = Number.parseInt(contentLength, 10);
|
||||
if (Number.isFinite(parsedLength) && parsedLength > MAX_BODY_BYTES) {
|
||||
annotate({
|
||||
action: { name: "workout.batch.ingest" },
|
||||
meta: { outcome: "payload_too_large", bytes: parsedLength },
|
||||
});
|
||||
return apiError(
|
||||
`Request body exceeds the ${MAX_BODY_BYTES}-byte limit`,
|
||||
413,
|
||||
{ errorCode: "workout.batch.payload_too_large" },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// v1.4.25 W10 reconcile (security H-2 parity): per-user rate limit.
|
||||
// Without it, a leaked wildcard iOS token can sustain unbounded
|
||||
// writes (100 workouts/batch × N batches/sec) and degrade the
|
||||
// database for every other user on the host. 60 batches/min/user is
|
||||
// generous for healthy iOS sync and tight enough that a misbehaving
|
||||
// client bottoms out within a minute.
|
||||
const rl = await checkRateLimit(
|
||||
`workouts:batch:${user.id}`,
|
||||
BATCH_RATE_LIMIT_MAX,
|
||||
BATCH_RATE_LIMIT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
annotate({
|
||||
action: { name: "workout.batch.ingest" },
|
||||
meta: { outcome: "rate_limited" },
|
||||
});
|
||||
return apiError("Too many batch submissions, try again later", 429);
|
||||
}
|
||||
|
||||
const { data: rawBody, error: jsonError } = await safeJson<unknown>(request);
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
// Distinguish "too many entries" from "validation failed" so the
|
||||
// client surfaces a useful diagnostic. The cap stops a buggy client
|
||||
// from saturating the request pipeline; the iOS app paginates above
|
||||
// this threshold.
|
||||
if (
|
||||
typeof rawBody === "object" &&
|
||||
rawBody !== null &&
|
||||
"workouts" in rawBody &&
|
||||
Array.isArray((rawBody as { workouts: unknown }).workouts) &&
|
||||
(rawBody as { workouts: unknown[] }).workouts.length >
|
||||
MAX_WORKOUTS_PER_BATCH
|
||||
) {
|
||||
return apiError(
|
||||
`Batch exceeds the ${MAX_WORKOUTS_PER_BATCH}-workout limit`,
|
||||
400,
|
||||
{ errorCode: "workout.batch.too_large" },
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = createBatchWorkoutSchema.safeParse(rawBody);
|
||||
if (!parsed.success) {
|
||||
const issue = parsed.error.issues[0];
|
||||
return apiError(issue?.message ?? "Invalid batch", 400, {
|
||||
errorCode: "workout.batch.invalid",
|
||||
});
|
||||
}
|
||||
|
||||
const { workouts } = parsed.data;
|
||||
|
||||
// Pre-flight duplicate detection so we can return per-entry status.
|
||||
// The composite unique index is `(userId, source, externalId)` —
|
||||
// entries without an `externalId` cannot collide on the key (NULL-
|
||||
// distinct semantics), so they always insert as new rows.
|
||||
type Prepared = {
|
||||
index: number;
|
||||
row: Prisma.WorkoutCreateManyInput;
|
||||
route: {
|
||||
geometry: unknown;
|
||||
sampleTimestamps: unknown;
|
||||
} | null;
|
||||
/** Composite-key probe for the pre-flight findMany. NULL when
|
||||
* the entry has no externalId — those rows always pass through
|
||||
* as "inserted" because the NULL-distinct unique index can never
|
||||
* collide them. */
|
||||
dedupKey: { source: string; externalId: string } | null;
|
||||
};
|
||||
|
||||
const results: EntryResult[] = new Array(workouts.length);
|
||||
const prepared: Prepared[] = workouts.map((w, index) => {
|
||||
const durationSec = Math.max(
|
||||
0,
|
||||
Math.round((w.endedAt.getTime() - w.startedAt.getTime()) / 1000),
|
||||
);
|
||||
const route = w.route
|
||||
? {
|
||||
geometry: w.route.geometry as unknown,
|
||||
sampleTimestamps: (w.route.sampleTimestamps ?? null) as unknown,
|
||||
}
|
||||
: null;
|
||||
const row: Prisma.WorkoutCreateManyInput = {
|
||||
userId: user.id,
|
||||
sportType: w.sportType,
|
||||
startedAt: w.startedAt,
|
||||
endedAt: w.endedAt,
|
||||
durationSec,
|
||||
totalEnergyKcal: w.totalEnergyKcal ?? null,
|
||||
totalDistanceM: w.totalDistanceM ?? null,
|
||||
avgHeartRate: w.avgHeartRate ?? null,
|
||||
maxHeartRate: w.maxHeartRate ?? null,
|
||||
minHeartRate: w.minHeartRate ?? null,
|
||||
stepCount: w.stepCount ?? null,
|
||||
elevationM: w.elevationM ?? null,
|
||||
pauseDurationSec: w.pauseDurationSec ?? null,
|
||||
source: w.source,
|
||||
externalId: w.externalId ?? null,
|
||||
externalSourceVersion: w.externalSourceVersion ?? null,
|
||||
metadata: w.metadata
|
||||
? (w.metadata as Prisma.InputJsonValue)
|
||||
: Prisma.JsonNull,
|
||||
};
|
||||
return {
|
||||
index,
|
||||
row,
|
||||
route,
|
||||
dedupKey: w.externalId
|
||||
? { source: w.source, externalId: w.externalId }
|
||||
: null,
|
||||
};
|
||||
});
|
||||
|
||||
let insertedCount = 0;
|
||||
let duplicateCount = 0;
|
||||
|
||||
if (prepared.length > 0) {
|
||||
const dedupCandidates = prepared
|
||||
.map((p) => p.dedupKey)
|
||||
.filter((k): k is { source: string; externalId: string } => k !== null);
|
||||
|
||||
const existing = dedupCandidates.length
|
||||
? await prisma.workout.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
OR: dedupCandidates.map((k) => ({
|
||||
source: k.source as Prisma.WorkoutCreateManyInput["source"],
|
||||
externalId: k.externalId,
|
||||
})),
|
||||
},
|
||||
select: { source: true, externalId: true },
|
||||
})
|
||||
: [];
|
||||
|
||||
const existingSet = new Set(
|
||||
existing.map((row) => `${row.source}::${row.externalId}`),
|
||||
);
|
||||
|
||||
const toInsert: Prepared[] = [];
|
||||
for (const p of prepared) {
|
||||
const keyTuple = p.dedupKey
|
||||
? `${p.dedupKey.source}::${p.dedupKey.externalId}`
|
||||
: null;
|
||||
if (keyTuple !== null && existingSet.has(keyTuple)) {
|
||||
results[p.index] = { index: p.index, status: "duplicate" };
|
||||
duplicateCount += 1;
|
||||
} else {
|
||||
results[p.index] = { index: p.index, status: "inserted" };
|
||||
toInsert.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
if (toInsert.length > 0) {
|
||||
// Two-step write: createMany the workout rows, then findMany the
|
||||
// freshly-inserted rows to recover their ids so the route table
|
||||
// can be populated by FK. We chunk both steps for parity with the
|
||||
// measurements path and to keep individual SQL statements bounded
|
||||
// under Postgres's 65k-parameter cap.
|
||||
const CHUNK = 100;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
for (let i = 0; i < toInsert.length; i += CHUNK) {
|
||||
const slice = toInsert.slice(i, i + CHUNK);
|
||||
const result = await tx.workout.createMany({
|
||||
data: slice.map((p) => p.row),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
insertedCount += result.count;
|
||||
}
|
||||
|
||||
// Look up the workouts we just wrote (or that the racing batch
|
||||
// wrote) so we can attach routes by FK. We probe by the
|
||||
// composite unique key for entries that have one, and by the
|
||||
// `(userId, source, startedAt, sportType)` tuple as a fallback
|
||||
// for manual entries (where externalId is null). The fallback
|
||||
// is best-effort — manual entries from the iOS client always
|
||||
// carry an HK uuid, so the null-externalId case is exercised
|
||||
// only by future surface (manual workout entry).
|
||||
const routeAttachable = toInsert.filter((p) => p.route !== null);
|
||||
if (routeAttachable.length > 0) {
|
||||
const withExternal = routeAttachable.filter(
|
||||
(p) => p.dedupKey !== null,
|
||||
);
|
||||
const withoutExternal = routeAttachable.filter(
|
||||
(p) => p.dedupKey === null,
|
||||
);
|
||||
|
||||
const lookedUp = withExternal.length
|
||||
? await tx.workout.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
OR: withExternal.map((p) => ({
|
||||
source: p.dedupKey!
|
||||
.source as Prisma.WorkoutCreateManyInput["source"],
|
||||
externalId: p.dedupKey!.externalId,
|
||||
})),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
source: true,
|
||||
externalId: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
const idBySourceExt = new Map<string, string>();
|
||||
for (const row of lookedUp) {
|
||||
if (row.externalId !== null) {
|
||||
idBySourceExt.set(`${row.source}::${row.externalId}`, row.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Manual entries — `externalId` is null and the unique index
|
||||
// is NULL-distinct, so two manual entries on the same instant
|
||||
// would both insert. We look these up by the deterministic
|
||||
// (userId, source, startedAt, sportType) tuple for each row.
|
||||
const manualMatches = await Promise.all(
|
||||
withoutExternal.map((p) =>
|
||||
tx.workout.findFirst({
|
||||
where: {
|
||||
userId: user.id,
|
||||
source: p.row.source,
|
||||
startedAt: p.row.startedAt as Date,
|
||||
sportType: p.row.sportType,
|
||||
route: null,
|
||||
},
|
||||
select: { id: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const routesToInsert: Prisma.WorkoutRouteCreateManyInput[] = [];
|
||||
for (let i = 0; i < withExternal.length; i++) {
|
||||
const p = withExternal[i];
|
||||
const id = idBySourceExt.get(
|
||||
`${p.dedupKey!.source}::${p.dedupKey!.externalId}`,
|
||||
);
|
||||
if (id && p.route) {
|
||||
routesToInsert.push({
|
||||
workoutId: id,
|
||||
geometry: p.route.geometry as Prisma.InputJsonValue,
|
||||
sampleTimestamps:
|
||||
p.route.sampleTimestamps === null
|
||||
? Prisma.JsonNull
|
||||
: (p.route.sampleTimestamps as Prisma.InputJsonValue),
|
||||
});
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < withoutExternal.length; i++) {
|
||||
const p = withoutExternal[i];
|
||||
const match = manualMatches[i];
|
||||
if (match?.id && p.route) {
|
||||
routesToInsert.push({
|
||||
workoutId: match.id,
|
||||
geometry: p.route.geometry as Prisma.InputJsonValue,
|
||||
sampleTimestamps:
|
||||
p.route.sampleTimestamps === null
|
||||
? Prisma.JsonNull
|
||||
: (p.route.sampleTimestamps as Prisma.InputJsonValue),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (routesToInsert.length > 0) {
|
||||
for (let i = 0; i < routesToInsert.length; i += CHUNK) {
|
||||
await tx.workoutRoute.createMany({
|
||||
data: routesToInsert.slice(i, i + CHUNK),
|
||||
// 1:1 FK — `workoutId` has a UNIQUE index, so the
|
||||
// composite unique guards routes too. `skipDuplicates`
|
||||
// means a re-submitted batch that wins the race on the
|
||||
// workout row but loses on the route is a no-op rather
|
||||
// than a hard error.
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// v1.4.25 W10 reconcile (senior-dev H-1 parity): when two batches
|
||||
// race on overlapping externalIds, only one row lands per key.
|
||||
// `createMany.count` is the source of truth for how many rows
|
||||
// THIS request wrote; downgrade enough per-entry "inserted"
|
||||
// statuses to "duplicate" so the per-entry envelope sums match
|
||||
// the aggregate counts. Order doesn't matter — the iOS sync
|
||||
// cursor advances past both statuses identically.
|
||||
const racedDuplicates = toInsert.length - insertedCount;
|
||||
if (racedDuplicates > 0) {
|
||||
let downgraded = 0;
|
||||
for (const p of prepared) {
|
||||
if (downgraded >= racedDuplicates) break;
|
||||
if (results[p.index]?.status === "inserted") {
|
||||
results[p.index] = { index: p.index, status: "duplicate" };
|
||||
duplicateCount += 1;
|
||||
downgraded += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const skipped = results
|
||||
.filter((r) => r.status === "skipped")
|
||||
.map((r) => ({ index: r.index, reason: r.reason ?? "unknown" }));
|
||||
|
||||
await auditLog("workout.batch.ingest", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: {
|
||||
processed: workouts.length,
|
||||
inserted: insertedCount,
|
||||
duplicates: duplicateCount,
|
||||
skipped: skipped.length,
|
||||
},
|
||||
});
|
||||
|
||||
// v1.4.25 W16c — kick off PR detection for this user. Suppress push
|
||||
// notifications for historical-backfill batches so a multi-year
|
||||
// HKWorkout import doesn't fire hundreds of pushes during initial
|
||||
// sync. The detector also scans Measurement history on the same
|
||||
// pass, so a workout-only batch still surfaces measurement-side PRs
|
||||
// the user may have racked up since the last detection.
|
||||
if (insertedCount > 0 || duplicateCount > 0) {
|
||||
const silent = workouts.length > PR_DETECTION_SILENT_THRESHOLD;
|
||||
try {
|
||||
await enqueuePrDetection(user.id, { silent });
|
||||
await auditLog("personal_records.detection_enqueued", {
|
||||
userId: user.id,
|
||||
details: {
|
||||
source: "workout.batch",
|
||||
batchSize: workouts.length,
|
||||
silent,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
annotate({
|
||||
action: { name: "personal_records.detection_enqueue_failed" },
|
||||
meta: { error: err instanceof Error ? err.message : String(err) },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "workout.batch.ingest" },
|
||||
meta: {
|
||||
processed: workouts.length,
|
||||
inserted: insertedCount,
|
||||
duplicates: duplicateCount,
|
||||
skipped: skipped.length,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
processed: workouts.length,
|
||||
inserted: insertedCount,
|
||||
duplicates: duplicateCount,
|
||||
skipped,
|
||||
entries: results,
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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: {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in new issue
Block a user