mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
45 files changed
+10064
-269
No files matched your search
@@ -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 = []
|
||||
@@ -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
@@ -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");
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* GET /api/dashboard/summary
|
||||
*
|
||||
* Aggregator endpoint for the iOS DashboardSummary view. Combines
|
||||
* greeting, intake-day streaks, today's medication compliance, the
|
||||
* highlighted insight, and per-metric latest+sparkline+trend.
|
||||
*
|
||||
* The shape is fixed for the iOS client and intentionally normalised —
|
||||
* `kind` is iOS-friendly (camelCase), unlike the canonical Prisma enum
|
||||
* (BLOOD_PRESSURE_SYS etc.).
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { MeasurementType } from "@/generated/prisma/client";
|
||||
|
||||
const SPARK_DAYS = 7;
|
||||
const STREAK_WINDOW_DAYS = 365;
|
||||
|
||||
type MetricKind =
|
||||
| "weight"
|
||||
| "bloodPressure"
|
||||
| "pulse"
|
||||
| "bodyFat"
|
||||
| "glucose"
|
||||
| "sleep"
|
||||
| "steps";
|
||||
|
||||
interface MetricCard {
|
||||
id: string;
|
||||
kind: MetricKind;
|
||||
title: string;
|
||||
latestValue: number | null;
|
||||
secondaryValue: number | null;
|
||||
unit: string;
|
||||
trend: "up" | "down" | "flat" | "unknown";
|
||||
sparkline: number[];
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
const METRIC_TITLES: Record<MetricKind, string> = {
|
||||
weight: "Gewicht",
|
||||
bloodPressure: "Blutdruck",
|
||||
pulse: "Puls",
|
||||
bodyFat: "Körperfett",
|
||||
glucose: "Blutzucker",
|
||||
sleep: "Schlaf",
|
||||
steps: "Schritte",
|
||||
};
|
||||
|
||||
const METRIC_UNITS: Record<MetricKind, string> = {
|
||||
weight: "kg",
|
||||
bloodPressure: "mmHg",
|
||||
pulse: "bpm",
|
||||
bodyFat: "%",
|
||||
glucose: "mg/dL",
|
||||
sleep: "h",
|
||||
steps: "Schritte",
|
||||
};
|
||||
|
||||
function trendOf(values: number[]): MetricCard["trend"] {
|
||||
if (values.length < 2) return "unknown";
|
||||
const first = values[0];
|
||||
const last = values[values.length - 1];
|
||||
const delta = last - first;
|
||||
const epsilon = Math.max(1, Math.abs(first) * 0.01);
|
||||
if (Math.abs(delta) < epsilon) return "flat";
|
||||
return delta > 0 ? "up" : "down";
|
||||
}
|
||||
|
||||
function startOfDayBerlin(date: Date): Date {
|
||||
// Compute midnight in Europe/Berlin → UTC ms.
|
||||
const berlin = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone: "Europe/Berlin",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
}).formatToParts(date);
|
||||
const y = berlin.find((p) => p.type === "year")?.value ?? "1970";
|
||||
const m = berlin.find((p) => p.type === "month")?.value ?? "01";
|
||||
const d = berlin.find((p) => p.type === "day")?.value ?? "01";
|
||||
return new Date(`${y}-${m}-${d}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
function berlinDayKey(date: Date): string {
|
||||
return new Intl.DateTimeFormat("sv-SE", {
|
||||
timeZone: "Europe/Berlin",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
interface StreakInfo {
|
||||
currentDays: number;
|
||||
longest: number;
|
||||
}
|
||||
|
||||
/** Compute the current logging-day streak (days where any measurement or
|
||||
* intake event was recorded, in Berlin time) plus the longest streak in
|
||||
* the last `STREAK_WINDOW_DAYS` days. */
|
||||
function computeStreak(activityDays: Set<string>): StreakInfo {
|
||||
if (activityDays.size === 0) return { currentDays: 0, longest: 0 };
|
||||
|
||||
const sorted = [...activityDays].sort();
|
||||
let longest = 1;
|
||||
let run = 1;
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const prev = new Date(`${sorted[i - 1]}T00:00:00.000Z`).getTime();
|
||||
const cur = new Date(`${sorted[i]}T00:00:00.000Z`).getTime();
|
||||
if (cur - prev === 86_400_000) {
|
||||
run += 1;
|
||||
longest = Math.max(longest, run);
|
||||
} else {
|
||||
run = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Current streak: walk back from today (Berlin).
|
||||
const todayKey = berlinDayKey(new Date());
|
||||
let currentDays = 0;
|
||||
let cursor = new Date(`${todayKey}T00:00:00.000Z`);
|
||||
// Allow yesterday's last day to count if today not yet logged.
|
||||
if (!activityDays.has(berlinDayKey(cursor))) {
|
||||
cursor = new Date(cursor.getTime() - 86_400_000);
|
||||
}
|
||||
while (activityDays.has(berlinDayKey(cursor))) {
|
||||
currentDays += 1;
|
||||
cursor = new Date(cursor.getTime() - 86_400_000);
|
||||
}
|
||||
|
||||
return { currentDays, longest };
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "dashboard.summary" } });
|
||||
|
||||
const now = new Date();
|
||||
const sevenDaysAgo = new Date(now.getTime() - SPARK_DAYS * 86_400_000);
|
||||
const streakWindowStart = new Date(
|
||||
now.getTime() - STREAK_WINDOW_DAYS * 86_400_000,
|
||||
);
|
||||
const todayStart = startOfDayBerlin(now);
|
||||
const todayEnd = new Date(todayStart.getTime() + 86_400_000);
|
||||
|
||||
const measurementTypes: MeasurementType[] = [
|
||||
"WEIGHT",
|
||||
"BLOOD_PRESSURE_SYS",
|
||||
"BLOOD_PRESSURE_DIA",
|
||||
"PULSE",
|
||||
"BODY_FAT",
|
||||
"BLOOD_GLUCOSE",
|
||||
"SLEEP_DURATION",
|
||||
"ACTIVITY_STEPS",
|
||||
];
|
||||
|
||||
const [recentMeasurements, todaysIntakes, streakActivity] =
|
||||
await Promise.all([
|
||||
prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
type: { in: measurementTypes },
|
||||
measuredAt: { gte: sevenDaysAgo },
|
||||
},
|
||||
orderBy: { measuredAt: "asc" },
|
||||
select: { type: true, value: true, measuredAt: true },
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
scheduledFor: { gte: todayStart, lt: todayEnd },
|
||||
},
|
||||
select: { id: true, takenAt: true, skipped: true },
|
||||
}),
|
||||
prisma.medicationIntakeEvent.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
scheduledFor: { gte: streakWindowStart },
|
||||
OR: [{ takenAt: { not: null } }, { skipped: true }],
|
||||
},
|
||||
select: { takenAt: true, scheduledFor: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const activityDays = new Set<string>();
|
||||
for (const m of recentMeasurements) activityDays.add(berlinDayKey(m.measuredAt));
|
||||
// Pull a wider measurement window for the streak so it isn't capped by
|
||||
// SPARK_DAYS — but only if the user has any data at all.
|
||||
if (recentMeasurements.length > 0) {
|
||||
const wider = await prisma.measurement.findMany({
|
||||
where: {
|
||||
userId: user.id,
|
||||
measuredAt: { gte: streakWindowStart },
|
||||
},
|
||||
select: { measuredAt: true },
|
||||
});
|
||||
for (const m of wider) activityDays.add(berlinDayKey(m.measuredAt));
|
||||
}
|
||||
for (const e of streakActivity) {
|
||||
activityDays.add(berlinDayKey(e.takenAt ?? e.scheduledFor));
|
||||
}
|
||||
|
||||
const streak = computeStreak(activityDays);
|
||||
|
||||
// Per-type latest + sparkline.
|
||||
const byType = new Map<MeasurementType, { value: number; at: Date }[]>();
|
||||
for (const m of recentMeasurements) {
|
||||
const list = byType.get(m.type) ?? [];
|
||||
list.push({ value: m.value, at: m.measuredAt });
|
||||
byType.set(m.type, list);
|
||||
}
|
||||
|
||||
function latestOf(type: MeasurementType): { value: number; at: Date } | null {
|
||||
const list = byType.get(type);
|
||||
if (!list || list.length === 0) return null;
|
||||
return list[list.length - 1];
|
||||
}
|
||||
|
||||
function sparkOf(type: MeasurementType): number[] {
|
||||
const list = byType.get(type);
|
||||
if (!list) return [];
|
||||
return list.map((p) => p.value);
|
||||
}
|
||||
|
||||
const metrics: MetricCard[] = [];
|
||||
|
||||
// Weight
|
||||
{
|
||||
const latest = latestOf("WEIGHT");
|
||||
const spark = sparkOf("WEIGHT");
|
||||
metrics.push({
|
||||
id: "weight",
|
||||
kind: "weight",
|
||||
title: METRIC_TITLES.weight,
|
||||
latestValue: latest?.value ?? null,
|
||||
secondaryValue: null,
|
||||
unit: METRIC_UNITS.weight,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest?.at.toISOString() ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Blood pressure (paired sys/dia)
|
||||
{
|
||||
const sysList = byType.get("BLOOD_PRESSURE_SYS") ?? [];
|
||||
const diaList = byType.get("BLOOD_PRESSURE_DIA") ?? [];
|
||||
const latestSys = sysList[sysList.length - 1] ?? null;
|
||||
const latestDia = diaList[diaList.length - 1] ?? null;
|
||||
metrics.push({
|
||||
id: "bp",
|
||||
kind: "bloodPressure",
|
||||
title: METRIC_TITLES.bloodPressure,
|
||||
latestValue: latestSys?.value ?? null,
|
||||
secondaryValue: latestDia?.value ?? null,
|
||||
unit: METRIC_UNITS.bloodPressure,
|
||||
trend: trendOf(sysList.map((p) => p.value)),
|
||||
sparkline: sysList.map((p) => p.value),
|
||||
updatedAt:
|
||||
latestSys?.at.toISOString() ?? latestDia?.at.toISOString() ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Pulse
|
||||
{
|
||||
const latest = latestOf("PULSE");
|
||||
const spark = sparkOf("PULSE");
|
||||
metrics.push({
|
||||
id: "pulse",
|
||||
kind: "pulse",
|
||||
title: METRIC_TITLES.pulse,
|
||||
latestValue: latest?.value ?? null,
|
||||
secondaryValue: null,
|
||||
unit: METRIC_UNITS.pulse,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest?.at.toISOString() ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
// Body fat
|
||||
{
|
||||
const latest = latestOf("BODY_FAT");
|
||||
const spark = sparkOf("BODY_FAT");
|
||||
if (latest) {
|
||||
metrics.push({
|
||||
id: "bodyFat",
|
||||
kind: "bodyFat",
|
||||
title: METRIC_TITLES.bodyFat,
|
||||
latestValue: latest.value,
|
||||
secondaryValue: null,
|
||||
unit: METRIC_UNITS.bodyFat,
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest.at.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Glucose / sleep / steps — only if data is present.
|
||||
for (const [type, kind] of [
|
||||
["BLOOD_GLUCOSE", "glucose"],
|
||||
["SLEEP_DURATION", "sleep"],
|
||||
["ACTIVITY_STEPS", "steps"],
|
||||
] as const) {
|
||||
const latest = latestOf(type);
|
||||
if (!latest) continue;
|
||||
const spark = sparkOf(type);
|
||||
metrics.push({
|
||||
id: kind,
|
||||
kind,
|
||||
title: METRIC_TITLES[kind],
|
||||
latestValue: latest.value,
|
||||
secondaryValue: null,
|
||||
unit: METRIC_UNITS[kind],
|
||||
trend: trendOf(spark),
|
||||
sparkline: spark,
|
||||
updatedAt: latest.at.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
const scheduledToday = todaysIntakes.length;
|
||||
const takenToday = todaysIntakes.filter(
|
||||
(e) => e.takenAt !== null && !e.skipped,
|
||||
).length;
|
||||
|
||||
const greetingName = user.displayName ?? user.username;
|
||||
|
||||
return apiSuccess({
|
||||
greeting: {
|
||||
salutation: `Hi, ${greetingName}`,
|
||||
date: now.toISOString(),
|
||||
},
|
||||
streak: {
|
||||
currentDays: streak.currentDays,
|
||||
longest: streak.longest,
|
||||
label: "Tage in Folge",
|
||||
},
|
||||
compliance: {
|
||||
scheduledToday,
|
||||
takenToday,
|
||||
},
|
||||
highlightInsight: null,
|
||||
metrics,
|
||||
lastUpdated: now.toISOString(),
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
@@ -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" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user