mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
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);
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user