From 1f5ad1729d458066eec21959c3c94458d25efe52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 3 May 2026 21:42:39 +0200 Subject: [PATCH] feat(api): aggregator endpoints for headless API clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds seven read/write endpoints that aggregate or adapt existing data into shapes more practical for non-browser consumers. None of the pre-existing endpoints are touched. • `GET /api/dashboard/summary` — greeting + streak + today compliance + per-metric latest value with 7-day sparkline. Single round-trip for a "home" view instead of fanning out across 6 endpoints. • `GET /api/measurements/series?kind=&days=` — pre-aggregated series + stats (mean, min, max, stdDev, count) reusing `src/lib/analytics/trends.ts`. • `GET/PATCH /api/integrations/healthkit` — opt-in per-metric sync config (entries[] of `{ id, kind, direction, enabled }`). Stored in the new `users.healthkit_config_json` column. • `POST /api/devices` — APNs/FCM token registration only; no push sender wired up here. Upsert on `token`. • `GET /api/medications/intake?scope=today|compliance` — top-level aggregator over all of a user's medications, plus a sibling `POST /api/medications/intake` accepting `{ intakeId, status, takenAt }` for clients that don't want to maintain medication-id state. • `GET /api/insights/cards` — adapter that maps the rule-based alerts from `/api/insights/comprehensive` into card-shaped `Insight[]`. • `GET /api/insights/correlations` — placeholder array today; flagged in audit log as `insights.correlations.empty` until a real engine lands. All endpoints honour `requireAuth()` (cookie or bearer) and the existing audit/Wide-Event pipeline. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../dashboard/summary/__tests__/route.test.ts | 108 ++++++ src/app/api/dashboard/summary/route.ts | 346 ++++++++++++++++++ src/app/api/devices/__tests__/route.test.ts | 103 ++++++ src/app/api/devices/route.ts | 86 +++++ .../insights/cards/__tests__/route.test.ts | 102 ++++++ src/app/api/insights/cards/route.ts | 189 ++++++++++ .../correlations/__tests__/route.test.ts | 66 ++++ src/app/api/insights/correlations/route.ts | 26 ++ .../healthkit/__tests__/route.test.ts | 112 ++++++ src/app/api/integrations/healthkit/route.ts | 185 ++++++++++ .../series/__tests__/route.test.ts | 101 +++++ src/app/api/measurements/series/route.ts | 154 ++++++++ .../intake/__tests__/route.test.ts | 160 ++++++++ src/app/api/medications/intake/route.ts | 201 ++++++++++ 14 files changed, 1939 insertions(+) create mode 100644 src/app/api/dashboard/summary/__tests__/route.test.ts create mode 100644 src/app/api/dashboard/summary/route.ts create mode 100644 src/app/api/devices/__tests__/route.test.ts create mode 100644 src/app/api/devices/route.ts create mode 100644 src/app/api/insights/cards/__tests__/route.test.ts create mode 100644 src/app/api/insights/cards/route.ts create mode 100644 src/app/api/insights/correlations/__tests__/route.test.ts create mode 100644 src/app/api/insights/correlations/route.ts create mode 100644 src/app/api/integrations/healthkit/__tests__/route.test.ts create mode 100644 src/app/api/integrations/healthkit/route.ts create mode 100644 src/app/api/measurements/series/__tests__/route.test.ts create mode 100644 src/app/api/measurements/series/route.ts create mode 100644 src/app/api/medications/intake/__tests__/route.test.ts create mode 100644 src/app/api/medications/intake/route.ts diff --git a/src/app/api/dashboard/summary/__tests__/route.test.ts b/src/app/api/dashboard/summary/__tests__/route.test.ts new file mode 100644 index 000000000..2a129e314 --- /dev/null +++ b/src/app/api/dashboard/summary/__tests__/route.test.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + measurement: { findMany: 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, + displayName: null, + }, +}; + +// `apiHandler` always reads `request.url` — even when the inner handler +// ignores it — so we hand it a NextRequest and bypass the inner-handler +// arity check via a cast. +const callGet = GET as unknown as (req: NextRequest) => Promise; +function makeReq(): NextRequest { + return new NextRequest("http://localhost/api/dashboard/summary"); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never); +}); + +describe("GET /api/dashboard/summary", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await callGet(makeReq()); + expect(res.status).toBe(401); + }); + + it("returns the aggregated payload with empty data", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + greeting: { salutation: string; date: string }; + streak: { currentDays: number; longest: number; label: string }; + compliance: { scheduledToday: number; takenToday: number }; + metrics: Array<{ id: string; kind: string; sparkline: number[] }>; + }; + }; + expect(body.data.greeting.salutation).toBe("Hi, marc"); + expect(body.data.streak.currentDays).toBe(0); + expect(body.data.compliance.scheduledToday).toBe(0); + expect(body.data.metrics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ id: "weight" }), + expect.objectContaining({ id: "bp" }), + expect.objectContaining({ id: "pulse" }), + ]), + ); + }); + + it("computes intake compliance for today", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockImplementation((( + args: unknown, + ) => { + const a = args as { where: { OR?: unknown } }; + if (!a.where.OR) { + return Promise.resolve([ + { id: "e1", takenAt: new Date(), skipped: false }, + { id: "e2", takenAt: null, skipped: false }, + ]) as never; + } + return Promise.resolve([]) as never; + }) as never); + const res = await callGet(makeReq()); + const body = (await res.json()) as { + data: { compliance: { scheduledToday: number; takenToday: number } }; + }; + expect(body.data.compliance.scheduledToday).toBe(2); + expect(body.data.compliance.takenToday).toBe(1); + }); +}); diff --git a/src/app/api/dashboard/summary/route.ts b/src/app/api/dashboard/summary/route.ts new file mode 100644 index 000000000..07e99858d --- /dev/null +++ b/src/app/api/dashboard/summary/route.ts @@ -0,0 +1,346 @@ +/** + * GET /api/dashboard/summary + * + * Aggregator endpoint for the iOS DashboardSummary view. Combines + * greeting, intake-day streaks, today's medication compliance, the + * highlighted insight, and per-metric latest+sparkline+trend. + * + * The shape is fixed for the iOS client and intentionally normalised — + * `kind` is iOS-friendly (camelCase), unlike the canonical Prisma enum + * (BLOOD_PRESSURE_SYS etc.). + */ +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 type { MeasurementType } from "@/generated/prisma/client"; + +const SPARK_DAYS = 7; +const STREAK_WINDOW_DAYS = 365; + +type MetricKind = + | "weight" + | "bloodPressure" + | "pulse" + | "bodyFat" + | "glucose" + | "sleep" + | "steps"; + +interface MetricCard { + id: string; + kind: MetricKind; + title: string; + latestValue: number | null; + secondaryValue: number | null; + unit: string; + trend: "up" | "down" | "flat" | "unknown"; + sparkline: number[]; + updatedAt: string | null; +} + +const METRIC_TITLES: Record = { + weight: "Gewicht", + bloodPressure: "Blutdruck", + pulse: "Puls", + bodyFat: "Körperfett", + glucose: "Blutzucker", + sleep: "Schlaf", + steps: "Schritte", +}; + +const METRIC_UNITS: Record = { + weight: "kg", + bloodPressure: "mmHg", + pulse: "bpm", + bodyFat: "%", + glucose: "mg/dL", + sleep: "h", + steps: "Schritte", +}; + +function trendOf(values: number[]): MetricCard["trend"] { + if (values.length < 2) return "unknown"; + const first = values[0]; + const last = values[values.length - 1]; + const delta = last - first; + const epsilon = Math.max(1, Math.abs(first) * 0.01); + if (Math.abs(delta) < epsilon) return "flat"; + 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", + 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"; + 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): StreakInfo { + if (activityDays.size === 0) return { currentDays: 0, longest: 0 }; + + const sorted = [...activityDays].sort(); + let longest = 1; + let run = 1; + for (let i = 1; i < sorted.length; i++) { + const prev = new Date(`${sorted[i - 1]}T00:00:00.000Z`).getTime(); + const cur = new Date(`${sorted[i]}T00:00:00.000Z`).getTime(); + if (cur - prev === 86_400_000) { + run += 1; + longest = Math.max(longest, run); + } else { + run = 1; + } + } + + // Current streak: walk back from today (Berlin). + const todayKey = berlinDayKey(new Date()); + 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))) { + cursor = new Date(cursor.getTime() - 86_400_000); + } + while (activityDays.has(berlinDayKey(cursor))) { + currentDays += 1; + cursor = new Date(cursor.getTime() - 86_400_000); + } + + return { currentDays, longest }; +} + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "dashboard.summary" } }); + + 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 todayEnd = new Date(todayStart.getTime() + 86_400_000); + + const measurementTypes: MeasurementType[] = [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + "BODY_FAT", + "BLOOD_GLUCOSE", + "SLEEP_DURATION", + "ACTIVITY_STEPS", + ]; + + const [recentMeasurements, todaysIntakes, streakActivity] = + await Promise.all([ + prisma.measurement.findMany({ + where: { + userId: user.id, + type: { in: measurementTypes }, + measuredAt: { gte: sevenDaysAgo }, + }, + orderBy: { measuredAt: "asc" }, + select: { type: true, value: true, measuredAt: true }, + }), + prisma.medicationIntakeEvent.findMany({ + where: { + userId: user.id, + scheduledFor: { gte: todayStart, lt: todayEnd }, + }, + select: { id: true, takenAt: true, skipped: true }, + }), + prisma.medicationIntakeEvent.findMany({ + where: { + userId: user.id, + scheduledFor: { gte: streakWindowStart }, + OR: [{ takenAt: { not: null } }, { skipped: true }], + }, + select: { takenAt: true, scheduledFor: true }, + }), + ]); + + const activityDays = new Set(); + for (const m of recentMeasurements) activityDays.add(berlinDayKey(m.measuredAt)); + // 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) { + const wider = await prisma.measurement.findMany({ + where: { + userId: user.id, + measuredAt: { gte: streakWindowStart }, + }, + select: { measuredAt: true }, + }); + for (const m of wider) activityDays.add(berlinDayKey(m.measuredAt)); + } + for (const e of streakActivity) { + activityDays.add(berlinDayKey(e.takenAt ?? e.scheduledFor)); + } + + const streak = computeStreak(activityDays); + + // Per-type latest + sparkline. + const byType = new Map(); + for (const m of recentMeasurements) { + const list = byType.get(m.type) ?? []; + list.push({ value: m.value, at: m.measuredAt }); + byType.set(m.type, list); + } + + function latestOf(type: MeasurementType): { value: number; at: Date } | null { + const list = byType.get(type); + if (!list || list.length === 0) return null; + return list[list.length - 1]; + } + + function sparkOf(type: MeasurementType): number[] { + const list = byType.get(type); + if (!list) return []; + return list.map((p) => p.value); + } + + const metrics: MetricCard[] = []; + + // Weight + { + const latest = latestOf("WEIGHT"); + const spark = sparkOf("WEIGHT"); + metrics.push({ + id: "weight", + kind: "weight", + title: METRIC_TITLES.weight, + latestValue: latest?.value ?? null, + secondaryValue: null, + unit: METRIC_UNITS.weight, + trend: trendOf(spark), + sparkline: spark, + updatedAt: latest?.at.toISOString() ?? null, + }); + } + + // Blood pressure (paired sys/dia) + { + const sysList = byType.get("BLOOD_PRESSURE_SYS") ?? []; + const diaList = byType.get("BLOOD_PRESSURE_DIA") ?? []; + const latestSys = sysList[sysList.length - 1] ?? null; + const latestDia = diaList[diaList.length - 1] ?? null; + metrics.push({ + id: "bp", + kind: "bloodPressure", + title: METRIC_TITLES.bloodPressure, + latestValue: latestSys?.value ?? null, + secondaryValue: latestDia?.value ?? null, + unit: METRIC_UNITS.bloodPressure, + trend: trendOf(sysList.map((p) => p.value)), + sparkline: sysList.map((p) => p.value), + updatedAt: + latestSys?.at.toISOString() ?? latestDia?.at.toISOString() ?? null, + }); + } + + // Pulse + { + const latest = latestOf("PULSE"); + const spark = sparkOf("PULSE"); + metrics.push({ + id: "pulse", + kind: "pulse", + title: METRIC_TITLES.pulse, + latestValue: latest?.value ?? null, + secondaryValue: null, + unit: METRIC_UNITS.pulse, + trend: trendOf(spark), + sparkline: spark, + updatedAt: latest?.at.toISOString() ?? null, + }); + } + + // Body fat + { + const latest = latestOf("BODY_FAT"); + const spark = sparkOf("BODY_FAT"); + if (latest) { + metrics.push({ + id: "bodyFat", + kind: "bodyFat", + title: METRIC_TITLES.bodyFat, + latestValue: latest.value, + secondaryValue: null, + unit: METRIC_UNITS.bodyFat, + trend: trendOf(spark), + sparkline: spark, + updatedAt: latest.at.toISOString(), + }); + } + } + + // Glucose / sleep / steps — only if data is present. + for (const [type, kind] of [ + ["BLOOD_GLUCOSE", "glucose"], + ["SLEEP_DURATION", "sleep"], + ["ACTIVITY_STEPS", "steps"], + ] as const) { + const latest = latestOf(type); + if (!latest) continue; + const spark = sparkOf(type); + metrics.push({ + id: kind, + kind, + title: METRIC_TITLES[kind], + latestValue: latest.value, + secondaryValue: null, + unit: METRIC_UNITS[kind], + trend: trendOf(spark), + sparkline: spark, + updatedAt: latest.at.toISOString(), + }); + } + + const scheduledToday = todaysIntakes.length; + const takenToday = todaysIntakes.filter( + (e) => e.takenAt !== null && !e.skipped, + ).length; + + const greetingName = user.displayName ?? user.username; + + return apiSuccess({ + greeting: { + salutation: `Hi, ${greetingName}`, + date: now.toISOString(), + }, + streak: { + currentDays: streak.currentDays, + longest: streak.longest, + label: "Tage in Folge", + }, + compliance: { + scheduledToday, + takenToday, + }, + highlightInsight: null, + metrics, + lastUpdated: now.toISOString(), + }); +}); diff --git a/src/app/api/devices/__tests__/route.test.ts b/src/app/api/devices/__tests__/route.test.ts new file mode 100644 index 000000000..e260f6d10 --- /dev/null +++ b/src/app/api/devices/__tests__/route.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + device: { + findUnique: vi.fn(), + create: 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 { POST } 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(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/devices", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.device.findUnique).mockResolvedValue(null); + vi.mocked(prisma.device.create).mockResolvedValue({ id: "dev-1" } as never); + vi.mocked(prisma.device.update).mockResolvedValue({ id: "dev-1" } as never); +}); + +describe("POST /api/devices", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await POST( + req({ + token: "abcd1234efgh5678", + bundleId: "io.healthlog.app", + }), + ); + expect(res.status).toBe(401); + }); + + it("returns 422 for missing token", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await POST(req({ bundleId: "io.healthlog.app" })); + expect(res.status).toBe(422); + }); + + it("creates a new device row when token is unknown", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await POST( + req({ + token: "abcd1234efgh5678", + bundleId: "io.healthlog.app", + locale: "de-DE", + appVersion: "1.0.0", + model: "iPhone15,2", + }), + ); + expect(res.status).toBe(201); + expect(prisma.device.create).toHaveBeenCalledTimes(1); + expect(prisma.device.update).not.toHaveBeenCalled(); + }); + + it("upserts when token already exists (transfers ownership)", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.device.findUnique).mockResolvedValue({ + id: "dev-1", + userId: "other-user", + token: "abcd1234efgh5678", + } as never); + const res = await POST( + req({ token: "abcd1234efgh5678", bundleId: "io.healthlog.app" }), + ); + expect(res.status).toBe(201); + expect(prisma.device.update).toHaveBeenCalledTimes(1); + expect(prisma.device.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/devices/route.ts b/src/app/api/devices/route.ts new file mode 100644 index 000000000..b732d62d1 --- /dev/null +++ b/src/app/api/devices/route.ts @@ -0,0 +1,86 @@ +/** + * POST /api/devices + * + * APNs device registration. Native clients call this on login and on + * APNs token rotation. We upsert by `token`; sending the same token on a + * different account simply transfers ownership. + * + * No APNs send-side logic lives here — that ships in Phase 8. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess, safeJson } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; + +const deviceSchema = z.object({ + token: z + .string() + .min(8) + .max(512) + .regex(/^[A-Za-z0-9+/=._:-]+$/, "Invalid token format"), + bundleId: z.string().min(1).max(128), + locale: z.string().min(2).max(16).optional(), + appVersion: z.string().min(1).max(32).optional(), + model: z.string().min(1).max(64).optional(), +}); + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const { data: body, error } = await safeJson(request); + if (error) return error; + + const parsed = deviceSchema.safeParse(body); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } + + const { token, bundleId, locale, appVersion, model } = parsed.data; + + const existing = await prisma.device.findUnique({ where: { token } }); + let id: string; + if (existing) { + const updated = await prisma.device.update({ + where: { token }, + data: { + userId: user.id, + platform: "ios", + bundleId, + locale: locale ?? null, + appVersion: appVersion ?? null, + model: model ?? null, + lastSeen: new Date(), + }, + select: { id: true }, + }); + id = updated.id; + } else { + const created = await prisma.device.create({ + data: { + userId: user.id, + platform: "ios", + token, + bundleId, + locale: locale ?? null, + appVersion: appVersion ?? null, + model: model ?? null, + }, + select: { id: true }, + }); + id = created.id; + } + + await auditLog("devices.register", { + userId: user.id, + details: { deviceId: id, bundleId, model: model ?? null }, + }); + + annotate({ + action: { name: "devices.register", entity_type: "device", entity_id: id }, + }); + + return apiSuccess({ id }, 201); +}); diff --git a/src/app/api/insights/cards/__tests__/route.test.ts b/src/app/api/insights/cards/__tests__/route.test.ts new file mode 100644 index 000000000..72d0e5a1f --- /dev/null +++ b/src/app/api/insights/cards/__tests__/route.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + measurement: { findMany: vi.fn() }, + medication: { findMany: vi.fn() }, + medicationIntakeEvent: { 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 }, +}; + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + heightCm: null, + dateOfBirth: null, + aiProvider: null, + } as never); +}); + +const callGet = GET as unknown as (req: NextRequest) => Promise; +function makeReq(): NextRequest { + return new NextRequest("http://localhost/api/insights/cards"); +} + +describe("GET /api/insights/cards", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await callGet(makeReq()); + expect(res.status).toBe(401); + }); + + it("returns an empty array when no data is available", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: unknown[] }; + expect(Array.isArray(body.data)).toBe(true); + expect(body.data).toHaveLength(0); + }); + + it("emits cards when measurements trigger alerts", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + // Provide enough sys + dia measurements to trigger danger-level alert. + const now = new Date(); + const measurements: Array<{ + type: string; + value: number; + measuredAt: Date; + }> = []; + for (let i = 0; i < 10; i++) { + const at = new Date(now.getTime() - i * 86_400_000); + measurements.push({ type: "BLOOD_PRESSURE_SYS", value: 180, measuredAt: at }); + measurements.push({ type: "BLOOD_PRESSURE_DIA", value: 110, measuredAt: at }); + } + vi.mocked(prisma.measurement.findMany).mockResolvedValue(measurements as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + heightCm: null, + dateOfBirth: null, + aiProvider: "ANTHROPIC", + } as never); + + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: Array<{ severity: string; provider: string; title: string }>; + }; + expect(body.data.length).toBeGreaterThan(0); + expect(body.data[0].provider).toBe("anthropic"); + expect(body.data.some((c) => c.severity === "alert")).toBe(true); + }); +}); diff --git a/src/app/api/insights/cards/route.ts b/src/app/api/insights/cards/route.ts new file mode 100644 index 000000000..645b68873 --- /dev/null +++ b/src/app/api/insights/cards/route.ts @@ -0,0 +1,189 @@ +/** + * GET /api/insights/cards — iOS adapter over `/api/insights/comprehensive`. + * + * Reuses the same data sources (measurements, mood, intake events) and the + * shared `generateAlerts()` rule engine, then re-shapes each `HealthAlert` + * to the iOS Insight model (id, title, summary, severity, recommendations, + * provider). + */ +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 { summarize, type DataPoint } from "@/lib/analytics/trends"; +import { getBpTargets } from "@/lib/analytics/bp-targets"; +import { + generateAlerts, + type HealthAlert, +} from "@/lib/analytics/classifications"; +import { pairByTimestamp } from "@/lib/analytics/correlations"; +import type { MeasurementType } from "@/generated/prisma/client"; + +type Severity = "alert" | "caution" | "info" | "good"; + +function toSeverity(level: HealthAlert["level"]): Severity { + switch (level) { + case "danger": + return "alert"; + case "warning": + return "caution"; + case "success": + return "good"; + default: + return "info"; + } +} + +interface InsightRecommendation { + id: string; + label: string; + actionURL: string | null; +} + +interface InsightCard { + id: string; + title: string; + summary: string; + body: string | null; + severity: Severity; + recommendations: InsightRecommendation[]; + generatedAt: string; + provider: string; +} + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "insights.cards" } }); + + const ninetyDaysAgo = new Date(Date.now() - 90 * 86_400_000); + + const [dbUser, allMeasurements, medications] = await Promise.all([ + prisma.user.findUnique({ + where: { id: user.id }, + select: { heightCm: true, dateOfBirth: true, aiProvider: true }, + }), + prisma.measurement.findMany({ + where: { + userId: user.id, + type: { + in: [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + ], + }, + measuredAt: { gte: ninetyDaysAgo }, + }, + orderBy: { measuredAt: "asc" }, + select: { type: true, value: true, measuredAt: true }, + }), + prisma.medication.findMany({ + where: { userId: user.id, active: true }, + include: { schedules: true }, + }), + ]); + + const byType = (t: MeasurementType): DataPoint[] => + allMeasurements + .filter((m) => m.type === t) + .map((m) => ({ date: m.measuredAt, value: m.value })); + + const summaries: Partial>> = {}; + for (const t of [ + "WEIGHT", + "BLOOD_PRESSURE_SYS", + "BLOOD_PRESSURE_DIA", + "PULSE", + ] as MeasurementType[]) { + const data = byType(t); + if (data.length > 0) summaries[t] = summarize(data); + } + + let bmi: number | null = null; + if (dbUser?.heightCm && summaries.WEIGHT?.latest) { + const heightM = dbUser.heightCm / 100; + bmi = + Math.round((summaries.WEIGHT.latest / (heightM * heightM)) * 10) / 10; + } + + let bpPctInTarget: number | null = null; + const bpTargets = getBpTargets(dbUser?.dateOfBirth ?? null); + if (bpTargets) { + const sysData = byType("BLOOD_PRESSURE_SYS"); + const diaData = byType("BLOOD_PRESSURE_DIA"); + const pairs = pairByTimestamp(sysData, diaData, 5 * 60_000); + if (pairs.length > 0) { + const inTarget = pairs.filter( + (p) => + p.a >= bpTargets.sysLow && + p.a <= bpTargets.sysHigh && + p.b >= bpTargets.diaLow && + p.b <= bpTargets.diaHigh, + ).length; + bpPctInTarget = Math.round((inTarget / pairs.length) * 100); + } + } + + // Lightweight medication compliance for alert input. + const medicationCompliance: Array<{ + name: string; + compliance7: number; + compliance30: number; + }> = []; + for (const med of medications) { + if (med.schedules.length === 0) continue; + const events = await prisma.medicationIntakeEvent.findMany({ + where: { + userId: user.id, + medicationId: med.id, + scheduledFor: { gte: new Date(Date.now() - 30 * 86_400_000) }, + }, + select: { takenAt: true, skipped: true, scheduledFor: true }, + }); + const takenLast7 = events.filter( + (e) => + e.takenAt !== null && + !e.skipped && + e.scheduledFor.getTime() >= Date.now() - 7 * 86_400_000, + ).length; + const taken30 = events.filter((e) => e.takenAt !== null && !e.skipped).length; + const expected7 = med.schedules.length * 7; + const expected30 = med.schedules.length * 30; + medicationCompliance.push({ + name: med.name, + compliance7: expected7 > 0 ? Math.round((takenLast7 / expected7) * 100) : 0, + compliance30: + expected30 > 0 ? Math.round((taken30 / expected30) * 100) : 0, + }); + } + + const alerts = generateAlerts({ + bmi, + bpAvgSys: summaries.BLOOD_PRESSURE_SYS?.avg30 ?? null, + bpAvgDia: summaries.BLOOD_PRESSURE_DIA?.avg30 ?? null, + bpPctInTarget, + weightSlope30: summaries.WEIGHT?.slope30?.slope ?? null, + pulseAvg30: summaries.PULSE?.avg30 ?? null, + pulseAnomalyCount: summaries.PULSE?.anomalyCount, + medications: medicationCompliance, + }); + + const provider = dbUser?.aiProvider?.toLowerCase() ?? "claude"; + const generatedAt = new Date().toISOString(); + + const cards: InsightCard[] = alerts.map((alert, idx) => ({ + id: `alert-${idx + 1}`, + title: alert.title, + summary: alert.message, + body: null, + severity: toSeverity(alert.level), + recommendations: [], + generatedAt, + provider, + })); + + return apiSuccess(cards); +}); diff --git a/src/app/api/insights/correlations/__tests__/route.test.ts b/src/app/api/insights/correlations/__tests__/route.test.ts new file mode 100644 index 000000000..8599f907e --- /dev/null +++ b/src/app/api/insights/correlations/__tests__/route.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { auditLog: { create: 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 { 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 }, +}; + +beforeEach(() => { + vi.resetAllMocks(); +}); + +const callGet = GET as unknown as (req: NextRequest) => Promise; +function makeReq(): NextRequest { + return new NextRequest("http://localhost/api/insights/correlations"); +} + +describe("GET /api/insights/correlations", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await callGet(makeReq()); + expect(res.status).toBe(401); + }); + + it("returns an empty array as the placeholder", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { data: unknown[] }; + expect(body.data).toEqual([]); + }); + + it("writes the empty-state audit log", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + await callGet(makeReq()); + expect(auditLog).toHaveBeenCalledWith( + "insights.correlations.empty", + expect.objectContaining({ userId: "user-1" }), + ); + }); +}); diff --git a/src/app/api/insights/correlations/route.ts b/src/app/api/insights/correlations/route.ts new file mode 100644 index 000000000..a21b0f8a5 --- /dev/null +++ b/src/app/api/insights/correlations/route.ts @@ -0,0 +1,26 @@ +/** + * GET /api/insights/correlations — placeholder iOS endpoint. + * + * The full correlations engine is part of `/api/insights/comprehensive` + * and is intentionally not re-exposed yet — iOS only needs a stable + * shape to render the empty state. We return `[]` and emit an audit log + * so the gap is visible until Phase 7 fills it in. + */ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; + +export const dynamic = "force-dynamic"; + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "insights.correlations" } }); + + await auditLog("insights.correlations.empty", { + userId: user.id, + details: { reason: "phase7_pending" }, + }); + + return apiSuccess([]); +}); diff --git a/src/app/api/integrations/healthkit/__tests__/route.test.ts b/src/app/api/integrations/healthkit/__tests__/route.test.ts new file mode 100644 index 000000000..bffbb1161 --- /dev/null +++ b/src/app/api/integrations/healthkit/__tests__/route.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +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, PATCH } 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(); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + healthKitConfigJson: null, + healthKitLastSyncedAt: null, + } as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); +}); + +const callGet = GET as unknown as (req: NextRequest) => Promise; +function makeGetReq(): NextRequest { + return new NextRequest("http://localhost/api/integrations/healthkit"); +} + +describe("GET /api/integrations/healthkit", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await callGet(makeGetReq()); + expect(res.status).toBe(401); + }); + + it("returns the default entries when nothing is stored", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await callGet(makeGetReq()); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { entries: Array<{ id: string; direction: string }> }; + }; + expect(body.data.entries.length).toBeGreaterThan(0); + const bodyMass = body.data.entries.find((e) => e.id === "bodyMass"); + expect(bodyMass?.direction).toBe("bidirectional"); + }); +}); + +describe("PATCH /api/integrations/healthkit", () => { + function req(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/integrations/healthkit", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + } + + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await PATCH(req({ entries: [] })); + expect(res.status).toBe(401); + }); + + it("returns 422 for invalid direction", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await PATCH( + req({ entries: [{ id: "bodyMass", direction: "FOO" }] }), + ); + expect(res.status).toBe(422); + }); + + it("updates known entries and silently ignores unknown ids", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await PATCH( + req({ + entries: [ + { id: "bodyMass", direction: "readOnly" }, + { id: "totallyUnknown", direction: "bidirectional" }, + ], + }), + ); + expect(res.status).toBe(200); + expect(prisma.user.update).toHaveBeenCalledTimes(1); + const updateArgs = vi.mocked(prisma.user.update).mock.calls[0][0] as unknown as { + data: { healthKitConfigJson: { entries: Array<{ id: string }> } }; + }; + const ids = updateArgs.data.healthKitConfigJson.entries.map((e) => e.id); + expect(ids).toContain("bodyMass"); + expect(ids).not.toContain("totallyUnknown"); + }); +}); diff --git a/src/app/api/integrations/healthkit/route.ts b/src/app/api/integrations/healthkit/route.ts new file mode 100644 index 000000000..e0c769719 --- /dev/null +++ b/src/app/api/integrations/healthkit/route.ts @@ -0,0 +1,185 @@ +/** + * HealthKit (iOS) integration config. + * + * Persisted on `User.healthKitConfigJson` as `{ entries: [...] }`. + * Each entry: `{ id, kind, direction, enabled }` where `direction` is one + * of `bidirectional | readOnly | writeOnly`. + * + * GET → resolved config (with default entries when nothing is stored). + * PATCH → merge by `id`; unknown ids are silently ignored. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess, safeJson } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { prisma } from "@/lib/db"; +import { Prisma } from "@/generated/prisma/client"; +import { auditLog } from "@/lib/auth/audit"; + +export const directionEnum = z.enum([ + "bidirectional", + "readOnly", + "writeOnly", + "disabled", +]); + +const entrySchema = z.object({ + id: z.string().min(1).max(64), + kind: z.string().min(1).max(64).optional(), + direction: directionEnum, + enabled: z.boolean().optional(), +}); + +const patchSchema = z.object({ + entries: z.array(entrySchema).max(50), +}); + +export interface HealthKitEntry { + id: string; + kind: string; + direction: z.infer; + enabled: boolean; +} + +export const DEFAULT_HEALTHKIT_ENTRIES: HealthKitEntry[] = [ + { id: "bodyMass", kind: "bodyMass", direction: "bidirectional", enabled: true }, + { id: "bp", kind: "bloodPressure", direction: "bidirectional", enabled: true }, + { id: "glucose", kind: "bloodGlucose", direction: "bidirectional", enabled: true }, + { id: "heartRate", kind: "heartRate", direction: "readOnly", enabled: true }, + { id: "stepCount", kind: "stepCount", direction: "readOnly", enabled: true }, + { id: "sleep", kind: "sleepAnalysis", direction: "readOnly", enabled: true }, +]; + +interface StoredConfig { + entries: HealthKitEntry[]; +} + +function parseStored(json: unknown): StoredConfig | null { + if (!json || typeof json !== "object") return null; + const obj = json as Record; + const entries = obj.entries; + if (!Array.isArray(entries)) return null; + const out: HealthKitEntry[] = []; + for (const raw of entries) { + if (!raw || typeof raw !== "object") continue; + const e = raw as Record; + const id = typeof e.id === "string" ? e.id : null; + const kind = typeof e.kind === "string" ? e.kind : id; + const directionRaw = typeof e.direction === "string" ? e.direction : null; + if (!id || !kind || !directionRaw) continue; + const dir = directionEnum.safeParse(directionRaw); + if (!dir.success) continue; + out.push({ + id, + kind, + direction: dir.data, + enabled: typeof e.enabled === "boolean" ? e.enabled : true, + }); + } + return { entries: out }; +} + +function mergeWithDefaults(stored: StoredConfig | null): HealthKitEntry[] { + const map = new Map(); + for (const def of DEFAULT_HEALTHKIT_ENTRIES) { + map.set(def.id, { ...def }); + } + if (stored) { + for (const entry of stored.entries) { + const existing = map.get(entry.id); + if (existing) { + map.set(entry.id, { ...existing, ...entry }); + } else { + // Surface custom keys the user has added. + map.set(entry.id, { ...entry, enabled: entry.enabled ?? true }); + } + } + } + return [...map.values()]; +} + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "integrations.healthkit.get" } }); + + const row = await prisma.user.findUnique({ + where: { id: user.id }, + select: { healthKitConfigJson: true, healthKitLastSyncedAt: true }, + }); + + const stored = parseStored(row?.healthKitConfigJson ?? null); + const entries = mergeWithDefaults(stored); + + return apiSuccess({ + entries, + lastSyncedAt: row?.healthKitLastSyncedAt?.toISOString() ?? null, + }); +}); + +export const PATCH = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const { data: body, error } = await safeJson(request); + if (error) return error; + + const parsed = patchSchema.safeParse(body); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } + + const row = await prisma.user.findUnique({ + where: { id: user.id }, + select: { healthKitConfigJson: true }, + }); + const stored = parseStored(row?.healthKitConfigJson ?? null); + + // Resolve known ids set: default ids + already-stored ids. + const knownIds = new Set( + DEFAULT_HEALTHKIT_ENTRIES.map((e) => e.id), + ); + if (stored) for (const e of stored.entries) knownIds.add(e.id); + + // Merge by id; unknown ids are silently ignored per spec. + const map = new Map(); + if (stored) for (const e of stored.entries) map.set(e.id, e); + for (const update of parsed.data.entries) { + if (!knownIds.has(update.id)) continue; + const existing = + map.get(update.id) ?? + DEFAULT_HEALTHKIT_ENTRIES.find((d) => d.id === update.id); + map.set(update.id, { + id: update.id, + kind: update.kind ?? existing?.kind ?? update.id, + direction: update.direction, + enabled: update.enabled ?? existing?.enabled ?? true, + }); + } + + const updatedConfig: StoredConfig = { entries: [...map.values()] }; + + await prisma.user.update({ + where: { id: user.id }, + data: { + healthKitConfigJson: updatedConfig as unknown as Prisma.InputJsonValue, + }, + }); + + await auditLog("integrations.healthkit.update", { + userId: user.id, + details: { count: updatedConfig.entries.length }, + }); + + annotate({ + action: { name: "integrations.healthkit.update" }, + meta: { count: updatedConfig.entries.length }, + }); + + // Return resolved config (defaults merged) so the client always sees a + // complete entries list. + const merged = mergeWithDefaults(updatedConfig); + return apiSuccess({ + entries: merged, + lastSyncedAt: null, + }); +}); diff --git a/src/app/api/measurements/series/__tests__/route.test.ts b/src/app/api/measurements/series/__tests__/route.test.ts new file mode 100644 index 000000000..bcfcf041e --- /dev/null +++ b/src/app/api/measurements/series/__tests__/route.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { measurement: { 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 }, +}; + +function req(query: string): NextRequest { + return new NextRequest(`http://localhost/api/measurements/series?${query}`); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never); +}); + +describe("GET /api/measurements/series", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await GET(req("kind=weight")); + expect(res.status).toBe(401); + }); + + it("returns 422 for invalid kind", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await GET(req("kind=garbage")); + expect(res.status).toBe(422); + }); + + it("returns paired BP series with sys + dia", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const t = new Date("2026-05-01T10:00:00.000Z"); + const findManyMock = vi.mocked(prisma.measurement.findMany); + findManyMock.mockImplementation(((args: unknown) => { + const a = args as { where: { type: string } }; + if (a.where.type === "BLOOD_PRESSURE_SYS") { + return Promise.resolve([ + { id: "s1", value: 126, measuredAt: t }, + ]) as never; + } + if (a.where.type === "BLOOD_PRESSURE_DIA") { + return Promise.resolve([ + { + id: "d1", + value: 82, + measuredAt: new Date(t.getTime() + 60_000), + }, + ]) as never; + } + return Promise.resolve([]) as never; + }) as never); + const res = await GET(req("kind=bloodPressure&days=30")); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + kind: string; + points: Array<{ value: number; secondary: number | null }>; + stats: { count: number }; + }; + }; + expect(body.data.kind).toBe("bloodPressure"); + expect(body.data.points).toHaveLength(1); + expect(body.data.points[0].value).toBe(126); + expect(body.data.points[0].secondary).toBe(82); + expect(body.data.stats.count).toBe(1); + }); + + it("returns single-value series for kind=weight", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.measurement.findMany).mockResolvedValue([ + { id: "w1", value: 78.4, measuredAt: new Date() }, + ] as never); + const res = await GET(req("kind=weight&days=7")); + const body = (await res.json()) as { + data: { points: Array<{ secondary: number | null }> }; + }; + expect(body.data.points[0].secondary).toBeNull(); + }); +}); diff --git a/src/app/api/measurements/series/route.ts b/src/app/api/measurements/series/route.ts new file mode 100644 index 000000000..5307e1c4f --- /dev/null +++ b/src/app/api/measurements/series/route.ts @@ -0,0 +1,154 @@ +/** + * GET /api/measurements/series?kind=&days= + * + * iOS-friendly time-series adapter. Maps the camelCase `kind` to the + * canonical Prisma `MeasurementType`(s) and returns + * { kind, points: [{ id, at, value, secondary }], stats } + * + * The `kind=bloodPressure` case pairs systolic + diastolic by + * `measuredAt` so iOS can render the dual-line chart with one fetch. + */ +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; +import { prisma } from "@/lib/db"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { summarize, type DataPoint } from "@/lib/analytics/trends"; +import type { MeasurementType } from "@/generated/prisma/client"; + +const kindEnum = z.enum([ + "weight", + "bloodPressure", + "pulse", + "bodyFat", + "glucose", + "sleep", + "steps", +]); + +const querySchema = z.object({ + kind: kindEnum, + days: z.coerce.number().int().min(1).max(365).optional().default(30), +}); + +const KIND_TO_TYPE: Record, MeasurementType> = { + weight: "WEIGHT", + bloodPressure: "BLOOD_PRESSURE_SYS", + pulse: "PULSE", + bodyFat: "BODY_FAT", + glucose: "BLOOD_GLUCOSE", + sleep: "SLEEP_DURATION", + steps: "ACTIVITY_STEPS", +}; + +interface SeriesPoint { + id: string; + at: string; + value: number; + secondary: number | null; +} + +function stdDev(values: number[]): number { + if (values.length < 2) return 0; + const mean = values.reduce((sum, v) => sum + v, 0) / values.length; + const variance = + values.reduce((sum, v) => sum + (v - mean) ** 2, 0) / values.length; + return Math.round(Math.sqrt(variance) * 100) / 100; +} + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const parsed = querySchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams), + ); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } + + const { kind, days } = parsed.data; + const since = new Date(Date.now() - days * 86_400_000); + + let points: SeriesPoint[] = []; + if (kind === "bloodPressure") { + const [sys, dia] = await Promise.all([ + prisma.measurement.findMany({ + where: { + userId: user.id, + type: "BLOOD_PRESSURE_SYS", + measuredAt: { gte: since }, + }, + orderBy: { measuredAt: "asc" }, + select: { id: true, value: true, measuredAt: true }, + }), + prisma.measurement.findMany({ + where: { + userId: user.id, + type: "BLOOD_PRESSURE_DIA", + measuredAt: { gte: since }, + }, + orderBy: { measuredAt: "asc" }, + select: { id: true, value: true, measuredAt: true }, + }), + ]); + + // Pair by closest timestamp within ±5 minutes. + const PAIR_WINDOW_MS = 5 * 60_000; + points = sys.map((s) => { + let bestDia: { value: number; dt: number } | null = null; + for (const d of dia) { + const dt = Math.abs(d.measuredAt.getTime() - s.measuredAt.getTime()); + if (dt > PAIR_WINDOW_MS) continue; + if (!bestDia || dt < bestDia.dt) { + bestDia = { value: d.value, dt }; + } + } + return { + id: s.id, + at: s.measuredAt.toISOString(), + value: s.value, + secondary: bestDia?.value ?? null, + }; + }); + } else { + const type = KIND_TO_TYPE[kind]; + const rows = await prisma.measurement.findMany({ + where: { userId: user.id, type, measuredAt: { gte: since } }, + orderBy: { measuredAt: "asc" }, + select: { id: true, value: true, measuredAt: true }, + }); + points = rows.map((r) => ({ + id: r.id, + at: r.measuredAt.toISOString(), + value: r.value, + secondary: null, + })); + } + + const dataPoints: DataPoint[] = points.map((p) => ({ + date: new Date(p.at), + value: p.value, + })); + const summary = dataPoints.length > 0 ? summarize(dataPoints) : null; + const values = dataPoints.map((p) => p.value); + + annotate({ + action: { name: "measurements.series" }, + meta: { kind, days, count: points.length }, + }); + + return apiSuccess({ + kind, + points, + stats: summary + ? { + mean: summary.mean, + min: summary.min, + max: summary.max, + stdDev: stdDev(values), + count: summary.count, + } + : { mean: 0, min: 0, max: 0, stdDev: 0, count: 0 }, + }); +}); diff --git a/src/app/api/medications/intake/__tests__/route.test.ts b/src/app/api/medications/intake/__tests__/route.test.ts new file mode 100644 index 000000000..a865812ed --- /dev/null +++ b/src/app/api/medications/intake/__tests__/route.test.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + medicationIntakeEvent: { + findMany: vi.fn(), + findUnique: vi.fn(), + update: vi.fn(), + }, + medication: { update: vi.fn() }, + $transaction: 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, POST } 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(); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never); +}); + +describe("GET /api/medications/intake", () => { + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await GET( + new NextRequest("http://localhost/api/medications/intake?scope=today"), + ); + expect(res.status).toBe(401); + }); + + it("returns 422 for invalid scope", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await GET( + new NextRequest("http://localhost/api/medications/intake?scope=junk"), + ); + expect(res.status).toBe(422); + }); + + it("returns today's events as a flat array", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + { + id: "e1", + medicationId: "m1", + scheduledFor: new Date(), + takenAt: null, + skipped: false, + medication: { id: "m1", snoozedUntil: null }, + }, + ] as never); + const res = await GET( + new NextRequest("http://localhost/api/medications/intake?scope=today"), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: Array<{ id: string; status: string }>; + }; + expect(body.data).toHaveLength(1); + expect(body.data[0].status).toBe("pending"); + }); + + it("returns compliance buckets for the last N days", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([ + { + scheduledFor: new Date(), + takenAt: new Date(), + skipped: false, + }, + ] as never); + const res = await GET( + new NextRequest( + "http://localhost/api/medications/intake?scope=compliance&days=7", + ), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: Array<{ date: string; scheduled: number; taken: number }>; + }; + expect(body.data.length).toBe(7); + }); +}); + +describe("POST /api/medications/intake", () => { + function req(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/medications/intake", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + } + + it("returns 401 when unauthenticated", async () => { + vi.mocked(getSession).mockResolvedValue(null); + const res = await POST(req({ intakeId: "e1", status: "taken" })); + expect(res.status).toBe(401); + }); + + it("returns 404 when the event isn't owned by the user", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValue({ + id: "e1", + userId: "someone-else", + medicationId: "m1", + } as never); + const res = await POST(req({ intakeId: "e1", status: "taken" })); + expect(res.status).toBe(404); + }); + + it("returns 422 for invalid status", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + const res = await POST(req({ intakeId: "e1", status: "broken" })); + expect(res.status).toBe(422); + }); + + it("marks event as skipped", async () => { + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValue({ + id: "e1", + userId: "user-1", + medicationId: "m1", + } as never); + vi.mocked(prisma.medicationIntakeEvent.update).mockResolvedValue({ + id: "e1", + skipped: true, + takenAt: null, + } as never); + const res = await POST(req({ intakeId: "e1", status: "skipped" })); + expect(res.status).toBe(200); + expect(prisma.medicationIntakeEvent.update).toHaveBeenCalledWith({ + where: { id: "e1" }, + data: { takenAt: null, skipped: true }, + }); + }); +}); diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts new file mode 100644 index 000000000..c379e35dd --- /dev/null +++ b/src/app/api/medications/intake/route.ts @@ -0,0 +1,201 @@ +/** + * Top-level medication-intake aggregator + writer. + * + * GET /api/medications/intake?scope=today + * → array of today's intake events for the user (across medications). + * + * GET /api/medications/intake?scope=compliance&days=N + * → per-day { date, scheduled, taken } for the last N days. + * + * POST /api/medications/intake + * Body: { intakeId, status: "taken" | "skipped" | "snoozed", takenAt?, snoozedUntil? } + * Updates the named MedicationIntakeEvent and returns the updated row. + */ +import { 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 { annotate } from "@/lib/logging/context"; +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; + +const querySchema = z.object({ + scope: z.enum(["today", "compliance"]), + days: z.coerce.number().int().min(1).max(365).optional().default(30), +}); + +const updateSchema = z.object({ + intakeId: z.string().min(1), + status: z.enum(["taken", "skipped", "snoozed"]), + takenAt: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .optional(), + snoozedUntil: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .optional(), +}); + +function startOfDayBerlin(date: Date): Date { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(date); + 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); +} + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const parsed = querySchema.safeParse( + Object.fromEntries(request.nextUrl.searchParams), + ); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } + + const { scope, days } = parsed.data; + + if (scope === "today") { + const todayStart = startOfDayBerlin(new Date()); + const todayEnd = new Date(todayStart.getTime() + 86_400_000); + const events = await prisma.medicationIntakeEvent.findMany({ + where: { + userId: user.id, + scheduledFor: { gte: todayStart, lt: todayEnd }, + }, + orderBy: { scheduledFor: "asc" }, + include: { medication: { select: { id: true, snoozedUntil: true } } }, + }); + + annotate({ + action: { name: "medications.intake.today" }, + meta: { count: events.length }, + }); + + return apiSuccess( + events.map((e) => ({ + id: e.id, + medicationId: e.medicationId, + scheduledAt: e.scheduledFor.toISOString(), + takenAt: e.takenAt?.toISOString() ?? null, + status: e.skipped + ? "skipped" + : e.takenAt + ? "taken" + : e.medication.snoozedUntil && + e.medication.snoozedUntil > new Date() + ? "snoozed" + : "pending", + snoozedUntil: e.medication.snoozedUntil?.toISOString() ?? null, + })), + ); + } + + // compliance: per-day scheduled vs taken for the last N days + const start = new Date(Date.now() - days * 86_400_000); + const events = await prisma.medicationIntakeEvent.findMany({ + where: { userId: user.id, scheduledFor: { gte: start } }, + select: { scheduledFor: true, takenAt: true, skipped: true }, + }); + + const buckets = new Map(); + 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 }); + } + for (const e of events) { + const key = berlinDayKey(e.scheduledFor); + const bucket = buckets.get(key); + if (!bucket) continue; + bucket.scheduled += 1; + if (e.takenAt && !e.skipped) bucket.taken += 1; + } + + const result = [...buckets.entries()] + .map(([date, v]) => ({ date, scheduled: v.scheduled, taken: v.taken })) + .sort((a, b) => a.date.localeCompare(b.date)); + + annotate({ + action: { name: "medications.intake.compliance" }, + meta: { days, count: result.length }, + }); + + return apiSuccess(result); +}); + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const { data: body, error } = await safeJson(request); + if (error) return error; + + const parsed = updateSchema.safeParse(body); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 422); + } + + const { intakeId, status, takenAt, snoozedUntil } = parsed.data; + + const existing = await prisma.medicationIntakeEvent.findUnique({ + where: { id: intakeId }, + }); + if (!existing || existing.userId !== user.id) { + return apiError("Intake event not found", 404); + } + + let updated; + if (status === "taken") { + [updated] = await prisma.$transaction([ + prisma.medicationIntakeEvent.update({ + where: { id: intakeId }, + data: { takenAt: takenAt ?? new Date(), skipped: false }, + }), + prisma.medication.update({ + where: { id: existing.medicationId }, + data: { snoozedUntil: null }, + }), + ]); + } else if (status === "skipped") { + updated = await prisma.medicationIntakeEvent.update({ + where: { id: intakeId }, + data: { takenAt: null, skipped: true }, + }); + } else { + // snoozed: snoozedUntil lives on the Medication row. + const until = + snoozedUntil ?? new Date(Date.now() + 30 * 60_000); // default +30min + await prisma.medication.update({ + where: { id: existing.medicationId }, + data: { snoozedUntil: until }, + }); + updated = await prisma.medicationIntakeEvent.findUnique({ + where: { id: intakeId }, + }); + } + + await auditLog("medications.intake.update", { + userId: user.id, + ipAddress: getClientIp(request), + details: { intakeId, status }, + }); + + annotate({ + action: { name: "medications.intake.update" }, + meta: { intakeId, status }, + }); + + return apiSuccess(updated); +});