Merge pull request #117 from MBombeck/feat/api-headless-clients

feat: headless-client API surface (Bearer auth, idempotency, aggregator endpoints, PDF report)
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-03 23:00:31 +02:00
commit aac2e18668
45 files changed
+10064 -269

No files matched your search

+30
View File
@@ -0,0 +1,30 @@
# Gitleaks configuration. Extends the built-in default ruleset and adds
# a project-specific allowlist for false-positives that gitleaks 8.x flags
# as "generic-api-key" inside test fixtures.
[extend]
useDefault = true
[allowlist]
description = "Project-wide ignores for test fixtures and known non-secret tokens"
paths = [
# All test files contain hardcoded fake tokens / idempotency-keys.
'''(^|/)__tests__/''',
'''\.test\.ts$''',
# OpenAPI spec contains example tokens (`hlk_example…`) used to document the schema.
'''^docs/api/openapi\.yaml$''',
]
# Substring matches — anything matching is treated as non-secret regardless of file.
regexes = [
# Idempotency-Key request-header values are random UUIDs / opaque strings,
# not secrets. The header name itself just trips the generic-api-key rule.
'''idempotency-key''',
# Allow the placeholder prefix used in OpenAPI examples + docs.
'''hlk_example''',
]
# Specific commit fingerprints from prior false-positive findings (kept for reproducibility).
# Empty by default; populate via `gitleaks detect --report-path` if a future regression
# triggers the same false-positive on a stable commit hash.
stopwords = []
+76
View File
@@ -0,0 +1,76 @@
# HealthLog API Spec
`openapi.yaml` is the canonical OpenAPI 3.1 description for the HealthLog
Next.js API. It is the source of truth for the native iOS client's
DTO codegen.
## Preview locally
Open an interactive Redoc preview in your browser:
```bash
npx @redocly/cli preview docs/api/openapi.yaml
```
Generate static HTML for sharing:
```bash
npx @redocly/cli build-docs docs/api/openapi.yaml --output docs/api/index.html
```
## Validate
```bash
npx @redocly/cli lint docs/api/openapi.yaml
```
The spec is expected to be lint-clean (errors = 0). Some warnings are
intentional:
- `no-server-example.com` — `http://localhost:3000` is the dev server.
- `operation-2xx-response` / `operation-4xx-response` — webhook + redirect
endpoints intentionally omit one or the other.
- `no-unused-components` — `ReminderPhase` is exposed for client codegen
even though no path response references it directly.
## Layout
- **`info`** — title, version (mirrored from `package.json`), description.
- **`servers`** — production `https://healthlog.bombeck.io` plus
`http://localhost:3000` for local development.
- **`tags`** — domain groupings (`Auth`, `Measurements`, `Medications`,
`Mood`, `Dashboard`, `Insights`, `Achievements`, `DoctorReport`,
`Integrations`, `User`, `Tokens`, `Notifications`, `Onboarding`,
`Health`, `Settings`, `Analytics`, `Export`, `AuditLog`, `Feedback`,
`BugReport`, `Monitoring`, `Webhooks`, `Ingest`, `Admin`).
- **`securitySchemes`**:
- `bearerAuth` — long-lived `hlk_*` API tokens issued via `POST /api/tokens`.
Required on `/api/ingest/medication`.
- `sessionCookie` — HttpOnly `healthlog_session` cookie set by the
password and passkey login flows. Used by the web app and any iOS
client running inside a web view.
- **`components.schemas`** — DTOs for all routes plus the underlying
Prisma models (e.g. `Measurement`, `Medication`, `MedicationIntakeEvent`,
`MoodEntry`, `User`, `DashboardLayout`, `ApiEnvelope`, `ApiError`).
- **`paths`** — all routes from `src/app/api/**/route.ts`, including
the `/api/admin/*` family which is tagged `Admin` and marked internal.
## Conventions
- Successful responses use the envelope `{ data, error: null }`.
- Error responses use the envelope `{ data: null, error: '<message>' }`.
- Pagination uses `limit` + `offset` query parameters and a
`meta: { total, limit, offset }` block on the response.
- All timestamps are ISO 8601 / RFC 3339 (`format: date-time`) in UTC.
- IDs are CUIDs (opaque strings).
## Updating the spec
When adding or changing routes:
1. Update the relevant route handler under `src/app/api/**/route.ts`.
2. Mirror the change in `openapi.yaml`. Keep `operationId` camelCase and
stable — the iOS codegen keys off it.
3. Run `npx @redocly/cli lint docs/api/openapi.yaml` and resolve all
errors. Warnings are tolerated when documented above.
4. Bump `info.version` if the change is breaking for clients.
File diff suppressed because it is too large. Load diff
+57
View File
@@ -0,0 +1,57 @@
# Doctor Report
The doctor report is a printable PDF summary of a user's tracked vitals,
medication compliance, glucose context, and mood over a configurable window
(default 90 days, max 365). It is intended as discussion material for a
doctor's appointment, **not** a medical diagnosis.
## Two endpoints
The same aggregation logic backs both endpoints — `collectDoctorReportData`
in `src/lib/doctor-report-data.ts` is the single source of truth — so the
JSON payload and the server-rendered PDF are guaranteed to be in sync.
### `POST /api/doctor-report` — JSON payload
Returns the aggregated `DoctorReportData` envelope. Used by the PWA's
client-side renderer (`src/lib/doctor-report-pdf.ts`, jsPDF + autotable).
Best on desktop browsers and on Android, where local generation keeps the
PDF off the wire and works offline (cached page).
- Auth: session required.
- Rate-limit: 10/h per user.
- Body: `{ days?: number }` (default 90).
- Response: `200 application/json` with envelope `{ data, error: null }`.
- Audit action: `doctor-report.generate`.
### `POST /api/doctor-report/pdf` — server-rendered PDF
Returns the ready-to-download PDF bytes. Used primarily on iOS/Safari, where
the client-side jsPDF download UX is unreliable (Safari sometimes opens the
PDF in a new tab without preserving the filename or fails the blob save).
- Auth: session required (`401` otherwise).
- Rate-limit: 10/h per user, shared with the JSON endpoint (`429` on excess).
- Body: `{ days?: number; locale?: "de" | "en" }`. Body is optional — empty
bodies are accepted.
- Locale resolution: explicit `body.locale` wins, then the
`Accept-Language` header (`de*` → `de`, anything else → `en`), then the
hard fallback `de`.
- Response: `200 application/pdf` with
`Content-Disposition: attachment; filename="healthlog-report-YYYY-MM-DD.pdf"`
and `Cache-Control: no-store`. Body is the raw PDF bytes.
- Errors: JSON envelope `{ data: null, error: "..." }` for `401` and `429`;
`500` if rendering fails.
- Audit action: `doctor-report.pdf.generate`.
## Implementation notes
- `src/lib/doctor-report-pdf-core.ts` contains the isomorphic renderer.
jsPDF runs unchanged in Node — `doc.output("arraybuffer")` returns a valid
`%PDF-` byte stream in both environments, so we deliberately avoid headless
Chromium (Puppeteer/Playwright) to keep the runtime dependency surface low.
- `src/lib/doctor-report-pdf.ts` is a thin client façade that returns the
raw `jsPDF` instance so the existing settings-page download flow (which
calls `doc.save(...)`) keeps working unchanged.
- Server-side translations come from `messages/{de,en}.json` via
`getServerTranslator()` (`src/lib/i18n/server-translator.ts`).
@@ -0,0 +1,55 @@
-- v1.3: iOS adapter endpoints + idempotency cache + device registration
--
-- Purely additive:
-- • users.display_name, users.healthkit_config_json, users.healthkit_last_synced_at
-- • new tables: idempotency_keys, devices
-- ── users new columns ─────────────────────────────────────────────────
ALTER TABLE "users"
ADD COLUMN "display_name" TEXT,
ADD COLUMN "healthkit_config_json" JSONB,
ADD COLUMN "healthkit_last_synced_at" TIMESTAMP(3);
-- ── idempotency_keys ──────────────────────────────────────────────────
CREATE TABLE "idempotency_keys" (
"id" TEXT PRIMARY KEY,
"user_id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"method" TEXT NOT NULL,
"path" TEXT NOT NULL,
"response_status" INTEGER NOT NULL,
"response_body" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "idempotency_keys_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX "idempotency_keys_user_id_key_method_path_key"
ON "idempotency_keys" ("user_id", "key", "method", "path");
CREATE INDEX "idempotency_keys_expires_at_idx"
ON "idempotency_keys" ("expires_at");
-- ── devices ───────────────────────────────────────────────────────────
CREATE TABLE "devices" (
"id" TEXT PRIMARY KEY,
"user_id" TEXT NOT NULL,
"platform" TEXT NOT NULL,
"token" TEXT NOT NULL,
"bundle_id" TEXT NOT NULL,
"locale" TEXT,
"app_version" TEXT,
"model" TEXT,
"last_seen" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "devices_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX "devices_token_key" ON "devices" ("token");
CREATE INDEX "devices_user_id_idx" ON "devices" ("user_id");
+56
View File
@@ -74,6 +74,14 @@ model User {
// Onboarding
onboardingCompletedAt DateTime? @map("onboarding_completed_at")
// HealthKit (iOS) integration config — list of per-metric direction toggles.
// Shape: { entries: [{ id, kind, direction, enabled }], lastSyncedAt }
healthKitConfigJson Json? @map("healthkit_config_json")
healthKitLastSyncedAt DateTime? @map("healthkit_last_synced_at")
// Display name (optional override of username for greetings).
displayName String? @map("display_name")
// Relations
passkeys Passkey[]
sessions Session[]
@@ -90,6 +98,8 @@ model User {
dataBackups DataBackup[]
achievements UserAchievement[]
feedback Feedback[]
idempotencyKeys IdempotencyKey[]
devices Device[]
@@map("users")
}
@@ -559,6 +569,52 @@ model RateLimit {
@@map("rate_limits")
}
// ─── Idempotency Keys (mobile-client replay protection) ──
/// Caches the response of a POST/PUT/PATCH/DELETE request keyed by
/// (userId, key, method, path) so retries within the TTL replay the
/// original outcome instead of producing a duplicate side-effect.
model IdempotencyKey {
id String @id @default(cuid())
userId String @map("user_id")
key String
method String
path String
responseStatus Int @map("response_status")
responseBody String @map("response_body") // serialized JSON envelope
expiresAt DateTime @map("expires_at")
createdAt DateTime @default(now()) @map("created_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, key, method, path])
@@index([expiresAt])
@@map("idempotency_keys")
}
// ─── Devices (APNs / mobile push targets) ────────────────
/// Registered native-client device for push delivery. Token is unique
/// across users; re-registering on a different account moves ownership.
model Device {
id String @id @default(cuid())
userId String @map("user_id")
platform String // "ios"
token String @unique
bundleId String @map("bundle_id")
locale String?
appVersion String? @map("app_version")
model String?
lastSeen DateTime @default(now()) @map("last_seen")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("devices")
}
// ─── User Achievements ───────────────────────────────────────
model UserAchievement {
@@ -0,0 +1,133 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// --- Mocks must come before importing the route. ---
vi.mock("@/lib/db", () => ({
prisma: {
user: { findFirst: vi.fn() },
apiToken: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/password", () => ({
verifyPassword: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({
createSession: vi.fn().mockResolvedValue("session-id"),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({ allowed: true, remaining: 5, reset: 0 }),
rateLimitHeaders: vi.fn(() => ({})),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/hmac", () => ({
hashToken: vi.fn(() => "deadbeef"),
}));
vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));
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 { verifyPassword } from "@/lib/auth/password";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { ensureDbCompatibility } from "@/lib/db-compat";
import { auditLog } from "@/lib/auth/audit";
import { createSession } from "@/lib/auth/session";
const FAKE_USER = {
id: "user-1",
username: "marc",
email: "marc@example.com",
passwordHash: "$argon2id$...",
};
function makeRequest(headers: Record<string, string> = {}): NextRequest {
return new NextRequest("http://localhost/api/auth/login", {
method: "POST",
headers: {
"content-type": "application/json",
...headers,
},
body: JSON.stringify({ email: "marc@example.com", password: "supersecret" }),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.user.findFirst).mockResolvedValue(FAKE_USER as never);
vi.mocked(verifyPassword).mockResolvedValue(true);
vi.mocked(prisma.apiToken.create).mockResolvedValue({ id: "tok-1" } as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 5,
reset: 0,
} as never);
vi.mocked(rateLimitHeaders).mockReturnValue({} as never);
vi.mocked(ensureDbCompatibility).mockResolvedValue(undefined);
vi.mocked(auditLog).mockResolvedValue(undefined as never);
vi.mocked(createSession).mockResolvedValue("session-id");
});
describe("POST /api/auth/login — native token issuance", () => {
it("issues a Bearer token when X-Client-Type: native is set", async () => {
const original = process.env.API_TOKEN_HMAC_KEY;
process.env.API_TOKEN_HMAC_KEY = "test-key";
try {
const res = await POST(makeRequest({ "x-client-type": "native" }));
const body = (await res.json()) as {
data: { user: { id: string }; token?: string; tokenExpiresAt?: string };
};
expect(res.status).toBe(200);
expect(body.data.user.id).toBe("user-1");
expect(body.data.token).toMatch(/^hlk_[a-f0-9]{64}$/);
expect(body.data.tokenExpiresAt).toEqual(expect.any(String));
expect(prisma.apiToken.create).toHaveBeenCalledTimes(1);
} finally {
process.env.API_TOKEN_HMAC_KEY = original;
}
});
it("does NOT issue a token without the native header", async () => {
const res = await POST(makeRequest());
const body = (await res.json()) as {
data: { user: { id: string }; token?: string };
};
expect(res.status).toBe(200);
expect(body.data.user.id).toBe("user-1");
expect(body.data.token).toBeUndefined();
expect(prisma.apiToken.create).not.toHaveBeenCalled();
});
it("issues a token when User-Agent starts with HealthLog-iOS", async () => {
const original = process.env.API_TOKEN_HMAC_KEY;
process.env.API_TOKEN_HMAC_KEY = "test-key";
try {
const res = await POST(
makeRequest({ "user-agent": "HealthLog-iOS/1.0 (iPhone)" }),
);
const body = (await res.json()) as { data: { token?: string } };
expect(res.status).toBe(200);
expect(body.data.token).toMatch(/^hlk_/);
} finally {
process.env.API_TOKEN_HMAC_KEY = original;
}
});
});
+24
View File
@@ -9,6 +9,7 @@ import { ensureDbCompatibility } from "@/lib/db-compat";
import { NextRequest, NextResponse } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { issueApiToken, isNativeClientRequest } from "@/lib/auth/issue-token";
export const POST = apiHandler(async (request: NextRequest) => {
const ip = getClientIp(request) ?? "unknown";
@@ -74,6 +75,29 @@ export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "auth.login.password" } });
// Native clients (iOS) additionally receive a long-lived Bearer token in
// the response so they don't need to juggle the cookie jar.
if (isNativeClientRequest(request.headers)) {
const issued = await issueApiToken({
userId: user.id,
name: `iOS auto-login ${new Date().toISOString()}`,
permissions: ["*"],
expiresInDays: 90,
});
await auditLog("auth.token.autoissue.native", {
userId: user.id,
ipAddress: ip,
details: { tokenId: issued.tokenId, source: "login.password" },
});
annotate({ action: { name: "auth.token.autoissue.native" } });
return apiSuccess({
user: { id: user.id, username: user.username },
token: issued.token,
tokenExpiresAt: issued.expiresAt.toISOString(),
});
}
return apiSuccess({
user: { id: user.id, username: user.username },
});
@@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
apiToken: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/passkey", () => ({
verifyAuthentication: vi.fn(),
}));
vi.mock("@/lib/auth/session", () => ({
createSession: vi.fn().mockResolvedValue("session-id"),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/auth/hmac", () => ({
hashToken: vi.fn(() => "deadbeef"),
}));
vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));
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 { verifyAuthentication } from "@/lib/auth/passkey";
import { checkRateLimit } from "@/lib/rate-limit";
const FAKE_USER = {
id: "user-1",
username: "marc",
email: "marc@example.com",
};
function makeRequest(headers: Record<string, string> = {}): NextRequest {
return new NextRequest("http://localhost/api/auth/passkey/login-verify", {
method: "POST",
headers: { "content-type": "application/json", ...headers },
body: JSON.stringify({
challengeId: "ch-1",
credential: { id: "cred-1" },
}),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
vi.mocked(prisma.apiToken.create).mockResolvedValue({ id: "tok-1" } as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
reset: 0,
} as never);
vi.mocked(verifyAuthentication).mockResolvedValue({
verification: { verified: true },
passkey: { userId: "user-1" },
} as never);
});
describe("POST /api/auth/passkey/login-verify — native token issuance", () => {
it("issues a Bearer token when X-Client-Type: native is set", async () => {
const original = process.env.API_TOKEN_HMAC_KEY;
process.env.API_TOKEN_HMAC_KEY = "test-key";
try {
const res = await POST(makeRequest({ "x-client-type": "native" }));
const body = (await res.json()) as {
data: { token?: string; tokenExpiresAt?: string };
};
expect(res.status).toBe(200);
expect(body.data.token).toMatch(/^hlk_[a-f0-9]{64}$/);
expect(body.data.tokenExpiresAt).toEqual(expect.any(String));
expect(prisma.apiToken.create).toHaveBeenCalledTimes(1);
} finally {
process.env.API_TOKEN_HMAC_KEY = original;
}
});
it("does NOT issue a token without the native header", async () => {
const res = await POST(makeRequest());
const body = (await res.json()) as { data: { token?: string } };
expect(res.status).toBe(200);
expect(body.data.token).toBeUndefined();
expect(prisma.apiToken.create).not.toHaveBeenCalled();
});
it("returns 401 when verification fails", async () => {
vi.mocked(verifyAuthentication).mockResolvedValue({
verification: { verified: false },
passkey: { userId: "user-1" },
} as never);
const res = await POST(makeRequest({ "x-client-type": "native" }));
expect(res.status).toBe(401);
expect(prisma.apiToken.create).not.toHaveBeenCalled();
});
});
@@ -8,6 +8,7 @@ import { checkRateLimit } from "@/lib/rate-limit";
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { issueApiToken, isNativeClientRequest } from "@/lib/auth/issue-token";
export const POST = apiHandler(async (request: NextRequest) => {
await ensureDbCompatibility();
@@ -59,6 +60,27 @@ export const POST = apiHandler(async (request: NextRequest) => {
annotate({ action: { name: "auth.login.passkey" } });
if (isNativeClientRequest(request.headers)) {
const issued = await issueApiToken({
userId: user.id,
name: `iOS auto-login ${new Date().toISOString()}`,
permissions: ["*"],
expiresInDays: 90,
});
await auditLog("auth.token.autoissue.native", {
userId: user.id,
ipAddress: ip,
details: { tokenId: issued.tokenId, source: "login.passkey" },
});
annotate({ action: { name: "auth.token.autoissue.native" } });
return apiSuccess({
user: { id: user.id, username: user.username },
token: issued.token,
tokenExpiresAt: issued.expiresAt.toISOString(),
});
}
return apiSuccess({
user: { id: user.id, username: user.username },
});
+12 -44
View File
@@ -1,62 +1,30 @@
import { prisma } from "@/lib/db";
import { profileSchema } from "@/lib/validations/auth";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { applyProfileUpdate } from "@/lib/auth/profile-update";
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const parsed = profileSchema.safeParse(body);
if (!parsed.success) {
return apiError(parsed.error.issues[0].message, 422);
const result = await applyProfileUpdate(user.id, body, getClientIp(request));
if (!result.ok) {
return apiError(result.message, result.status);
}
const data = parsed.data;
const normalizedEmail = data.email ? data.email.trim().toLowerCase() : null;
if (data.email !== undefined && normalizedEmail) {
const existingUser = await prisma.user.findUnique({
where: { email: normalizedEmail },
select: { id: true },
});
if (existingUser && existingUser.id !== user.id) {
return apiError("Email already in use", 409);
}
}
const updatedUser = await prisma.user.update({
where: { id: user.id },
data: {
...(data.email !== undefined ? { email: normalizedEmail } : {}),
heightCm: data.heightCm ?? null,
dateOfBirth: data.dateOfBirth ? new Date(data.dateOfBirth) : null,
...(data.gender !== undefined ? { gender: data.gender } : {}),
},
});
await auditLog("profile.update", {
userId: updatedUser.id,
ipAddress: getClientIp(request),
});
annotate({ action: { name: "auth.profile.update" } });
return apiSuccess({
id: updatedUser.id,
username: updatedUser.username,
email: updatedUser.email,
role: updatedUser.role,
heightCm: updatedUser.heightCm,
dateOfBirth: updatedUser.dateOfBirth,
gender: updatedUser.gender,
timezone: updatedUser.timezone,
id: result.user.id,
username: result.user.username,
email: result.user.email,
role: result.user.role,
heightCm: result.user.heightCm,
dateOfBirth: result.user.dateOfBirth,
gender: result.user.gender,
timezone: result.user.timezone,
});
});
@@ -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(),
});
});
+118
View File
@@ -0,0 +1,118 @@
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("rejects cross-user re-registration with 409", 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(409);
expect(prisma.device.update).not.toHaveBeenCalled();
expect(prisma.device.create).not.toHaveBeenCalled();
});
it("updates in place when same user re-registers their own token", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.device.findUnique).mockResolvedValue({
id: "dev-1",
userId: "user-1",
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();
});
});
+96
View File
@@ -0,0 +1,96 @@
/**
* 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) {
// Cross-user-hijack guard: a device-token belongs to exactly one user.
// Re-registering the same token under a different account is rejected
// — APNs tokens aren't a secret, so trusting the wire input would let
// anyone who learns/guesses a token redirect another user's pushes.
if (existing.userId !== user.id) {
await auditLog("device.register.denied", {
userId: user.id,
details: { reason: "token_owned_by_other_user", deviceId: existing.id },
});
return apiError("Device token already registered to another account", 409);
}
const updated = await prisma.device.update({
where: { token },
data: {
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,186 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
// --- Mocks must come before importing the route. ---
vi.mock("@/lib/db", () => ({
prisma: {
measurement: { findMany: vi.fn() },
medication: { findMany: vi.fn() },
medicationIntakeEvent: { findMany: vi.fn() },
moodEntry: { findMany: vi.fn() },
user: { findUnique: vi.fn() },
auditLog: { create: vi.fn() },
},
}));
vi.mock("@/lib/auth/session", () => ({
getSession: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn(),
}));
vi.mock("@/lib/geo", () => ({
lookupIpLocation: vi.fn(),
}));
vi.mock("@/lib/analytics/effective-range", () => ({
getEffectiveRange: vi.fn(() => ({ range: null })),
}));
vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));
import { POST } from "../pdf/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { checkRateLimit } from "@/lib/rate-limit";
import { auditLog } from "@/lib/auth/audit";
function makeRequest(
body?: Record<string, unknown>,
headers: Record<string, string> = {},
): NextRequest {
const baseHeaders: Record<string, string> = {
"content-type": "application/json",
...headers,
};
return new NextRequest("http://localhost/api/doctor-report/pdf", {
method: "POST",
headers: baseHeaders,
body: body === undefined ? undefined : JSON.stringify(body),
});
}
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3600_000) },
user: { id: "user-1", role: "USER" as const },
};
function setEmptyDataMocks() {
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.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
username: "marc",
dateOfBirth: null,
gender: null,
heightCm: null,
glucoseUnit: null,
thresholdsJson: null,
} as never);
}
describe("POST /api/doctor-report/pdf", () => {
beforeEach(() => {
vi.resetAllMocks();
});
it("returns 401 when not authenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await POST(makeRequest({ days: 90 }));
expect(res.status).toBe(401);
const json = (await res.json()) as { error: string };
expect(json.error).toBe("Not authenticated");
});
it("returns 429 when rate limit exceeded", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now() + 3600_000,
});
const res = await POST(makeRequest({ days: 90 }));
expect(res.status).toBe(429);
const json = (await res.json()) as { error: string };
expect(json.error).toMatch(/Maximum/);
});
it("returns 200 application/pdf with PDF bytes on happy path", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
vi.mocked(auditLog).mockResolvedValue(undefined as never);
setEmptyDataMocks();
const res = await POST(
makeRequest({ days: 30, locale: "de" }, { "accept-language": "de-DE" }),
);
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toBe("application/pdf");
expect(res.headers.get("cache-control")).toBe("no-store");
const disposition = res.headers.get("content-disposition") ?? "";
expect(disposition).toMatch(/attachment;\s*filename="healthlog-report-/);
expect(disposition).toMatch(/\.pdf"/);
const buf = Buffer.from(await res.arrayBuffer());
expect(buf.byteLength).toBeGreaterThan(1024);
expect(buf.slice(0, 5).toString("utf8")).toBe("%PDF-");
expect(auditLog).toHaveBeenCalledWith(
"doctor-report.pdf.generate",
expect.objectContaining({
userId: "user-1",
details: expect.objectContaining({ days: 30, locale: "de" }),
}),
);
});
it("falls back to default days=90 when body omits it", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
vi.mocked(auditLog).mockResolvedValue(undefined as never);
setEmptyDataMocks();
const res = await POST(makeRequest({}));
expect(res.status).toBe(200);
expect(auditLog).toHaveBeenCalledWith(
"doctor-report.pdf.generate",
expect.objectContaining({
details: expect.objectContaining({ days: 90 }),
}),
);
});
it("uses Accept-Language to pick the locale when body omits it", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 3600_000,
});
vi.mocked(auditLog).mockResolvedValue(undefined as never);
setEmptyDataMocks();
const res = await POST(
makeRequest({ days: 7 }, { "accept-language": "en-US,en;q=0.9" }),
);
expect(res.status).toBe(200);
expect(auditLog).toHaveBeenCalledWith(
"doctor-report.pdf.generate",
expect.objectContaining({
details: expect.objectContaining({ locale: "en" }),
}),
);
});
});
+114
View File
@@ -0,0 +1,114 @@
import { NextRequest, NextResponse } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiError, getClientIp } from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import {
collectDoctorReportData,
normaliseDays,
} from "@/lib/doctor-report-data";
import { renderDoctorReportPdfBytes } from "@/lib/doctor-report-pdf-core";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
import { locales, type Locale } from "@/lib/i18n/config";
/**
* Server-rendered PDF doctor report.
*
* Mirrors `/api/doctor-report` (which returns JSON for client-side rendering)
* but produces the finished PDF bytes server-side. Useful on iOS / Safari where
* the client-side jsPDF download UX is unreliable.
*
* Auth, rate-limit (10/h), and audit-log policy match the JSON endpoint.
*/
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "doctor-report.pdf.generate" } });
const rl = await checkRateLimit(
`doctor-report:${user.id}`,
10,
60 * 60 * 1000,
);
if (!rl.allowed) {
return apiError("Maximum 10 reports per hour", 429);
}
// Body is optional — JSON is allowed but not required.
const body = await readOptionalJsonBody(request);
const days = normaliseDays(body?.days);
const locale = resolveLocale(
body?.locale,
request.headers.get("accept-language"),
);
const data = await collectDoctorReportData(user.id, days);
const { t } = getServerTranslator(locale);
const pdfBytes = renderDoctorReportPdfBytes(data, { t, locale });
await auditLog("doctor-report.pdf.generate", {
userId: user.id,
ipAddress: getClientIp(request),
details: { days, locale },
});
annotate({ meta: { report_days: days, locale, bytes: pdfBytes.byteLength } });
const filename = `healthlog-report-${new Date().toISOString().slice(0, 10)}.pdf`;
// NextResponse accepts a BodyInit; copy to a fresh ArrayBuffer to avoid
// SharedArrayBuffer typing surprises with Uint8Array.
const buffer = pdfBytes.slice().buffer;
return new NextResponse(buffer, {
status: 200,
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="${filename}"`,
"Content-Length": String(pdfBytes.byteLength),
"Cache-Control": "no-store",
},
});
});
interface PdfRequestBody {
days?: unknown;
locale?: unknown;
}
/**
* Best-effort JSON body parse. Returns `null` if the request had no body or
* a non-JSON content type — both are acceptable for this endpoint.
*/
async function readOptionalJsonBody(
request: NextRequest,
): Promise<PdfRequestBody | null> {
const contentType = request.headers.get("content-type") ?? "";
if (!contentType.includes("application/json")) return null;
try {
return (await request.json()) as PdfRequestBody;
} catch {
return null;
}
}
/**
* Resolve the active locale: explicit body override wins, then a parsed
* `Accept-Language`, then a hard fallback to German (the primary HealthLog
* locale for the medical context).
*/
function resolveLocale(
bodyLocale: unknown,
acceptLanguage: string | null,
): Locale {
if (
typeof bodyLocale === "string" &&
(locales as readonly string[]).includes(bodyLocale)
) {
return bodyLocale as Locale;
}
if (!acceptLanguage) return "de";
// parseLocaleFromAcceptLanguage returns "de" for de-* headers, otherwise
// "en" — matching the broader app's i18n behaviour for present headers.
return parseLocaleFromAcceptLanguage(acceptLanguage);
}
+18 -191
View File
@@ -1,16 +1,18 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiSuccess, apiError, getClientIp, safeJson } from "@/lib/api-response";
import {
apiSuccess,
apiError,
getClientIp,
safeJson,
} from "@/lib/api-response";
import { checkRateLimit } from "@/lib/rate-limit";
import { NextRequest } from "next/server";
import {
getEffectiveRange,
type ThresholdOverridesJson,
} from "@/lib/analytics/effective-range";
import { resolveGlucoseUnit, thresholdMetricForContext } from "@/lib/glucose";
import type { GlucoseContext } from "@/generated/prisma/client";
collectDoctorReportData,
normaliseDays,
} from "@/lib/doctor-report-data";
/**
* Collect data for doctor report PDF generation (client-side).
@@ -20,203 +22,28 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "doctor-report.generate" } });
const rl = await checkRateLimit(`doctor-report:${user.id}`, 10, 60 * 60 * 1000);
const rl = await checkRateLimit(
`doctor-report:${user.id}`,
10,
60 * 60 * 1000,
);
if (!rl.allowed) {
return apiError("Maximum 10 reports per hour", 429);
}
const { data: body, error: jsonError } = await safeJson(request);
if (jsonError) return jsonError;
const rawDays = (body as Record<string, unknown>)?.days;
const days =
typeof rawDays === "number" && Number.isInteger(rawDays) && rawDays >= 1 && rawDays <= 365
? rawDays
: 90;
const since = new Date();
since.setDate(since.getDate() - days);
const userId = user.id;
const [measurements, medications, intakeEvents, moodEntries, userProfile] =
await Promise.all([
prisma.measurement.findMany({
where: { userId, measuredAt: { gte: since } },
orderBy: { measuredAt: "asc" },
}),
prisma.medication.findMany({
where: { userId, active: true },
include: { schedules: true },
}),
prisma.medicationIntakeEvent.findMany({
where: { userId, scheduledFor: { gte: since } },
include: { medication: { select: { name: true } } },
orderBy: { scheduledFor: "asc" },
}),
prisma.moodEntry.findMany({
where: { userId, moodLoggedAt: { gte: since } },
orderBy: { moodLoggedAt: "asc" },
}),
prisma.user.findUnique({
where: { id: userId },
select: {
username: true,
dateOfBirth: true,
gender: true,
heightCm: true,
glucoseUnit: true,
thresholdsJson: true,
},
}),
]);
// Group measurements by type
const byType: Record<
string,
Array<{ value: number; measuredAt: string }>
> = {};
for (const m of measurements) {
if (!byType[m.type]) byType[m.type] = [];
byType[m.type].push({
value: m.value,
measuredAt: m.measuredAt.toISOString(),
});
}
// Calculate stats per type
const stats: Record<
string,
{ avg: number; min: number; max: number; count: number; latest: number }
> = {};
for (const [type, entries] of Object.entries(byType)) {
const values = entries.map((e) => e.value);
stats[type] = {
avg: values.reduce((a, b) => a + b, 0) / values.length,
min: Math.min(...values),
max: Math.max(...values),
count: values.length,
latest: values[values.length - 1],
};
}
// Medication compliance
const complianceByMed: Record<
string,
{ total: number; taken: number; skipped: number; missed: number }
> = {};
for (const event of intakeEvents) {
const name = event.medication.name;
if (!complianceByMed[name]) {
complianceByMed[name] = { total: 0, taken: 0, skipped: 0, missed: 0 };
}
complianceByMed[name].total++;
if (event.takenAt) {
complianceByMed[name].taken++;
} else if (event.skipped) {
complianceByMed[name].skipped++;
} else {
complianceByMed[name].missed++;
}
}
// Mood summary
const moodScores = moodEntries.map((e) => e.score);
const moodSummary =
moodScores.length > 0
? {
avg: moodScores.reduce((a, b) => a + b, 0) / moodScores.length,
min: Math.min(...moodScores),
max: Math.max(...moodScores),
count: moodScores.length,
distribution: {
1: moodScores.filter((s) => s === 1).length,
2: moodScores.filter((s) => s === 2).length,
3: moodScores.filter((s) => s === 3).length,
4: moodScores.filter((s) => s === 4).length,
5: moodScores.filter((s) => s === 5).length,
},
}
: null;
// BMI
const weightStats = stats.WEIGHT;
const bmi =
weightStats && userProfile?.heightCm
? weightStats.latest / (userProfile.heightCm / 100) ** 2
: null;
const days = normaliseDays((body as Record<string, unknown>)?.days);
const data = await collectDoctorReportData(user.id, days);
await auditLog("doctor-report.generate", {
userId,
userId: user.id,
ipAddress: getClientIp(request),
details: { days },
});
annotate({ meta: { report_days: days } });
// Per-context glucose stats (canonical mg/dL) and effective ranges.
const glucoseContexts: GlucoseContext[] = [
"FASTING",
"POSTPRANDIAL",
"RANDOM",
"BEDTIME",
];
const glucoseStats: Record<
string,
{ avg: number; min: number; max: number; count: number; latest: number }
> = {};
const glucoseRanges: Record<string, { min: number; max: number }> = {};
const glucoseRows = measurements.filter((m) => m.type === "BLOOD_GLUCOSE");
const overrides = (userProfile?.thresholdsJson ?? null) as ThresholdOverridesJson | null;
const profileForRange = {
heightCm: userProfile?.heightCm ?? null,
dateOfBirth: userProfile?.dateOfBirth ?? null,
gender: userProfile?.gender ?? null,
};
for (const ctx of glucoseContexts) {
const rows = glucoseRows.filter((m) => m.glucoseContext === ctx);
if (rows.length === 0) continue;
const values = rows.map((r) => r.value);
glucoseStats[ctx] = {
avg: values.reduce((a, b) => a + b, 0) / values.length,
min: Math.min(...values),
max: Math.max(...values),
count: values.length,
latest: values[values.length - 1],
};
const eff = getEffectiveRange(
thresholdMetricForContext(ctx),
profileForRange,
overrides,
);
if (eff.range) {
glucoseRanges[ctx] = { min: eff.range.greenMin, max: eff.range.greenMax };
}
}
return apiSuccess({
period: { days, since: since.toISOString() },
patient: {
username: userProfile?.username ?? null,
dateOfBirth: userProfile?.dateOfBirth ?? null,
gender: userProfile?.gender ?? null,
heightCm: userProfile?.heightCm ?? null,
},
measurements: byType,
stats,
glucoseStats,
glucoseRanges,
glucoseUnit: resolveGlucoseUnit(userProfile?.glucoseUnit ?? null),
bmi: bmi ? Math.round(bmi * 10) / 10 : null,
compliance: complianceByMed,
medications: medications.map((m) => ({
name: m.name,
dose: m.dose,
schedules: m.schedules.map((s) => ({
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label,
})),
})),
mood: moodSummary,
});
return apiSuccess(data);
});
@@ -0,0 +1,114 @@
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() },
medication: { findMany: vi.fn() },
passkey: { findMany: vi.fn() },
auditLog: { findMany: vi.fn() },
userAchievement: {
findMany: vi.fn(),
createMany: 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,
createdAt: new Date("2026-03-01T00:00:00.000Z"),
heightCm: null,
locale: "en",
},
};
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.measurement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medication.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.passkey.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.auditLog.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userAchievement.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.userAchievement.createMany).mockResolvedValue({
count: 0,
} as never);
});
describe("GET /api/gamification/achievements?format=ios", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await GET(
new NextRequest(
"http://localhost/api/gamification/achievements?format=ios",
),
);
expect(res.status).toBe(401);
});
it("returns a flat iOS-shaped achievement array", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(
new NextRequest(
"http://localhost/api/gamification/achievements?format=ios",
),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: Array<{
id: string;
key: string;
title: string;
description: string;
iconName: string;
unlocked: boolean;
unlockedAt: string | null;
progress: number;
}>;
};
expect(Array.isArray(body.data)).toBe(true);
expect(body.data.length).toBeGreaterThan(0);
const first = body.data[0];
expect(first.id).toEqual(expect.any(String));
expect(first.key).toEqual(expect.any(String));
expect(first.title).toEqual(expect.any(String));
expect(first.iconName).toEqual(expect.any(String));
expect(first.progress).toBeGreaterThanOrEqual(0);
expect(first.progress).toBeLessThanOrEqual(1);
});
it("falls back to the legacy wrapped shape when no format is given", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await GET(
new NextRequest("http://localhost/api/gamification/achievements"),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { achievements: unknown[]; summary: unknown };
};
expect(body.data.achievements).toBeDefined();
expect(body.data.summary).toBeDefined();
});
});
+40 -2
View File
@@ -15,6 +15,20 @@ import {
classifyPulse,
} from "@/lib/analytics/classifications";
import { classifyIntakeTiming } from "@/lib/analytics/compliance";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import { resolveServerLocale } from "@/lib/i18n/server-locale";
import type { NextRequest } from "next/server";
interface IosAchievement {
id: string;
key: string;
title: string;
description: string;
iconName: string;
unlocked: boolean;
unlockedAt: string | null;
progress: number;
}
export const dynamic = "force-dynamic";
@@ -431,9 +445,14 @@ function getCompliance80DaySeries(
return toDaySeries(greenDays);
}
export const GET = apiHandler(async () => {
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
annotate({ action: { name: "gamification.achievements" } });
const formatParam = request.nextUrl.searchParams.get("format");
const isIosFormat = formatParam === "ios";
annotate({
action: { name: "gamification.achievements" },
meta: { format: isIosFormat ? "ios" : "default" },
});
const now = new Date();
const userId = user.id;
@@ -742,5 +761,24 @@ export const GET = apiHandler(async () => {
});
}
if (isIosFormat) {
const locale = await resolveServerLocale({
request,
userLocale: user.locale,
});
const t = getServerTranslator(locale);
const ios: IosAchievement[] = result.achievements.map((a) => ({
id: a.id,
key: a.id,
title: t.t(a.titleKey),
description: t.t(a.descriptionKey),
iconName: a.icon,
unlocked: a.unlocked,
unlockedAt: a.completedAt,
progress: Math.max(0, Math.min(1, a.progressPercent / 100)),
}));
return apiSuccess(ios);
}
return apiSuccess(result);
});
@@ -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);
});
});
+196
View File
@@ -0,0 +1,196 @@
/**
* 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;
}> = [];
// Single batched query — bucket by medicationId in JS (fix N+1).
const activeMeds = medications.filter((m) => m.schedules.length > 0);
if (activeMeds.length > 0) {
const allEvents = await prisma.medicationIntakeEvent.findMany({
where: {
userId: user.id,
medicationId: { in: activeMeds.map((m) => m.id) },
scheduledFor: { gte: new Date(Date.now() - 30 * 86_400_000) },
},
select: { medicationId: true, takenAt: true, skipped: true, scheduledFor: true },
});
const eventsByMed = new Map<string, typeof allEvents>();
for (const e of allEvents) {
const arr = eventsByMed.get(e.medicationId) ?? [];
arr.push(e);
eventsByMed.set(e.medicationId, arr);
}
const cutoff7 = Date.now() - 7 * 86_400_000;
for (const med of activeMeds) {
const events = eventsByMed.get(med.id) ?? [];
const takenLast7 = events.filter(
(e) => e.takenAt !== null && !e.skipped && e.scheduledFor.getTime() >= cutoff7,
).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,
});
});
+13 -2
View File
@@ -9,6 +9,8 @@ import {
listMeasurementsSchema,
getUnitForType,
} from "@/lib/validations/measurement";
import { withIdempotency } from "@/lib/idempotency";
import { getSession } from "@/lib/auth/session";
import { NextRequest } from "next/server";
import type {
MeasurementType,
@@ -17,6 +19,11 @@ import type {
} from "@/generated/prisma/client";
import { Prisma } from "@/generated/prisma/client";
async function resolveUserIdForIdempotency(): Promise<string | null> {
const session = await getSession().catch(() => null);
return session?.user.id ?? null;
}
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
@@ -59,7 +66,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
});
export const POST = apiHandler(async (request: NextRequest) => {
export const POST = apiHandler(
withIdempotency<[NextRequest]>(postMeasurement, resolveUserIdForIdempotency),
);
async function postMeasurement(request: NextRequest) {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request);
@@ -159,4 +170,4 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
return apiSuccess(measurement, 201);
});
}
@@ -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 },
});
});
+16 -2
View File
@@ -7,11 +7,25 @@ import {
intakeSchema,
listIntakeEventsSchema,
} from "@/lib/validations/medication";
import { withIdempotency } from "@/lib/idempotency";
import { getSession } from "@/lib/auth/session";
import { NextRequest } from "next/server";
type RouteParams = { params: Promise<{ id: string }> };
export const POST = apiHandler(async (request: NextRequest, { params }: RouteParams) => {
async function resolveUserIdForIdempotency(): Promise<string | null> {
const session = await getSession().catch(() => null);
return session?.user.id ?? null;
}
export const POST = apiHandler(
withIdempotency<[NextRequest, RouteParams]>(
postIntake,
resolveUserIdForIdempotency,
),
);
async function postIntake(request: NextRequest, { params }: RouteParams) {
const { user } = await requireAuth();
const { id } = await params;
@@ -100,7 +114,7 @@ export const POST = apiHandler(async (request: NextRequest, { params }: RoutePar
});
return apiSuccess(event, 201);
});
}
export const GET = apiHandler(async (request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
@@ -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);
});
+13 -2
View File
@@ -10,6 +10,13 @@ import { NextRequest } from "next/server";
import { Prisma } from "@/generated/prisma/client";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { withIdempotency } from "@/lib/idempotency";
import { getSession } from "@/lib/auth/session";
async function resolveUserIdForIdempotency(): Promise<string | null> {
const session = await getSession().catch(() => null);
return session?.user.id ?? null;
}
function toBerlinDate(date: Date): string {
return new Intl.DateTimeFormat("sv-SE", {
@@ -73,7 +80,11 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
});
export const POST = apiHandler(async (request: NextRequest) => {
export const POST = apiHandler(
withIdempotency<[NextRequest]>(postMoodEntry, resolveUserIdForIdempotency),
);
async function postMoodEntry(request: NextRequest) {
const { user } = await requireAuth();
const { data: body, error: jsonError } = await safeJson(request);
@@ -119,4 +130,4 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
throw err;
}
});
}
@@ -0,0 +1,131 @@
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();
});
const callGet = GET as unknown as (req: NextRequest) => Promise<Response>;
function makeGetReq(): NextRequest {
return new NextRequest("http://localhost/api/user/profile");
}
describe("GET /api/user/profile", () => {
it("returns 401 when unauthenticated", async () => {
vi.mocked(getSession).mockResolvedValue(null);
const res = await callGet(makeGetReq());
expect(res.status).toBe(401);
});
it("returns flattened iOS-style fields", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue({
username: "marc",
displayName: "Marc B.",
email: "marc@example.com",
dateOfBirth: new Date("1985-03-12T00:00:00.000Z"),
gender: "MALE",
heightCm: 180,
locale: "de",
timezone: "Europe/Berlin",
} as never);
const res = await callGet(makeGetReq());
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
username: string;
displayName: string | null;
heightCm: number;
locale: string | null;
};
};
expect(body.data.username).toBe("marc");
expect(body.data.displayName).toBe("Marc B.");
expect(body.data.heightCm).toBe(180);
expect(body.data.locale).toBe("de");
});
});
describe("PATCH /api/user/profile", () => {
function req(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/user/profile", {
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({ displayName: "Test" }));
expect(res.status).toBe(401);
});
it("returns 422 for invalid heightCm", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
const res = await PATCH(req({ heightCm: 9999 }));
expect(res.status).toBe(422);
});
it("persists displayName + locale via shared helper", async () => {
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(null);
vi.mocked(prisma.user.update).mockResolvedValue({
id: "user-1",
username: "marc",
displayName: "Marc B.",
email: null,
role: "USER",
heightCm: null,
dateOfBirth: null,
gender: null,
timezone: "Europe/Berlin",
locale: "de",
} as never);
const res = await PATCH(req({ displayName: "Marc B.", locale: "de" }));
expect(res.status).toBe(200);
expect(prisma.user.update).toHaveBeenCalledWith(
expect.objectContaining({
where: { id: "user-1" },
data: expect.objectContaining({
displayName: "Marc B.",
locale: "de",
}),
}),
);
});
});
+71
View File
@@ -0,0 +1,71 @@
/**
* iOS-friendly user profile endpoint.
*
* GET → flattened profile fields (camelCase) for the native client.
* Aliased over the same data exposed by `/api/auth/me`.
* PATCH → delegates to the shared `applyProfileUpdate` helper used by
* `/api/auth/profile` PUT — same validation, same audit log.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, apiSuccess, getClientIp, safeJson } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { applyProfileUpdate } from "@/lib/auth/profile-update";
import { prisma } from "@/lib/db";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
annotate({ action: { name: "user.profile.get" } });
const dbUser = await prisma.user.findUnique({
where: { id: user.id },
select: {
username: true,
displayName: true,
email: true,
dateOfBirth: true,
gender: true,
heightCm: true,
locale: true,
timezone: true,
},
});
return apiSuccess({
username: dbUser?.username ?? user.username,
displayName: dbUser?.displayName ?? null,
email: dbUser?.email ?? null,
dateOfBirth: dbUser?.dateOfBirth?.toISOString() ?? null,
gender: dbUser?.gender ?? null,
heightCm: dbUser?.heightCm ?? null,
locale: dbUser?.locale ?? null,
timezone: dbUser?.timezone ?? "Europe/Berlin",
});
});
export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const { data: body, error } = await safeJson(request);
if (error) return error;
const result = await applyProfileUpdate(user.id, body, getClientIp(request));
if (!result.ok) {
return apiError(result.message, result.status);
}
annotate({ action: { name: "user.profile.update" } });
return apiSuccess({
username: result.user.username,
displayName: result.user.displayName,
email: result.user.email,
dateOfBirth: result.user.dateOfBirth?.toISOString() ?? null,
gender: result.user.gender,
heightCm: result.user.heightCm,
locale: result.user.locale,
timezone: result.user.timezone,
});
});
@@ -0,0 +1,163 @@
import { describe, it, expect } from "vitest";
import {
buildDoctorReportPdfDocument,
renderDoctorReportPdfBytes,
} from "../doctor-report-pdf-core";
import type { DoctorReportData } from "../doctor-report-data";
import { getServerTranslator } from "../i18n/server-translator";
const FIXED_NOW = new Date("2026-05-03T12:00:00.000Z");
function makeData(overrides?: Partial<DoctorReportData>): DoctorReportData {
return {
period: { days: 90, since: "2026-02-02T00:00:00.000Z" },
patient: {
username: "marc",
dateOfBirth: "1985-06-15T00:00:00.000Z",
gender: "MALE",
heightCm: 182,
},
measurements: {
WEIGHT: [
{ value: 80, measuredAt: "2026-02-10T08:00:00.000Z" },
{ value: 79.5, measuredAt: "2026-04-30T08:00:00.000Z" },
],
},
stats: {
WEIGHT: { avg: 79.75, min: 79.5, max: 80, count: 2, latest: 79.5 },
BLOOD_PRESSURE_SYS: {
avg: 122,
min: 118,
max: 128,
count: 5,
latest: 120,
},
BLOOD_PRESSURE_DIA: {
avg: 78,
min: 72,
max: 82,
count: 5,
latest: 78,
},
PULSE: { avg: 65, min: 58, max: 72, count: 5, latest: 64 },
},
glucoseStats: {
FASTING: { avg: 92, min: 85, max: 100, count: 4, latest: 90 },
},
glucoseRanges: { FASTING: { min: 70, max: 99 } },
glucoseUnit: "mg/dL",
bmi: 24.1,
compliance: {
Ramipril: { total: 90, taken: 85, skipped: 3, missed: 2 },
},
medications: [
{
name: "Ramipril",
dose: "5mg",
schedules: [
{ windowStart: "08:00", windowEnd: "09:00", label: "Morning" },
],
},
],
mood: {
avg: 3.6,
min: 2,
max: 5,
count: 30,
distribution: { 1: 0, 2: 3, 3: 9, 4: 14, 5: 4 },
},
...overrides,
};
}
describe("renderDoctorReportPdfBytes", () => {
it("returns a Uint8Array starting with the %PDF- header", () => {
const { t } = getServerTranslator("de");
const bytes = renderDoctorReportPdfBytes(makeData(), {
t,
locale: "de",
now: FIXED_NOW,
});
expect(bytes).toBeInstanceOf(Uint8Array);
const header = String.fromCharCode(...bytes.slice(0, 5));
expect(header).toBe("%PDF-");
});
it("produces a non-trivial document (> 1 KB)", () => {
const { t } = getServerTranslator("de");
const bytes = renderDoctorReportPdfBytes(makeData(), {
t,
locale: "de",
now: FIXED_NOW,
});
expect(bytes.byteLength).toBeGreaterThan(1024);
});
it("is structurally deterministic for the same input + fixed timestamp", () => {
// jsPDF embeds a per-invocation file-id in the trailer (random UUID-like
// hex). The visible content is fully determined by the inputs, so we
// assert byte-length equality + identical content sections instead of a
// strict bytewise compare.
const { t } = getServerTranslator("de");
const a = renderDoctorReportPdfBytes(makeData(), {
t,
locale: "de",
now: FIXED_NOW,
});
const b = renderDoctorReportPdfBytes(makeData(), {
t,
locale: "de",
now: FIXED_NOW,
});
expect(a.byteLength).toBe(b.byteLength);
// Compare the first 95% of the document — file-id and trailer differ.
const cmp = Math.floor(a.byteLength * 0.95);
expect(
Buffer.from(a.slice(0, cmp)).equals(Buffer.from(b.slice(0, cmp))),
).toBe(true);
});
it("renders both DE and EN locales without errors", () => {
const de = renderDoctorReportPdfBytes(makeData(), {
t: getServerTranslator("de").t,
locale: "de",
now: FIXED_NOW,
});
const en = renderDoctorReportPdfBytes(makeData(), {
t: getServerTranslator("en").t,
locale: "en",
now: FIXED_NOW,
});
expect(de.byteLength).toBeGreaterThan(1024);
expect(en.byteLength).toBeGreaterThan(1024);
});
it("handles empty stats / no mood without throwing", () => {
const empty = makeData({
stats: {},
glucoseStats: {},
glucoseRanges: {},
compliance: {},
mood: null,
bmi: null,
});
const bytes = renderDoctorReportPdfBytes(empty, {
t: getServerTranslator("de").t,
locale: "de",
now: FIXED_NOW,
});
const header = String.fromCharCode(...bytes.slice(0, 5));
expect(header).toBe("%PDF-");
});
});
describe("buildDoctorReportPdfDocument", () => {
it("returns a jsPDF doc with at least one page", () => {
const doc = buildDoctorReportPdfDocument(makeData(), {
t: getServerTranslator("de").t,
locale: "de",
now: FIXED_NOW,
});
expect(doc.getNumberOfPages()).toBeGreaterThanOrEqual(1);
});
});
+145
View File
@@ -0,0 +1,145 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest, NextResponse } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
idempotencyKey: {
findUnique: vi.fn(),
create: vi.fn(),
delete: vi.fn(),
},
},
}));
vi.mock("@/lib/logging/context", () => ({
annotate: vi.fn(),
}));
import { withIdempotency } from "../idempotency";
import { prisma } from "@/lib/db";
function makeRequest(
method: string,
headers: Record<string, string> = {},
): NextRequest {
return new NextRequest("http://localhost/api/example", {
method,
headers: { "content-type": "application/json", ...headers },
body: method === "GET" ? undefined : JSON.stringify({ ok: true }),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValue(null);
vi.mocked(prisma.idempotencyKey.create).mockResolvedValue({} as never);
});
describe("withIdempotency", () => {
it("passes through when no Idempotency-Key header is present", async () => {
const handler = vi.fn(async () =>
NextResponse.json({ data: { ok: true }, error: null }, { status: 201 }),
);
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
const res = await wrapped(makeRequest("POST"));
expect(res.status).toBe(201);
expect(handler).toHaveBeenCalledTimes(1);
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
expect(prisma.idempotencyKey.create).not.toHaveBeenCalled();
});
it("caches the response and replays it on the second call", async () => {
let callCount = 0;
const handler = vi.fn(async () => {
callCount += 1;
return NextResponse.json(
{ data: { result: callCount }, error: null },
{ status: 201 },
);
});
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
// First call: nothing cached → handler runs → response stored.
const req1 = makeRequest("POST", { "idempotency-key": "abc-12345678" });
const res1 = await wrapped(req1);
const body1 = await res1.json();
expect(res1.status).toBe(201);
expect(body1).toEqual({ data: { result: 1 }, error: null });
expect(handler).toHaveBeenCalledTimes(1);
expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1);
// Capture the persisted body for replay.
const persistedBody = (
vi.mocked(prisma.idempotencyKey.create).mock.calls[0][0] as {
data: { responseBody: string; responseStatus: number };
}
).data;
// Second call: cache hit returns persisted envelope.
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValueOnce({
id: "idem-1",
userId: "u-1",
key: "abc-12345678",
method: "POST",
path: "/api/example",
responseStatus: persistedBody.responseStatus,
responseBody: persistedBody.responseBody,
expiresAt: new Date(Date.now() + 86_400_000),
createdAt: new Date(),
} as never);
const req2 = makeRequest("POST", { "idempotency-key": "abc-12345678" });
const res2 = await wrapped(req2);
expect(res2.headers.get("X-Idempotent-Replay")).toBe("true");
expect(res2.status).toBe(201);
const body2 = await res2.json();
expect(body2).toEqual({ data: { result: 1 }, error: null });
expect(handler).toHaveBeenCalledTimes(1); // not called again
});
it("ignores expired cache rows and re-runs the handler", async () => {
const handler = vi.fn(async () =>
NextResponse.json({ data: "fresh", error: null }, { status: 200 }),
);
vi.mocked(prisma.idempotencyKey.findUnique).mockResolvedValueOnce({
id: "idem-old",
userId: "u-1",
key: "abc-12345678",
method: "POST",
path: "/api/example",
responseStatus: 200,
responseBody: '{"data":"stale","error":null}',
expiresAt: new Date(Date.now() - 1000),
createdAt: new Date(),
} as never);
vi.mocked(prisma.idempotencyKey.delete).mockResolvedValue({} as never);
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
const res = await wrapped(
makeRequest("POST", { "idempotency-key": "abc-12345678" }),
);
const body = await res.json();
expect(handler).toHaveBeenCalledTimes(1);
expect(body).toEqual({ data: "fresh", error: null });
expect(prisma.idempotencyKey.delete).toHaveBeenCalled();
});
it("ignores malformed Idempotency-Key headers", async () => {
const handler = vi.fn(async () =>
NextResponse.json({ data: "ok", error: null }),
);
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
await wrapped(makeRequest("POST", { "idempotency-key": "!!" }));
expect(handler).toHaveBeenCalledTimes(1);
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
});
it("does nothing for GET requests", async () => {
const handler = vi.fn(async () =>
NextResponse.json({ data: "ok", error: null }),
);
const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1");
await wrapped(makeRequest("GET", { "idempotency-key": "abc-12345678" }));
expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,218 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
// --- Mocks must be hoisted before importing the module under test. ---
vi.mock("@/lib/db", () => ({
prisma: {
apiToken: {
findUnique: vi.fn(),
update: vi.fn(),
},
user: {
findUnique: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({
getSession: vi.fn(),
}));
vi.mock("@/lib/auth/hmac", () => ({
hashToken: vi.fn(),
}));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({
emitIfSampled: vi.fn(),
}));
const headersGet = vi.fn<(name: string) => string | null>();
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: headersGet })),
cookies: vi.fn(async () => ({ get: () => undefined, set: () => {}, delete: () => {} })),
}));
// --- Imports use the mocked modules above. ---
import { requireAuth, HttpError } from "../api-handler";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { hashToken } from "@/lib/auth/hmac";
import { auditLog } from "@/lib/auth/audit";
const FAKE_HASH = "deadbeefcafef00d";
const RAW_TOKEN = "hlk_" + "a".repeat(64);
const FAKE_USER = {
id: "user-1",
role: "USER" as const,
username: "marc",
email: "marc@example.com",
};
function setBearerHeader(value: string | null): void {
headersGet.mockReset();
headersGet.mockImplementation((name: string) =>
name.toLowerCase() === "authorization" ? value : null,
);
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(hashToken).mockReturnValue(FAKE_HASH);
vi.mocked(prisma.apiToken.update).mockResolvedValue({} as never);
vi.mocked(auditLog).mockResolvedValue(undefined as never);
});
describe("requireAuth — Bearer token path", () => {
it("authenticates a valid Bearer token and returns AuthContext", async () => {
vi.mocked(getSession).mockResolvedValue(null);
setBearerHeader(`Bearer ${RAW_TOKEN}`);
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "token-1",
userId: "user-1",
permissions: ["medication:ingest"],
revoked: false,
expiresAt,
} as never);
vi.mocked(prisma.user.findUnique).mockResolvedValue(FAKE_USER as never);
const ctx = await requireAuth();
expect(hashToken).toHaveBeenCalledWith(RAW_TOKEN);
expect(ctx.user.id).toBe("user-1");
expect(ctx.session.id).toBe("token-1");
expect(ctx.session.expiresAt).toEqual(expiresAt);
// lastUsedAt refresh is fire-and-forget but should still have been triggered.
expect(prisma.apiToken.update).toHaveBeenCalledWith({
where: { id: "token-1" },
data: { lastUsedAt: expect.any(Date) },
});
expect(auditLog).toHaveBeenCalledWith(
"auth.bearer.success",
expect.objectContaining({ userId: "user-1" }),
);
});
it("rejects a revoked token with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
setBearerHeader(`Bearer ${RAW_TOKEN}`);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "token-2",
userId: "user-1",
permissions: [],
revoked: true,
expiresAt: null,
} as never);
await expect(requireAuth()).rejects.toMatchObject({
statusCode: 401,
} satisfies Partial<HttpError>);
expect(auditLog).toHaveBeenCalledWith(
"auth.bearer.failure",
expect.objectContaining({
details: expect.objectContaining({ reason: "revoked" }),
}),
);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("rejects an expired token with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
setBearerHeader(`Bearer ${RAW_TOKEN}`);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "token-3",
userId: "user-1",
permissions: [],
revoked: false,
expiresAt: new Date(Date.now() - 60_000),
} as never);
await expect(requireAuth()).rejects.toMatchObject({ statusCode: 401 });
expect(auditLog).toHaveBeenCalledWith(
"auth.bearer.failure",
expect.objectContaining({
details: expect.objectContaining({ reason: "expired" }),
}),
);
});
it("rejects an unknown token with 401", async () => {
vi.mocked(getSession).mockResolvedValue(null);
setBearerHeader(`Bearer ${RAW_TOKEN}`);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue(null);
await expect(requireAuth()).rejects.toMatchObject({ statusCode: 401 });
expect(auditLog).toHaveBeenCalledWith(
"auth.bearer.failure",
expect.objectContaining({
details: expect.objectContaining({ reason: "unknown_token" }),
}),
);
});
it("rejects a Bearer token missing the requested permission with 403", async () => {
vi.mocked(getSession).mockResolvedValue(null);
setBearerHeader(`Bearer ${RAW_TOKEN}`);
vi.mocked(prisma.apiToken.findUnique).mockResolvedValue({
id: "token-4",
userId: "user-1",
permissions: ["something:else"],
revoked: false,
expiresAt: null,
} as never);
await expect(requireAuth("medication:ingest")).rejects.toMatchObject({
statusCode: 403,
});
expect(auditLog).toHaveBeenCalledWith(
"auth.bearer.failure",
expect.objectContaining({
details: expect.objectContaining({
reason: "insufficient_permissions",
required: "medication:ingest",
}),
}),
);
expect(prisma.user.findUnique).not.toHaveBeenCalled();
});
it("returns 401 when neither cookie nor Bearer is present", async () => {
vi.mocked(getSession).mockResolvedValue(null);
setBearerHeader(null);
await expect(requireAuth()).rejects.toMatchObject({ statusCode: 401 });
expect(prisma.apiToken.findUnique).not.toHaveBeenCalled();
});
});
describe("requireAuth — cookie path remains intact", () => {
it("returns the session payload without consulting the Bearer header", async () => {
const cookieSession = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3600_000) },
user: { ...FAKE_USER, role: "USER" as const },
};
vi.mocked(getSession).mockResolvedValue(cookieSession as never);
// Even if a Bearer header is present, the cookie wins (existing behaviour).
setBearerHeader(`Bearer ${RAW_TOKEN}`);
const ctx = await requireAuth("medication:ingest");
expect(ctx).toEqual(cookieSession);
// Cookie short-circuits before any token logic runs.
expect(prisma.apiToken.findUnique).not.toHaveBeenCalled();
expect(hashToken).not.toHaveBeenCalled();
expect(auditLog).not.toHaveBeenCalled();
});
});
+173 -25
View File
@@ -1,8 +1,13 @@
import { NextRequest, NextResponse } from "next/server";
import { headers } from "next/headers";
import type { User } from "@/generated/prisma/client";
import { WideEventBuilder } from "./logging/event-builder";
import { eventStorage, getEvent } from "./logging/context";
import { emitIfSampled } from "./logging/transports";
import { getSession } from "./auth/session";
import { hashToken } from "./auth/hmac";
import { prisma } from "./db";
import { auditLog } from "./auth/audit";
/**
* Custom error class for HTTP errors with status codes.
@@ -98,17 +103,176 @@ export function apiHandler<T extends (...args: any[]) => Promise<Response>>(
}
/**
* Require an authenticated session. Throws HttpError(401) if not authenticated.
* Authenticated request context. Returned by both session-cookie and Bearer-token
* authentication paths. The `session.id` is the session-record id for cookie auth
* and the `ApiToken` id for bearer auth — callers must not assume the id refers
* to a Session row.
*/
export type AuthContext = {
session: { id: string; expiresAt: Date };
user: User;
};
/**
* Require an authenticated request. Throws HttpError(401) / HttpError(403) on failure.
*
* Auth precedence (cookie-first, never both):
* 1. Valid session cookie → cookie path (existing behaviour, requiredPermission ignored).
* 2. No cookie + `Authorization: Bearer hlk_<...>` → API token path.
* 3. Neither → 401.
*
* @param requiredPermission Optional permission scope. Only enforced for Bearer
* auth — cookie sessions always pass (full user access). When set and missing
* from `ApiToken.permissions`, throws HttpError(403).
*/
export async function requireAuth(
requiredPermission?: string,
): Promise<AuthContext> {
// 1. Session cookie path — unchanged.
const sessionData = await getSession();
if (sessionData) {
const evt = getEvent();
if (evt) {
evt.setAuth({
user_id: sessionData.user.id,
user_role: sessionData.user.role,
auth_method: "session",
});
}
return sessionData;
}
// 2. Bearer-token path.
// `headers()` is only valid inside a Next.js request scope. Outside one
// (e.g. during direct unit tests of legacy routes that pre-date Bearer auth)
// we treat the absence of a header context as "no Bearer present" and fall
// through to the unauthenticated case below.
let authHeader: string | null = null;
try {
const headerList = await headers();
authHeader = headerList.get("authorization");
} catch {
authHeader = null;
}
if (authHeader?.startsWith("Bearer ")) {
return await authenticateBearer(authHeader.slice(7), requiredPermission);
}
// 3. No credentials.
throw new HttpError(401, "Not authenticated");
}
/**
* Authenticate a raw Bearer token against `ApiToken`.
* Annotates the Wide Event with `auth_method: "api_key"` and writes audit-log
* entries for both success and failure.
*/
async function authenticateBearer(
rawToken: string,
requiredPermission: string | undefined,
): Promise<AuthContext> {
const tokenHashValue = hashToken(rawToken);
const apiToken = await prisma.apiToken.findUnique({
where: { tokenHash: tokenHashValue },
select: {
id: true,
userId: true,
permissions: true,
revoked: true,
expiresAt: true,
},
});
if (!apiToken) {
auditLog("auth.bearer.failure", {
details: { reason: "unknown_token" },
}).catch(() => {});
throw new HttpError(401, "Invalid token");
}
if (apiToken.revoked) {
auditLog("auth.bearer.failure", {
userId: apiToken.userId,
details: { reason: "revoked", tokenId: apiToken.id },
}).catch(() => {});
throw new HttpError(401, "Invalid token");
}
if (apiToken.expiresAt && apiToken.expiresAt <= new Date()) {
auditLog("auth.bearer.failure", {
userId: apiToken.userId,
details: { reason: "expired", tokenId: apiToken.id },
}).catch(() => {});
throw new HttpError(401, "Token expired");
}
if (
requiredPermission &&
!apiToken.permissions.includes(requiredPermission)
) {
auditLog("auth.bearer.failure", {
userId: apiToken.userId,
details: {
reason: "insufficient_permissions",
tokenId: apiToken.id,
required: requiredPermission,
},
}).catch(() => {});
throw new HttpError(403, "Insufficient permissions");
}
const user = await prisma.user.findUnique({
where: { id: apiToken.userId },
});
if (!user) {
auditLog("auth.bearer.failure", {
userId: apiToken.userId,
details: { reason: "user_missing", tokenId: apiToken.id },
}).catch(() => {});
throw new HttpError(401, "Invalid token");
}
// Fire-and-forget: refresh lastUsedAt without blocking the request.
prisma.apiToken
.update({
where: { id: apiToken.id },
data: { lastUsedAt: new Date() },
})
.catch(() => {});
auditLog("auth.bearer.success", {
userId: user.id,
details: { tokenId: apiToken.id },
}).catch(() => {});
const evt = getEvent();
if (evt) {
evt.setAuth({
user_id: user.id,
user_role: user.role,
auth_method: "bearer",
});
}
// Use the token expiry as the session expiry; fall back to a 30-day window if
// the token has no fixed expiry so the contract `{ expiresAt: Date }` holds.
const expiresAt =
apiToken.expiresAt ?? new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);
return {
session: { id: apiToken.id, expiresAt },
user,
};
}
/**
* Require an authenticated admin user. Throws HttpError(401) or HttpError(403).
* Cookie-only — Bearer tokens never elevate to admin (security boundary).
* Automatically annotates the Wide Event with auth context.
*/
export async function requireAuth(): Promise<{
session: { id: string; expiresAt: Date };
user: Awaited<ReturnType<typeof getSession>> extends infer R
? R extends { user: infer U }
? U
: never
: never;
}> {
export async function requireAdmin(): Promise<AuthContext> {
const sessionData = await getSession();
if (!sessionData) throw new HttpError(401, "Not authenticated");
@@ -121,22 +285,6 @@ export async function requireAuth(): Promise<{
});
}
return sessionData;
}
/**
* Require an authenticated admin user. Throws HttpError(401) or HttpError(403).
* Automatically annotates the Wide Event with auth context.
*/
export async function requireAdmin(): Promise<{
session: { id: string; expiresAt: Date };
user: Awaited<ReturnType<typeof getSession>> extends infer R
? R extends { user: infer U }
? U
: never
: never;
}> {
const sessionData = await requireAuth();
if (sessionData.user.role !== "ADMIN") {
throw new HttpError(403, "Admin access required");
}
+75
View File
@@ -0,0 +1,75 @@
/**
* Helper for issuing an `ApiToken` for a user — currently used by the
* native-client auto-login flow (login + passkey login-verify) so the iOS
* app can talk to bearer-protected routes without juggling the session
* cookie. The raw `hlk_<hex>` value is returned exactly once.
*/
import { randomBytes } from "node:crypto";
import { prisma } from "@/lib/db";
import { hashToken } from "@/lib/auth/hmac";
export interface IssuedToken {
token: string;
expiresAt: Date;
tokenId: string;
name: string;
}
export interface IssueTokenOptions {
userId: string;
/** Token row name (also surfaced to the user under /api/tokens). */
name: string;
/** Default permissions. `["*"]` = unrestricted (matches cookie scope). */
permissions?: string[];
/** Lifetime in days. */
expiresInDays?: number;
}
/**
* Generate a new `hlk_<64 hex>` token, store its hash, and return the raw
* value to the caller. The plaintext token is never persisted and must
* never be logged.
*/
export async function issueApiToken(
opts: IssueTokenOptions,
): Promise<IssuedToken> {
const rawToken = `hlk_${randomBytes(32).toString("hex")}`;
const tokenHashValue = hashToken(rawToken);
const expiresAt = new Date(
Date.now() + (opts.expiresInDays ?? 90) * 24 * 60 * 60 * 1000,
);
const created = await prisma.apiToken.create({
data: {
userId: opts.userId,
name: opts.name,
tokenHash: tokenHashValue,
permissions: opts.permissions ?? ["*"],
expiresAt,
},
select: { id: true },
});
return {
token: rawToken,
expiresAt,
tokenId: created.id,
name: opts.name,
};
}
/**
* Detect whether the request is from a native client and should receive a
* bearer token in the login response. Two opt-in signals:
* - `X-Client-Type: native`
* - `User-Agent: HealthLog-iOS/...`
*/
export function isNativeClientRequest(headers: Headers): boolean {
const xClient = headers.get("x-client-type");
if (xClient && xClient.toLowerCase() === "native") return true;
const ua = headers.get("user-agent") ?? "";
if (ua.startsWith("HealthLog-iOS")) return true;
return false;
}
+114
View File
@@ -0,0 +1,114 @@
/**
* Shared profile-update logic. The web client hits `/api/auth/profile` PUT
* and the iOS client hits `/api/user/profile` PATCH — both funnel through
* `applyProfileUpdate` so we don't fork the validation/audit path.
*/
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { profileSchema } from "@/lib/validations/auth";
import { z } from "zod/v4";
const extendedProfileSchema = profileSchema.extend({
displayName: z.string().min(1).max(80).nullable().optional(),
locale: z.enum(["de", "en"]).nullable().optional(),
timezone: z.string().min(1).max(64).optional(),
});
export type ApplyProfileInput = z.infer<typeof extendedProfileSchema>;
export interface ApplyProfileResult {
ok: true;
user: {
id: string;
username: string;
displayName: string | null;
email: string | null;
role: string;
heightCm: number | null;
dateOfBirth: Date | null;
gender: string | null;
timezone: string;
locale: string | null;
};
}
export interface ApplyProfileError {
ok: false;
status: number;
message: string;
}
/**
* Validate and persist profile updates for `userId`. Returns either an
* `ApplyProfileResult` on success or `ApplyProfileError` with a status
* code so callers can shape the wire response themselves.
*/
export async function applyProfileUpdate(
userId: string,
body: unknown,
ipAddress?: string | null,
): Promise<ApplyProfileResult | ApplyProfileError> {
const parsed = extendedProfileSchema.safeParse(body);
if (!parsed.success) {
return {
ok: false,
status: 422,
message: parsed.error.issues[0]?.message ?? "Validation error",
};
}
const data = parsed.data;
const normalizedEmail = data.email ? data.email.trim().toLowerCase() : null;
if (data.email !== undefined && normalizedEmail) {
const existing = await prisma.user.findUnique({
where: { email: normalizedEmail },
select: { id: true },
});
if (existing && existing.id !== userId) {
return { ok: false, status: 409, message: "Email already in use" };
}
}
const updates: Record<string, unknown> = {};
if (data.email !== undefined) updates.email = normalizedEmail;
if (data.heightCm !== undefined) updates.heightCm = data.heightCm ?? null;
if (data.dateOfBirth !== undefined) {
updates.dateOfBirth = data.dateOfBirth ? new Date(data.dateOfBirth) : null;
}
if (data.gender !== undefined) updates.gender = data.gender;
if (data.displayName !== undefined) {
updates.displayName =
data.displayName === null || data.displayName === ""
? null
: data.displayName.trim();
}
if (data.locale !== undefined) updates.locale = data.locale;
if (data.timezone !== undefined) updates.timezone = data.timezone;
const updatedUser = await prisma.user.update({
where: { id: userId },
data: updates,
});
await auditLog("profile.update", {
userId: updatedUser.id,
ipAddress: ipAddress ?? null,
});
return {
ok: true,
user: {
id: updatedUser.id,
username: updatedUser.username,
displayName: updatedUser.displayName,
email: updatedUser.email,
role: updatedUser.role,
heightCm: updatedUser.heightCm,
dateOfBirth: updatedUser.dateOfBirth,
gender: updatedUser.gender,
timezone: updatedUser.timezone,
locale: updatedUser.locale,
},
};
}
+269
View File
@@ -0,0 +1,269 @@
/**
* Server-side aggregator for doctor-report data.
*
* Single source of truth for the aggregated payload consumed by both
* `/api/doctor-report` (JSON, client renders PDF) and
* `/api/doctor-report/pdf` (server-rendered PDF). Keeps the two endpoints
* structurally identical so visual parity between client- and server-rendered
* PDFs is guaranteed by construction, not by drift-prone copy-paste.
*/
import { prisma } from "@/lib/db";
import {
getEffectiveRange,
type ThresholdOverridesJson,
} from "@/lib/analytics/effective-range";
import {
resolveGlucoseUnit,
thresholdMetricForContext,
type GlucoseUnit,
} from "@/lib/glucose";
import type { GlucoseContext } from "@/generated/prisma/client";
export interface DoctorReportStats {
avg: number;
min: number;
max: number;
count: number;
latest: number;
}
export interface DoctorReportCompliance {
total: number;
taken: number;
skipped: number;
missed: number;
}
export interface DoctorReportMood {
avg: number;
min: number;
max: number;
count: number;
distribution: Record<number, number>;
}
export interface DoctorReportData {
period: { days: number; since: string };
patient: {
username: string | null;
dateOfBirth: string | null;
gender: string | null;
heightCm: number | null;
};
measurements: Record<string, Array<{ value: number; measuredAt: string }>>;
stats: Record<string, DoctorReportStats>;
glucoseStats: Record<string, DoctorReportStats>;
glucoseRanges: Record<string, { min: number; max: number }>;
glucoseUnit: GlucoseUnit;
bmi: number | null;
compliance: Record<string, DoctorReportCompliance>;
medications: Array<{
name: string;
dose: string;
schedules: Array<{
windowStart: string;
windowEnd: string;
label: string | null;
}>;
}>;
mood: DoctorReportMood | null;
}
const GLUCOSE_CONTEXTS: GlucoseContext[] = [
"FASTING",
"POSTPRANDIAL",
"RANDOM",
"BEDTIME",
];
/**
* Validate and normalise the requested reporting window.
* Accepts an unknown value (typically `body.days`); falls back to 90.
*/
export function normaliseDays(value: unknown, fallback = 90): number {
if (
typeof value === "number" &&
Number.isInteger(value) &&
value >= 1 &&
value <= 365
) {
return value;
}
return fallback;
}
/**
* Aggregate the doctor-report payload for a user over the last `days` days.
* Pure data assembly — no auth, no rate-limit, no audit. Idempotent.
*/
export async function collectDoctorReportData(
userId: string,
days: number,
): Promise<DoctorReportData> {
const since = new Date();
since.setDate(since.getDate() - days);
const [measurements, medications, intakeEvents, moodEntries, userProfile] =
await Promise.all([
prisma.measurement.findMany({
where: { userId, measuredAt: { gte: since } },
orderBy: { measuredAt: "asc" },
}),
prisma.medication.findMany({
where: { userId, active: true },
include: { schedules: true },
}),
prisma.medicationIntakeEvent.findMany({
where: { userId, scheduledFor: { gte: since } },
include: { medication: { select: { name: true } } },
orderBy: { scheduledFor: "asc" },
}),
prisma.moodEntry.findMany({
where: { userId, moodLoggedAt: { gte: since } },
orderBy: { moodLoggedAt: "asc" },
}),
prisma.user.findUnique({
where: { id: userId },
select: {
username: true,
dateOfBirth: true,
gender: true,
heightCm: true,
glucoseUnit: true,
thresholdsJson: true,
},
}),
]);
// Group measurements by type.
const byType: Record<
string,
Array<{ value: number; measuredAt: string }>
> = {};
for (const m of measurements) {
if (!byType[m.type]) byType[m.type] = [];
byType[m.type].push({
value: m.value,
measuredAt: m.measuredAt.toISOString(),
});
}
// Per-type stats.
const stats: Record<string, DoctorReportStats> = {};
for (const [type, entries] of Object.entries(byType)) {
const values = entries.map((e) => e.value);
stats[type] = {
avg: values.reduce((a, b) => a + b, 0) / values.length,
min: Math.min(...values),
max: Math.max(...values),
count: values.length,
latest: values[values.length - 1],
};
}
// Medication compliance.
const compliance: Record<string, DoctorReportCompliance> = {};
for (const event of intakeEvents) {
const name = event.medication.name;
if (!compliance[name]) {
compliance[name] = { total: 0, taken: 0, skipped: 0, missed: 0 };
}
compliance[name].total++;
if (event.takenAt) {
compliance[name].taken++;
} else if (event.skipped) {
compliance[name].skipped++;
} else {
compliance[name].missed++;
}
}
// Mood summary.
const moodScores = moodEntries.map((e) => e.score);
const mood: DoctorReportMood | null =
moodScores.length > 0
? {
avg: moodScores.reduce((a, b) => a + b, 0) / moodScores.length,
min: Math.min(...moodScores),
max: Math.max(...moodScores),
count: moodScores.length,
distribution: {
1: moodScores.filter((s) => s === 1).length,
2: moodScores.filter((s) => s === 2).length,
3: moodScores.filter((s) => s === 3).length,
4: moodScores.filter((s) => s === 4).length,
5: moodScores.filter((s) => s === 5).length,
},
}
: null;
// BMI from latest weight + profile height.
const weightStats = stats.WEIGHT;
const bmiRaw =
weightStats && userProfile?.heightCm
? weightStats.latest / (userProfile.heightCm / 100) ** 2
: null;
const bmi = bmiRaw !== null ? Math.round(bmiRaw * 10) / 10 : null;
// Per-context glucose stats + effective ranges (canonical mg/dL).
const glucoseStats: Record<string, DoctorReportStats> = {};
const glucoseRanges: Record<string, { min: number; max: number }> = {};
const glucoseRows = measurements.filter((m) => m.type === "BLOOD_GLUCOSE");
const overrides = (userProfile?.thresholdsJson ??
null) as ThresholdOverridesJson | null;
const profileForRange = {
heightCm: userProfile?.heightCm ?? null,
dateOfBirth: userProfile?.dateOfBirth ?? null,
gender: userProfile?.gender ?? null,
};
for (const ctx of GLUCOSE_CONTEXTS) {
const rows = glucoseRows.filter((m) => m.glucoseContext === ctx);
if (rows.length === 0) continue;
const values = rows.map((r) => r.value);
glucoseStats[ctx] = {
avg: values.reduce((a, b) => a + b, 0) / values.length,
min: Math.min(...values),
max: Math.max(...values),
count: values.length,
latest: values[values.length - 1],
};
const eff = getEffectiveRange(
thresholdMetricForContext(ctx),
profileForRange,
overrides,
);
if (eff.range) {
glucoseRanges[ctx] = { min: eff.range.greenMin, max: eff.range.greenMax };
}
}
return {
period: { days, since: since.toISOString() },
patient: {
username: userProfile?.username ?? null,
dateOfBirth: userProfile?.dateOfBirth
? userProfile.dateOfBirth.toISOString()
: null,
gender: userProfile?.gender ?? null,
heightCm: userProfile?.heightCm ?? null,
},
measurements: byType,
stats,
glucoseStats,
glucoseRanges,
glucoseUnit: resolveGlucoseUnit(userProfile?.glucoseUnit ?? null),
bmi,
compliance,
medications: medications.map((m) => ({
name: m.name,
dose: m.dose,
schedules: m.schedules.map((s) => ({
windowStart: s.windowStart,
windowEnd: s.windowEnd,
label: s.label,
})),
})),
mood,
};
}
+505
View File
@@ -0,0 +1,505 @@
/**
* Isomorphic PDF renderer for the doctor report.
*
* Runs identically in the browser (settings page download) and in Node
* (server-rendered `/api/doctor-report/pdf` endpoint). All locale-sensitive
* strings and number/date formatting are driven by the injected
* `{ t, locale }` so DE and EN output match the user's UI language.
*
* jsPDF is fully isomorphic: `doc.output("arraybuffer")` returns a valid
* `%PDF-` byte stream in both environments.
*/
import { jsPDF } from "jspdf";
import autoTable from "jspdf-autotable";
import { makeFormatters } from "./format-locale";
import type { Locale } from "./i18n/config";
import { convertGlucose, resolveGlucoseUnit } from "./glucose";
import type { DoctorReportData } from "./doctor-report-data";
type T = (key: string, params?: Record<string, string | number>) => string;
export interface DoctorReportRenderOptions {
t: T;
locale: Locale;
/**
* Optional fixed timestamp for "createdOn"/footer. Useful for deterministic
* tests; defaults to `new Date()`.
*/
now?: Date;
}
const TYPE_LABEL_KEYS: Record<string, string> = {
WEIGHT: "doctorReport.typeWeight",
BLOOD_PRESSURE_SYS: "doctorReport.typeBpSys",
BLOOD_PRESSURE_DIA: "doctorReport.typeBpDia",
PULSE: "doctorReport.typePulse",
BODY_FAT: "doctorReport.typeBodyFat",
SLEEP_DURATION: "doctorReport.typeSleep",
ACTIVITY_STEPS: "doctorReport.typeSteps",
};
const TYPE_UNIT_KEYS: Record<string, string | null> = {
WEIGHT: "kg",
BLOOD_PRESSURE_SYS: "mmHg",
BLOOD_PRESSURE_DIA: "mmHg",
PULSE: "bpm",
BODY_FAT: "%",
SLEEP_DURATION: "h",
ACTIVITY_STEPS: null, // translated unit
};
const MOOD_LABEL_KEYS: Record<number, string> = {
1: "doctorReport.moodAwful",
2: "doctorReport.moodBad",
3: "doctorReport.moodNeutral",
4: "doctorReport.moodGood",
5: "doctorReport.moodGreat",
};
const GLUCOSE_LABEL_KEYS = {
FASTING: "doctorReport.typeGlucoseFasting",
POSTPRANDIAL: "doctorReport.typeGlucosePostprandial",
RANDOM: "doctorReport.typeGlucoseRandom",
BEDTIME: "doctorReport.typeGlucoseBedtime",
} as const;
const DEFAULT_GLUCOSE_RANGES = {
FASTING: { min: 70, max: 99 },
POSTPRANDIAL: { min: 70, max: 140 },
RANDOM: { min: 70, max: 140 },
BEDTIME: { min: 90, max: 150 },
} as const;
const GLUCOSE_CONTEXTS: Array<keyof typeof GLUCOSE_LABEL_KEYS> = [
"FASTING",
"POSTPRANDIAL",
"RANDOM",
"BEDTIME",
];
function getBmiClassificationKey(bmi: number): string {
if (bmi < 18.5) return "doctorReport.bmiUnderweight";
if (bmi < 25) return "doctorReport.bmiNormal";
if (bmi < 30) return "doctorReport.bmiOverweight";
if (bmi < 35) return "doctorReport.bmiObeseGrade1";
if (bmi < 40) return "doctorReport.bmiObeseGrade2";
return "doctorReport.bmiObeseGrade3";
}
function getBpClassificationKey(sys: number, dia: number): string {
if (sys < 120 && dia < 80) return "doctorReport.bpOptimal";
if (sys < 130 && dia < 85) return "doctorReport.bpNormal";
if (sys < 140 && dia < 90) return "doctorReport.bpHighNormal";
if (sys < 160 && dia < 100) return "doctorReport.bpHypertensionGrade1";
if (sys < 180 && dia < 110) return "doctorReport.bpHypertensionGrade2";
return "doctorReport.bpHypertensionGrade3";
}
/**
* Render the doctor report into a `jsPDF` instance.
*
* Used internally by both the client wrapper (which calls `.save()` on the
* returned doc) and the server renderer (which calls `.output("arraybuffer")`
* via `renderDoctorReportPdfBytes`).
*/
export function buildDoctorReportPdfDocument(
data: DoctorReportData,
options: DoctorReportRenderOptions,
): jsPDF {
const { t, locale, now = new Date() } = options;
const formatters = makeFormatters(locale);
const num = (value: number, decimals = 1) =>
formatters.number(value, decimals);
const fmtDate = (iso: string) => formatters.date(iso);
const unitFor = (type: string): string => {
const staticUnit = TYPE_UNIT_KEYS[type];
if (staticUnit === null && type === "ACTIVITY_STEPS") {
return t("doctorReport.unitSteps");
}
return staticUnit ?? "";
};
const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" });
const pageWidth = doc.internal.pageSize.getWidth();
const margin = 20;
let y = margin;
doc.setFontSize(22);
doc.setFont("helvetica", "bold");
doc.setTextColor(30, 30, 30);
doc.text(t("doctorReport.title"), margin, y);
y += 8;
doc.setFontSize(10);
doc.setFont("helvetica", "normal");
doc.setTextColor(100, 100, 100);
doc.text(t("doctorReport.subtitle"), margin, y);
y += 6;
doc.setDrawColor(200, 200, 200);
doc.setLineWidth(0.5);
doc.line(margin, y, pageWidth - margin, y);
y += 8;
doc.setFontSize(9);
doc.setTextColor(80, 80, 80);
const patientInfo: string[] = [];
if (data.patient.username) {
patientInfo.push(`${t("doctorReport.patient")}: ${data.patient.username}`);
}
if (data.patient.dateOfBirth) {
patientInfo.push(
`${t("doctorReport.dateOfBirth")}: ${fmtDate(data.patient.dateOfBirth)}`,
);
}
if (data.patient.gender) {
const genderKeys: Record<string, string> = {
MALE: "doctorReport.genderMale",
FEMALE: "doctorReport.genderFemale",
};
const genderKey =
genderKeys[data.patient.gender] ?? "doctorReport.genderOther";
patientInfo.push(`${t("doctorReport.gender")}: ${t(genderKey)}`);
}
if (data.patient.heightCm) {
patientInfo.push(
`${t("doctorReport.height")}: ${data.patient.heightCm} cm`,
);
}
patientInfo.push(
`${t("doctorReport.period")}: ${fmtDate(data.period.since)} — ${fmtDate(now.toISOString())}`,
);
patientInfo.push(
`${t("doctorReport.createdOn")}: ${fmtDate(now.toISOString())}`,
);
for (const line of patientInfo) {
doc.text(line, margin, y);
y += 4.5;
}
y += 4;
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.setTextColor(30, 30, 30);
doc.text(t("doctorReport.vitalsTitle"), margin, y);
y += 6;
const vitalRows: string[][] = [];
const vitalTypes = [
"WEIGHT",
"BLOOD_PRESSURE_SYS",
"BLOOD_PRESSURE_DIA",
"PULSE",
"BODY_FAT",
];
for (const type of vitalTypes) {
const s = data.stats[type];
if (!s) continue;
const unit = unitFor(type);
vitalRows.push([
t(TYPE_LABEL_KEYS[type] ?? ""),
`${num(s.latest)} ${unit}`.trim(),
`${num(s.avg)} ${unit}`.trim(),
num(s.min),
num(s.max),
String(s.count),
]);
}
// Per-context glucose rows. Values stored canonically in mg/dL — convert to
// the user's display unit. Reference ranges come from the server-side
// `getEffectiveRange()` (already baked into `data.glucoseRanges`).
const glucoseUnit = resolveGlucoseUnit(data.glucoseUnit ?? null);
for (const ctx of GLUCOSE_CONTEXTS) {
const s = data.glucoseStats?.[ctx];
if (!s) continue;
const conv = (v: number) => convertGlucose(v, glucoseUnit);
vitalRows.push([
t(GLUCOSE_LABEL_KEYS[ctx]),
`${num(conv(s.latest))} ${glucoseUnit}`.trim(),
`${num(conv(s.avg))} ${glucoseUnit}`.trim(),
num(conv(s.min)),
num(conv(s.max)),
String(s.count),
]);
}
if (vitalRows.length > 0) {
autoTable(doc, {
startY: y,
head: [
[
t("doctorReport.colParameter"),
t("doctorReport.colCurrent"),
t("doctorReport.colAvgPeriod"),
t("doctorReport.colMin"),
t("doctorReport.colMax"),
t("doctorReport.colN"),
],
],
body: vitalRows,
theme: "grid",
styles: {
fontSize: 9,
cellPadding: 3,
textColor: [30, 30, 30],
lineColor: [200, 200, 200],
lineWidth: 0.3,
},
headStyles: {
fillColor: [245, 245, 245],
textColor: [30, 30, 30],
fontStyle: "bold",
},
alternateRowStyles: { fillColor: [252, 252, 252] },
margin: { left: margin, right: margin },
});
y =
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
.finalY + 8;
}
const sysStat = data.stats.BLOOD_PRESSURE_SYS;
const diaStat = data.stats.BLOOD_PRESSURE_DIA;
if (sysStat && diaStat) {
doc.setFontSize(12);
doc.setFont("helvetica", "bold");
doc.text(t("doctorReport.bpClassificationTitle"), margin, y);
y += 5;
doc.setFontSize(9);
doc.setFont("helvetica", "normal");
const bpClass = t(getBpClassificationKey(sysStat.avg, diaStat.avg));
doc.text(
`${t("doctorReport.avgBp")}: ${num(sysStat.avg, 0)}/${num(diaStat.avg, 0)} mmHg — ${t("doctorReport.classification")}: ${bpClass}`,
margin,
y,
);
y += 8;
}
if (data.bmi) {
doc.setFontSize(12);
doc.setFont("helvetica", "bold");
doc.text(t("doctorReport.bmiTitle"), margin, y);
y += 5;
doc.setFontSize(9);
doc.setFont("helvetica", "normal");
doc.text(
t("doctorReport.bmiRow", {
bmi: num(data.bmi, 1),
class: t(getBmiClassificationKey(data.bmi)),
}),
margin,
y,
);
y += 8;
}
// Glucose classification — one line per logged context.
const loggedGlucose = GLUCOSE_CONTEXTS.filter(
(ctx) => data.glucoseStats?.[ctx],
);
if (loggedGlucose.length > 0) {
if (y > 240) {
doc.addPage();
y = margin;
}
doc.setFontSize(12);
doc.setFont("helvetica", "bold");
doc.text(t("doctorReport.glucoseClassificationTitle"), margin, y);
y += 5;
doc.setFontSize(9);
doc.setFont("helvetica", "normal");
for (const ctx of loggedGlucose) {
const s = data.glucoseStats[ctx];
if (!s) continue;
const range = data.glucoseRanges?.[ctx] ?? DEFAULT_GLUCOSE_RANGES[ctx];
const conv = (v: number) => convertGlucose(v, glucoseUnit);
const inRange = s.avg >= range.min && s.avg <= range.max;
const classKey = inRange
? "doctorReport.glucoseInTarget"
: s.avg < range.min
? "doctorReport.glucoseBelowTarget"
: "doctorReport.glucoseAboveTarget";
doc.text(
t("doctorReport.glucoseRow", {
label: t(GLUCOSE_LABEL_KEYS[ctx]),
avg: num(conv(s.avg), 1),
unit: glucoseUnit,
rangeMin: num(conv(range.min)),
rangeMax: num(conv(range.max)),
class: t(classKey),
}),
margin,
y,
);
y += 5;
}
y += 3;
}
const complianceEntries = Object.entries(data.compliance);
if (complianceEntries.length > 0) {
if (y > 240) {
doc.addPage();
y = margin;
}
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.text(t("doctorReport.complianceTitle"), margin, y);
y += 6;
const compRows = complianceEntries.map(([name, c]) => {
const rate = c.total > 0 ? `${num((c.taken / c.total) * 100, 1)}%` : "—";
return [
name,
String(c.taken),
String(c.skipped),
String(c.missed),
String(c.total),
rate,
];
});
autoTable(doc, {
startY: y,
head: [
[
t("doctorReport.colMedication"),
t("doctorReport.colTaken"),
t("doctorReport.colSkipped"),
t("doctorReport.colMissed"),
t("doctorReport.colTotal"),
t("doctorReport.colComplianceRate"),
],
],
body: compRows,
theme: "grid",
styles: {
fontSize: 9,
cellPadding: 3,
textColor: [30, 30, 30],
lineColor: [200, 200, 200],
lineWidth: 0.3,
},
headStyles: {
fillColor: [245, 245, 245],
textColor: [30, 30, 30],
fontStyle: "bold",
},
alternateRowStyles: { fillColor: [252, 252, 252] },
margin: { left: margin, right: margin },
});
y =
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
.finalY + 8;
}
if (data.mood) {
if (y > 240) {
doc.addPage();
y = margin;
}
doc.setFontSize(14);
doc.setFont("helvetica", "bold");
doc.text(t("doctorReport.moodTitle"), margin, y);
y += 6;
doc.setFontSize(9);
doc.setFont("helvetica", "normal");
doc.text(
t("doctorReport.moodSummary", {
avg: num(data.mood.avg, 1),
count: data.mood.count,
min: num(data.mood.min, 0),
max: num(data.mood.max, 0),
}),
margin,
y,
);
y += 6;
const distRows = Object.entries(data.mood.distribution).map(
([score, count]) => [
t(MOOD_LABEL_KEYS[Number(score)] ?? "doctorReport.moodNeutral"),
String(count),
data.mood && data.mood.count > 0
? `${num((count / data.mood.count) * 100, 1)}%`
: "0%",
],
);
autoTable(doc, {
startY: y,
head: [
[
t("doctorReport.colMood"),
t("doctorReport.colCount"),
t("doctorReport.colShare"),
],
],
body: distRows,
theme: "grid",
styles: {
fontSize: 9,
cellPadding: 3,
textColor: [30, 30, 30],
lineColor: [200, 200, 200],
lineWidth: 0.3,
},
headStyles: {
fillColor: [245, 245, 245],
textColor: [30, 30, 30],
fontStyle: "bold",
},
margin: { left: margin, right: margin },
});
y =
(doc as jsPDF & { lastAutoTable: { finalY: number } }).lastAutoTable
.finalY + 8;
}
const addFooter = (pageDoc: jsPDF) => {
const pageHeight = pageDoc.internal.pageSize.getHeight();
pageDoc.setFontSize(7);
pageDoc.setFont("helvetica", "italic");
pageDoc.setTextColor(140, 140, 140);
pageDoc.text(t("doctorReport.footerDisclaimer1"), margin, pageHeight - 14);
pageDoc.text(t("doctorReport.footerDisclaimer2"), margin, pageHeight - 10);
pageDoc.text(
t("doctorReport.footerSource", {
timestamp: formatters.dateTime(now),
}),
margin,
pageHeight - 6,
);
};
const totalPages = doc.getNumberOfPages();
for (let i = 1; i <= totalPages; i++) {
doc.setPage(i);
addFooter(doc);
}
return doc;
}
/**
* Render the doctor report and return the PDF as a `Uint8Array`.
* Isomorphic — works in browser and Node.
*/
export function renderDoctorReportPdfBytes(
data: DoctorReportData,
options: DoctorReportRenderOptions,
): Uint8Array {
const doc = buildDoctorReportPdfDocument(data, options);
const ab = doc.output("arraybuffer");
return new Uint8Array(ab);
}
+173
View File
@@ -0,0 +1,173 @@
/**
* Idempotency-Key support for write endpoints (POST/PUT/PATCH/DELETE).
*
* Mobile clients send `Idempotency-Key: <uuid>`. The first request runs
* the handler normally and the response (status + body) is cached. Any
* retry within the TTL (24h) for the same `(userId, key, method, path)`
* tuple returns the cached envelope as a 200 (carrying the original
* status inside the JSON if needed) — no second side-effect.
*/
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { annotate } from "@/lib/logging/context";
const TTL_MS = 24 * 60 * 60 * 1000;
const SUPPORTED_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
const KEY_REGEX = /^[A-Za-z0-9_\-:.]{8,128}$/;
export interface IdempotencyContext {
userId: string;
key: string;
method: string;
path: string;
}
function getIdempotencyKey(request: Request | NextRequest): string | null {
const raw = request.headers.get("idempotency-key");
if (!raw) return null;
const trimmed = raw.trim();
if (!KEY_REGEX.test(trimmed)) return null;
return trimmed;
}
/**
* Look up a cached response for a (userId, key, method, path) tuple.
* Returns the cached NextResponse or null.
*/
async function findCached(ctx: IdempotencyContext): Promise<NextResponse | null> {
const row = await prisma.idempotencyKey.findUnique({
where: {
userId_key_method_path: {
userId: ctx.userId,
key: ctx.key,
method: ctx.method,
path: ctx.path,
},
},
});
if (!row) return null;
if (row.expiresAt <= new Date()) {
// Stale — purge and fall through.
await prisma.idempotencyKey.delete({ where: { id: row.id } }).catch(() => {});
return null;
}
let parsed: unknown = null;
try {
parsed = JSON.parse(row.responseBody);
} catch {
parsed = null;
}
annotate({
action: { name: "idempotency.replay" },
meta: { method: ctx.method, path: ctx.path },
});
// Replay original status to keep client behaviour byte-identical.
return NextResponse.json(parsed, {
status: row.responseStatus,
headers: {
"X-Idempotent-Replay": "true",
},
});
}
async function persistCached(
ctx: IdempotencyContext,
response: Response,
preReadBody?: string,
): Promise<void> {
const body =
preReadBody ?? (await response.clone().text().catch(() => ""));
await prisma.idempotencyKey
.create({
data: {
userId: ctx.userId,
key: ctx.key,
method: ctx.method,
path: ctx.path,
responseStatus: response.status,
responseBody: body,
expiresAt: new Date(Date.now() + TTL_MS),
},
})
.catch(() => {
// Concurrent insert with the same key — the other writer wins.
// Ignore because a future replay will hit their cached value.
});
}
/**
* Wrap a write handler so a repeat call with the same `Idempotency-Key`
* (and same userId/method/path) returns the originally cached response.
*
* The wrapped handler is responsible for authentication itself — this
* helper only triggers for methods in {POST, PUT, PATCH, DELETE} and only
* once `userIdResolver` returns a non-null value.
*
* No-op when the header is missing or the value is malformed.
*/
export function withIdempotency<
Args extends [Request | NextRequest, ...unknown[]],
>(
handler: (...args: Args) => Promise<Response>,
userIdResolver: (...args: Args) => Promise<string | null>,
): (...args: Args) => Promise<Response> {
return async (...args: Args): Promise<Response> => {
const request = args[0];
if (!SUPPORTED_METHODS.has(request.method)) {
return handler(...args);
}
const key = getIdempotencyKey(request);
if (!key) return handler(...args);
const userId = await userIdResolver(...args);
if (!userId) return handler(...args);
const url = new URL(request.url);
const ctx: IdempotencyContext = {
userId,
key,
method: request.method,
path: url.pathname,
};
const cached = await findCached(ctx);
if (cached) return cached;
const response = await handler(...args);
// Cache only client-stable responses. Replaying a stale 401/403
// (token expired mid-flight) or a 5xx would lock the user into a
// bogus result for the TTL window. 4xx-validation responses are
// intentionally cached so the same broken request doesn't hit the
// DB twice — but auth/throttle/server faults must not poison.
const cachable =
response.status < 400 ||
(response.status >= 400 &&
response.status < 500 &&
response.status !== 401 &&
response.status !== 403 &&
response.status !== 408 &&
response.status !== 429);
if (cachable) {
// Defence-in-depth: never persist a body that carries a freshly-issued
// bearer token. Auth routes shouldn't be wrapped in withIdempotency to
// begin with, but if a future caller forgets, we refuse to leak.
const cloned = response.clone();
const text = await cloned.text();
if (!text.includes("hlk_")) {
await persistCached(ctx, response, text);
}
}
return response;
};
}
+1 -1
View File
@@ -51,7 +51,7 @@ export interface WideEvent {
auth?: {
user_id?: string;
user_role?: string;
auth_method?: "session" | "api_key" | "cron_secret" | "telegram_webhook" | "webhook_secret";
auth_method?: "session" | "bearer" | "api_key" | "cron_secret" | "telegram_webhook" | "webhook_secret";
};
// Business-Daten (was wurde getan)