feat(api): aggregator endpoints for headless API clients

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) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-03 21:53:16 +02:00
1 parent 98e209b6f7
commit 1f5ad1729d
14 files changed
+1939

No files matched your search

@@ -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<Response>;
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);
});
});
+346
View File
@@ -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<MetricKind, string> = {
weight: "Gewicht",
bloodPressure: "Blutdruck",
pulse: "Puls",
bodyFat: "Körperfett",
glucose: "Blutzucker",
sleep: "Schlaf",
steps: "Schritte",
};
const METRIC_UNITS: Record<MetricKind, string> = {
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<string>): 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<string>();
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<MeasurementType, { value: number; at: Date }[]>();
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(),
});
});
+103
View File
@@ -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();
});
});
+86
View File
@@ -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);
});
@@ -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<Response>;
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);
});
});
+189
View File
@@ -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<Record<MeasurementType, ReturnType<typeof summarize>>> = {};
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);
});
@@ -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<Response>;
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" }),
);
});
});
@@ -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([]);
});
@@ -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<Response>;
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");
});
});
+185
View File
@@ -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<typeof directionEnum>;
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<string, unknown>;
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<string, unknown>;
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<string, HealthKitEntry>();
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<string>(
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<string, HealthKitEntry>();
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,
});
});
@@ -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();
});
});
+154
View File
@@ -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<z.infer<typeof kindEnum>, 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 },
});
});
@@ -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 },
});
});
});
+201
View File
@@ -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<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 });
}
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);
});