feat(api): publish the v1.43 contract with routes that answer 501 until filled

Every new endpoint of the contract exists, wrapped in apiHandler, with its
authentication, module gate and request validation, and answers
501 not_implemented until its behaviour lands. The OpenAPI document carries
the new operations and schemas and the additive response fields on the
changed routes. The four server feature flags are published as false.

contract-stub-inventory.test.ts registers the stubbed routes; each one
leaves the register with the change that fills it. The route, sharing,
session, security, module-gate and outcome inventories name the new routes
deliberately, and the error-code catalogue lists the codes the routes emit
today.
This commit is contained in:
Marc-André Bombeck committed 2026-10-15 21:49:02 +02:00
1 parent 6e72de1c35
commit 3786deb503
74 files changed
+6614 -282

No files matched your search

+3338 -267
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,121 @@
/**
* The routes that publish a contract and do not yet carry its effect.
*
* v1.43 fixed its API contract before the features behind it were built: the
* routes exist, authenticate, gate and validate as documented, and answer
* `501 not_implemented` (`src/lib/http/not-implemented.ts`) until their
* feature lands. That is safe only while nothing reaches them, and nothing
* should: every client path sits behind a `features` flag that stays `false`.
*
* This list is the debt register. A feature that lands removes its route here
* in the same change; a new stub has to be named here to exist. The release is
* not ready to tag while the list is not empty, which the last case below
* makes impossible to miss: it states the count it was written against, so
* the change that empties the list also has to touch this file.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { walkSourceFiles } from "./helpers/source-files";
const API_ROOT = join(process.cwd(), "src", "app", "api");
/** Route file (under `src/app/api`) to the operations it still stubs. */
const CONTRACT_STUBS: Readonly<Record<string, readonly string[]>> = {
"admin/maintenance/pulse-day-mean/route.ts": [
"admin.pulse_day_mean.get",
"admin.pulse_day_mean.post",
],
"admin/support-report/route.ts": ["admin.support_report.get"],
"anamnesis/audit-c/[id]/route.ts": ["anamnesis.audit_c.delete"],
"anamnesis/audit-c/route.ts": ["anamnesis.audit_c.create"],
"anamnesis/life-circumstances/ai-sections/route.ts": [
"anamnesis.life_circumstances.ai_sections.patch",
],
"anamnesis/life-circumstances/confirm/route.ts": [
"anamnesis.life_circumstances.confirm",
],
"anamnesis/life-circumstances/route.ts": ["anamnesis.life_circumstances.get"],
"auth/claim/sso/route.ts": ["profile_claim.sso.start"],
"auth/me/ai-usage/route.ts": ["auth.me.ai_usage.put"],
"biomarkers/[id]/merge/route.ts": ["labs.biomarker.merge"],
"dawarich/connect/route.ts": ["dawarich.connect"],
"dawarich/disconnect/route.ts": ["dawarich.disconnect"],
"dawarich/places/route.ts": ["dawarich.places.get"],
"dawarich/settings/route.ts": ["dawarich.settings.patch"],
"dawarich/status/route.ts": ["dawarich.status.get"],
"dawarich/sync/route.ts": ["dawarich.sync"],
"dawarich/test/route.ts": ["dawarich.test"],
"environment/travel/suggestions/[id]/dismiss/route.ts": [
"environment.travel_suggestion.dismiss",
],
"environment/travel/suggestions/route.ts": [
"environment.travel_suggestions.get",
],
"labs/[id]/links/route.ts": ["labs.link."],
"medications/[id]/ingredients/extract/route.ts": [
"medications.ingredients.extract",
],
"medications/[id]/ingredients/route.ts": ["medications.ingredients.put"],
"medications/[id]/links/route.ts": ["medication.link."],
"nutrients/catalog/route.ts": ["nutrients.catalog.get"],
};
/** The size of the register when it was written; see the header. */
const REGISTER_SIZE = 24;
function routeFiles(): string[] {
return walkSourceFiles(API_ROOT, { floor: 400, extensions: [".ts"] })
.filter((rel) => rel.endsWith("route.ts"))
.filter((rel) => !rel.includes("__tests__"));
}
describe("contract stubs answering 501", () => {
const routes = routeFiles();
const found = routes
.filter((rel) =>
/\bnotImplemented\s*\(/.test(readFileSync(join(API_ROOT, rel), "utf8")),
)
.sort();
it("sweeps the whole route tree", () => {
// The register can and should reach zero; the tree it is checked against
// cannot. A matcher that read nothing would make the register look clean.
expect(routes.length).toBeGreaterThan(400);
});
it("are exactly the registered ones", () => {
expect(found).toEqual(Object.keys(CONTRACT_STUBS).sort());
});
it("each still names the operations it was registered with", () => {
for (const [rel, operations] of Object.entries(CONTRACT_STUBS)) {
const source = readFileSync(join(API_ROOT, rel), "utf8");
for (const operation of operations) {
expect(source, `${rel} no longer stubs ${operation}`).toContain(
`notImplemented(${operation.endsWith(".") ? "`" : '"'}${operation}`,
);
}
}
});
it("authenticate before they answer", () => {
for (const rel of found) {
const source = readFileSync(join(API_ROOT, rel), "utf8");
expect(
/\b(requireAuth|requireRecordAuth|requireAdmin|claimGate)\s*\(/.test(
source,
),
`${rel} answers 501 without authenticating first`,
).toBe(true);
}
});
it("are still the register the release was planned with", () => {
// Empty before the release is tagged: set REGISTER_SIZE to 0 with the
// change that fills the last route.
expect(Object.keys(CONTRACT_STUBS)).toHaveLength(REGISTER_SIZE);
});
});
@@ -258,6 +258,10 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
kind: "adminConsole",
methods: ["POST"],
},
"/api/admin/maintenance/pulse-day-mean": {
kind: "adminConsole",
methods: ["GET", "POST"],
},
"/api/admin/monitoring/glitchtip-test": {
kind: "adminConsole",
methods: ["POST"],
@@ -284,6 +288,7 @@ const UNPUBLISHED: Readonly<Record<string, Exemption>> = {
methods: ["POST"],
},
"/api/admin/status": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/support-report": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/tokens": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/users": { kind: "adminConsole", methods: ["GET"] },
"/api/admin/users/{id}": { kind: "adminConsole", methods: ["PUT"] },
@@ -83,6 +83,8 @@ const UNAUTHENTICATED: Readonly<Record<string, string>> = {
"Previews a managed-profile handover link for a person who has no credential yet; the one-time token in the body is the credential.",
"/api/auth/claim":
"Turns a managed profile into the claimant's own account and creates their first credential; the one-time token in the body authorises it.",
"/api/auth/claim/sso":
"Starts claiming a managed profile through the identity provider; the claimant has no credential yet, and the one-time token in the body authorises it.",
// The tombstones, sourced from the registry rather than restated one by one.
// A retired path answers 410 from the proxy before any handler runs, and that
// answer never reads the request's cookies or Authorization header — so it is
@@ -96,6 +96,9 @@ describe("S1 — the direct session-resolution set is frozen", () => {
"refuses to claim a profile over a live session",
"app/api/auth/claim/preview/route.ts":
"refuses to preview a handover link over a live session",
// v1.43 (#959) — the single-sign-on claim start, same argument.
"app/api/auth/claim/sso/route.ts":
"refuses to start a single-sign-on claim over a live session",
// Anonymous IdP callback. Probes for an already-valid session so a
// replayed callback redirects instead of minting a second identity.
"app/api/auth/oidc/callback/route.ts":
+14 -1
View File
@@ -887,6 +887,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "profile",
why: "The record's health-profile facts, read through `readHealthProfileFacts(user.id)`. The module holds write helpers, and the GET calls none of them.",
},
"app/api/anamnesis/life-circumstances/route.ts": {
domain: "profile",
why: "The record's life circumstances (v1.43), the structured sibling of the facts above and under the same `profile` grant. Read-only: the section switches, confirmations and AUDIT-C are written through their own routes, none of them delegable.",
},
"app/api/profile/summary/route.ts": {
domain: "profile",
why: "The bounded shared profile read combines only the record's allergies, family history, and current lifestyle facts. It carries no settings, AI configuration, mutations, or encrypted free text.",
@@ -931,6 +935,10 @@ const DELEGABLE_ROUTES: Record<string, DelegableEntry> = {
domain: "measurements",
why: "The record's sleep rhythm, built from `buildSleepRhythm(user.id)` and gated on the record's sleep module.",
},
"app/api/nutrients/catalog/route.ts": {
domain: "measurements",
why: "The nutrient catalogue as the composition form reads it (v1.43): codes, units and upper limits, labels in the record's language. Reference data the record's module gate opens; nothing of the record itself.",
},
"app/api/nutrients/route.ts": {
domain: "measurements",
why: "The record's nutrient overview. It reads the last ingest audit row to date the data — integration-adjacent metadata, and no credential, endpoint or token crosses the wire.",
@@ -2104,8 +2112,13 @@ const ACTOR_ROUTES: Record<string, string> = {
*
* v1.42.4 -- the Labs list's per-marker overview joins the record list
* beside `GET /api/labs`, the rows it groups. 255 -> 256.
*
* v1.43 -- two reads join the record list: the life circumstances beside the
* facts (`profile`) and the nutrient catalogue beside the nutrient reads
* (`measurements`). The biomarker DELETE joins the manage literal on a module
* that was already there. 256 -> 258.
*/
const FROZEN_ENTRY_COUNT = 256;
const FROZEN_ENTRY_COUNT = 258;
/**
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
@@ -754,9 +754,20 @@ const OCR_COLLECTION_CONSUMERS: Readonly<Record<string, readonly string[]>> = {
skipped: ["result.skipped.length"],
};
/**
* `/api/<provider>/sync` routes that only queue a background job and answer
* 202 `{ enqueued }`: nothing is written while the request runs, so there is
* no written outcome to resolve. Each entry says why.
*/
const QUEUE_ONLY_SYNC_ROUTES: Readonly<Record<string, string>> = {
dawarich:
"Dawarich daily values are fetched by the dawarich-sync job; the route answers 202 { enqueued } and the outcome reaches the card through the connection health, not this response.",
};
function discoveredSyncRoutes(): Array<{ id: string; path: string }> {
return readdirSync(API_ROOT, { withFileTypes: true }).flatMap((entry) => {
if (!entry.isDirectory()) return [];
if (entry.name in QUEUE_ONLY_SYNC_ROUTES) return [];
const path = join(API_ROOT, entry.name, "sync", "route.ts");
return existsSync(path) ? [{ id: entry.name, path }] : [];
});
@@ -1078,6 +1089,13 @@ describe("written-outcome response consumer guard", () => {
).toBe(true);
}
}
for (const [id, reason] of Object.entries(QUEUE_ONLY_SYNC_ROUTES)) {
expect(
existsSync(join(API_ROOT, id, "sync", "route.ts")),
`${id} is listed as a queue-only sync route but has none`,
).toBe(true);
expect(reason.length).toBeGreaterThan(40);
}
const discovered = discoveredSyncRoutes().sort((a, b) =>
a.id.localeCompare(b.id),
);
@@ -252,6 +252,11 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
"src/app/api/labs/restore/route.ts",
"src/app/api/biomarkers/route.ts",
"src/app/api/biomarkers/[id]/route.ts",
// v1.43 — merging markers and filing a reading against its report or visit
// are the same row-store operations: the history and its links must stay
// consistent while the Labs surface is hidden.
"src/app/api/biomarkers/[id]/merge/route.ts",
"src/app/api/labs/[id]/links/route.ts",
// v1.18.9 — the Lab-OCR ingestion routes share the LabResult data-layer
// reasoning: the labs module gates the SURFACE (the Labs page + scan
// affordance), not the row store. The extract route is read-only vision
@@ -315,6 +320,9 @@ const EXEMPT_ROUTES: ReadonlyArray<string> = [
// SURFACE (the detail-page tab + the insights summary), not the row store.
"src/app/api/medications/[id]/efficacy/route.ts",
"src/app/api/medications/[id]/efficacy/target/route.ts",
// v1.43 — a medication's links to its visits, documents and indication are
// raw link CRUD over the person's own rows, like the vaccination links.
"src/app/api/medications/[id]/links/route.ts",
// ── INFRA / UI-ONLY ───────────────────────────────────────────────
// Static FHIR CapabilityStatement — server metadata, no user data.
"src/app/api/fhir/metadata/route.ts",
@@ -0,0 +1,39 @@
/**
* `GET/POST /api/admin/maintenance/pulse-day-mean` — the pulse day-mean
* method (#1107 follow-up).
*
* GET answers the active method, when it was applied and the last dry run
* (status and result: per-account labels only, never an id). POST
* `{ action: "dry_run" | "apply" }` answers 202 `{ enqueued }`: a dry run
* computes both methods for every account and day and writes nothing but its
* result; `apply` needs a successful dry run from the last seven days (409
* `maintenance.pulseDayMean.dryRunRequired`), switches the method and
* rebuilds the pulse rollups. 409 `maintenance.pulseDayMean.busy` while a run
* is active. Admin only, cookie session only.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
import { notImplemented } from "@/lib/http/not-implemented";
import { pulseDayMeanActionSchema } from "@/lib/validations/pulse-day-mean";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
await requireAdmin();
return notImplemented("admin.pulse_day_mean.get");
});
export const POST = apiHandler(async (request: NextRequest) => {
await requireAdmin();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = pulseDayMeanActionSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
return notImplemented("admin.pulse_day_mean.post");
});
+17
View File
@@ -0,0 +1,17 @@
/**
* `GET /api/admin/support-report` — a redacted plain-text report an operator
* can paste into an issue: build, database, configuration as yes/no, jobs,
* connections by health state, AI ledger and usage levels, notifications,
* backups and the most frequent recent error actions. Never an email address,
* a user id, a value, a token or a foreign host name; accounts appear as
* "Account 1..n". Admin only, cookie session only.
*/
import { apiHandler, requireAdmin } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
await requireAdmin();
return notImplemented("admin.support_report.get");
});
@@ -0,0 +1,21 @@
/**
* `DELETE /api/anamnesis/audit-c/{id}` — remove an AUDIT-C entry (#614).
* Soft delete; answers `{ id, removedAt }`, 404 `anamnesis.auditC.notFound`.
*
* Contract authority: `requireRecordAuth("manage", "profile")`. Until AUDIT-C
* lands the stub resolves the caller's own record only.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
type RouteParams = { params: Promise<{ id: string }> };
export const DELETE = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
await requireAuth();
await params;
return notImplemented("anamnesis.audit_c.delete");
},
);
+47
View File
@@ -0,0 +1,47 @@
/**
* `POST /api/anamnesis/audit-c` — record an AUDIT-C (#614).
*
* `{ items: [0..4, 0..4, 0..4], takenAt?, tz?, locale?, source, externalId? }`.
* 201 `{ result: AuditCResult }` with the sum and the screening cut-off for
* the entry's language and the profile's sex (null without one); a replay
* with the same `externalId` answers 200 with the same result. Stored beside
* the mental-health screeners but never listed by
* `/api/mental-health/assessments`. At most 30 an hour.
*
* Contract authority: `requireRecordAuth("manage", "profile")`. Until AUDIT-C
* lands the stub resolves the caller's own record only.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { notImplemented } from "@/lib/http/not-implemented";
import { withIdempotency } from "@/lib/idempotency";
import { checkRateLimit } from "@/lib/rate-limit";
import { auditCRequestSchema } from "@/lib/validations/life-circumstances";
export const POST = apiHandler(withIdempotency<[NextRequest]>(postAuditC));
async function postAuditC(request: NextRequest): Promise<Response> {
const { user } = await requireAuth();
const rl = await checkRateLimit(`audit-c:${user.id}`, 30, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError("Too many entries, try again later", 429, {
errorCode: "anamnesis.auditC.rateLimited",
});
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = auditCRequestSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "anamnesis.auditC.invalid",
});
}
return notImplemented("anamnesis.audit_c.create");
}
@@ -0,0 +1,33 @@
/**
* `PATCH /api/anamnesis/life-circumstances/ai-sections` — switch which
* life-circumstance sections may reach the AI (#614).
*
* `{ sections: { <SECTION>: boolean }, baseUpdatedAt? }` with at least one
* key, over `SMOKING_STATUS`, `ALCOHOL_PATTERN`, `SHIFT_SCHEDULE`, `ACTIVITY`,
* `WORK`, `HABITS`, `GOALS`. Sections not named keep their state. A stale
* `baseUpdatedAt` is 409 `about_me_conflict`, the token `about-me` uses.
* Own account only.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
import { notImplemented } from "@/lib/http/not-implemented";
import { lifeAiSectionsPatchSchema } from "@/lib/validations/life-circumstances";
export const PATCH = apiHandler(async (request: NextRequest) => {
await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = lifeAiSectionsPatchSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "anamnesis.lifeSections.invalid",
});
}
return notImplemented("anamnesis.life_circumstances.ai_sections.patch");
});
@@ -0,0 +1,28 @@
/**
* `POST /api/anamnesis/life-circumstances/confirm` — "still true" for one
* section (#614). `{ section }`; stores the confirmation instant only, never a
* new revision, and answers `{ section, lastConfirmedAt }`. Own account only.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
import { notImplemented } from "@/lib/http/not-implemented";
import { lifeConfirmSchema } from "@/lib/validations/life-circumstances";
export const POST = apiHandler(async (request: NextRequest) => {
await requireAuth();
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = lifeConfirmSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "anamnesis.lifeConfirm.invalid",
});
}
return notImplemented("anamnesis.life_circumstances.confirm");
});
@@ -0,0 +1,19 @@
/**
* `GET /api/anamnesis/life-circumstances` — the six life-circumstance
* sections (#614) with their current answers, AI switches, review state,
* the derived values (pack-years, activity minutes, free weekdays, screening
* hints) and AUDIT-C with its instrument text in the account's language.
*
* The new fact kinds are served here and only here: `GET /api/anamnesis/facts`
* keeps its three kinds for clients that decode that list closed. Delegates
* read it under the `profile` grant, like the facts.
*/
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
await requireRecordAuth("read", "profile");
return notImplemented("anamnesis.life_circumstances.get");
});
+37
View File
@@ -0,0 +1,37 @@
/**
* `POST /api/auth/claim/sso` — start claiming a managed profile through
* single sign-on (#959).
*
* Anonymous, like the password claim beside it. `{ token, username }`: the
* link is checked and the username reserved for the round trip, a sealed
* `hl_claim` cookie (10 minutes, `httpOnly`, `Secure` through
* `shouldEmitSecureCookie()`) carries `{ handoverId, username, exp }`, and the
* answer names where to go: `{ redirectTo: "/api/auth/oidc/login?intent=claim" }`.
* The OIDC callback binds the identity provider's subject to the profile. The
* token never rides a URL. Works on a single-sign-on-only server; 404
* `profile_claim.sso_unavailable` when no identity provider is configured.
*/
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
import { getSession } from "@/lib/auth/session";
import { notImplemented } from "@/lib/http/not-implemented";
import { claimGate } from "@/lib/managed-profiles/claim-gate";
import { claimSsoSchema } from "@/lib/validations/claim-sso";
export const POST = apiHandler(async (request: NextRequest) => {
// Refuses a signed-in caller and rate-limits the address, as the password
// claim does.
const gate = await claimGate(request, "claim", (await getSession()) !== null);
if (gate.refusal) return gate.refusal;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = claimSsoSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
return notImplemented("profile_claim.sso.start");
});
+42
View File
@@ -0,0 +1,42 @@
/**
* `PUT /api/auth/me/ai-usage` — the account's own AI usage level (#1217).
*
* `{ level: "frugal" | "standard" | "full" | null }`; `null` follows the
* instance default. A level above the operator's ceiling is refused with 422
* `ai.usage.aboveCeiling`, never capped silently. Answers the resolved
* `AiUsageState`, the same block `GET /api/auth/me` publishes as `ai.usage`.
* Own account only (cookie or a full-scope token), at most 30 writes an hour.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { notImplemented } from "@/lib/http/not-implemented";
import { checkRateLimit } from "@/lib/rate-limit";
import { aiUsagePutSchema } from "@/lib/validations/ai-usage";
export const dynamic = "force-dynamic";
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(`ai-usage:${user.id}`, 30, 60 * 60 * 1000);
if (!rl.allowed) {
return apiError("Too many changes, try again later", 429, {
errorCode: "ai.usage.rateLimited",
});
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = aiUsagePutSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "ai.usage.invalid",
});
}
return notImplemented("auth.me.ai_usage.put");
});
+9 -1
View File
@@ -83,7 +83,15 @@ export const dynamic = "force-dynamic";
* by design: a feature the server supports is supported for every account,
* and a per-account answer belongs in `modules` or `ai`, not here.
*/
const SERVER_FEATURES = { trackIntake: true } as const;
const SERVER_FEATURES = {
trackIntake: true,
// v1.43 — each turns true in the change that makes its route or request
// form work; until then a client must not send it (contract §0.6).
cumulativeHourBuckets: false,
syncTriggerBackfill: false,
biomarkerMerge: false,
lifeCircumstances: false,
} as const;
export const GET = apiHandler(async () => {
const auth = await requireActorAuth();
@@ -0,0 +1,62 @@
/**
* `POST /api/biomarkers/{id}/merge` — fold other markers into this one (D1223).
*
* `{id}` is the marker that stays. `{ sourceIds, dryRun }`: the readings of
* every source move onto the target in one transaction, converted where the
* analyte's conversion table knows the unit, the source names become aliases
* (`MERGE`), one `biomarker_merges` row is written per source and the sources
* are deleted. A pair of units without a known conversion refuses the whole
* merge (422 `labs.biomarker.merge.unitMismatch`); nothing moves. `dryRun`
* answers the same result and writes nothing. There is no undo.
*
* Contract authority: `requireRecordAuth("manage", "labs")` with the shared
* record-write ceiling. Until the merge lands the stub resolves the caller's
* own record only.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { notImplemented } from "@/lib/http/not-implemented";
import { withIdempotency } from "@/lib/idempotency";
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
import { biomarkerMergeSchema } from "@/lib/validations/biomarker-merge";
type RouteParams = { params: Promise<{ id: string }> };
export const POST = apiHandler(
withIdempotency<[NextRequest, RouteParams]>(mergeBiomarkers),
);
async function mergeBiomarkers(
request: NextRequest,
{ params }: RouteParams,
): Promise<Response> {
const { user } = await requireAuth();
const { id } = await params;
const writeRl = await checkRecordWriteRateLimit(user.id);
if (!writeRl.allowed) {
return apiError("Too many writes, try again later", 429, {
errorCode: "record_write.rate_limited",
});
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = biomarkerMergeSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "labs.biomarker.merge.invalid",
});
}
if (parsed.data.sourceIds.includes(id)) {
return apiError("The target cannot be one of the sources", 422, {
errorCode: "labs.biomarker.merge.invalid",
});
}
return notImplemented("labs.biomarker.merge");
}
+44
View File
@@ -0,0 +1,44 @@
/**
* `POST /api/dawarich/connect` — connect a Dawarich instance (movement
* context, daily values only).
*
* `{ url, apiKey }`; both sealed at rest. The URL must resolve to a public
* host unless its origin is listed in `DAWARICH_PRIVATE_ORIGINS` (422
* `private_origin_not_approved`). The key is checked against the instance
* before anything is stored, then a 30-day backfill starts. Part of the
* `environment` module; own account only, ten attempts a minute.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError, returnAllZodIssues, safeJson } from "@/lib/api-response";
import { notImplemented } from "@/lib/http/not-implemented";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit } from "@/lib/rate-limit";
import { dawarichConnectSchema } from "@/lib/validations/dawarich";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "environment");
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(`dawarich-connect:${user.id}`, 10, 60_000);
if (!rl.allowed) {
return apiError("Too many attempts, try again later", 429, {
errorCode: "dawarich.rate_limited",
});
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = dawarichConnectSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "dawarich.invalid",
});
}
return notImplemented("dawarich.connect");
});
+32
View File
@@ -0,0 +1,32 @@
/**
* `POST /api/dawarich/disconnect` — forget the connection; with
* `deleteData: true` the stored days go too. Answers
* `{ disconnected: true, deletedDays }`. Part of the `environment` module;
* own account only.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
import { notImplemented } from "@/lib/http/not-implemented";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { dawarichDisconnectSchema } from "@/lib/validations/dawarich";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "environment");
if (!gate.enabled) return gate.response;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = dawarichDisconnectSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "dawarich.invalid",
});
}
return notImplemented("dawarich.disconnect");
});
+32
View File
@@ -0,0 +1,32 @@
/**
* `GET /api/dawarich/places?q=` — the instance's places, to pick the home
* place from. Answered from Dawarich live (at most 200, filtered by `q`) and
* never stored. 409 `dawarich.not_connected`, 502 `dawarich.unreachable`.
* Part of the `environment` module; own account only.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { returnAllZodIssues } from "@/lib/api-response";
import { notImplemented } from "@/lib/http/not-implemented";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { dawarichPlacesQuerySchema } from "@/lib/validations/dawarich";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "environment");
if (!gate.enabled) return gate.response;
const parsed = dawarichPlacesQuerySchema.safeParse(
Object.fromEntries(request.nextUrl.searchParams),
);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "dawarich.invalid",
});
}
return notImplemented("dawarich.places.get");
});
+32
View File
@@ -0,0 +1,32 @@
/**
* `PATCH /api/dawarich/settings` — the home place (an id on the Dawarich
* instance) and the home country (ISO 3166-1 alpha-2) the daily values are
* measured against. 422 `dawarich.home_place_unknown` for a place id the
* instance does not have. Part of the `environment` module; own account only.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
import { notImplemented } from "@/lib/http/not-implemented";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { dawarichSettingsSchema } from "@/lib/validations/dawarich";
export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "environment");
if (!gate.enabled) return gate.response;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = dawarichSettingsSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "dawarich.invalid",
});
}
return notImplemented("dawarich.settings.patch");
});
+18
View File
@@ -0,0 +1,18 @@
/**
* `GET /api/dawarich/status` — whether Dawarich is connected, the home place
* and country state, how many days are stored and the connection `health`
* (the same shape every connection publishes; no legacy ledger fields).
* Part of the `environment` module; own account only.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { requireModuleEnabled } from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "environment");
if (!gate.enabled) return gate.response;
return notImplemented("dawarich.status.get");
});
+32
View File
@@ -0,0 +1,32 @@
/**
* `POST /api/dawarich/sync` — fetch the daily values again from `fromDate`
* (at most 365 days back; default: the nightly window). 202
* `{ enqueued }`; 409 `dawarich.not_connected`. Part of the `environment`
* module; own account only.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
import { notImplemented } from "@/lib/http/not-implemented";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { dawarichSyncSchema } from "@/lib/validations/dawarich";
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "environment");
if (!gate.enabled) return gate.response;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 1024,
});
if (jsonError) return jsonError;
const parsed = dawarichSyncSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "dawarich.invalid",
});
}
return notImplemented("dawarich.sync");
});
+15
View File
@@ -0,0 +1,15 @@
/**
* `POST /api/dawarich/test` — probe the connected Dawarich instance and answer
* the shared integration probe shape. Part of the `environment` module; own
* account only.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { requireModuleEnabled } from "@/lib/modules/gate";
export const POST = apiHandler(async () => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "environment");
if (!gate.enabled) return gate.response;
return notImplemented("dawarich.test");
});
@@ -0,0 +1,23 @@
/**
* `POST /api/environment/travel/suggestions/{id}/dismiss` — never suggest
* this run of travel days again. Answers `{ dismissed: true }`; 404
* `environment.travel_suggestion.not_found`. Part of the `environment`
* module; own account only.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { requireModuleEnabled } from "@/lib/modules/gate";
type RouteParams = { params: Promise<{ id: string }> };
export const POST = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
await params;
const gate = await requireModuleEnabled(user.id, "environment");
if (!gate.enabled) return gate.response;
return notImplemented("environment.travel_suggestion.dismiss");
},
);
@@ -0,0 +1,19 @@
/**
* `GET /api/environment/travel/suggestions` — runs of consecutive Dawarich
* travel days no stored trip covers yet, as `{ id, startDate, endDate,
* countryCode }`. Accepting one goes through `POST /api/environment/travel`
* (the person picks the place); dismissing through the sibling route. Part of
* the `environment` module; own account only.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { requireModuleEnabled } from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "environment");
if (!gate.enabled) return gate.response;
return notImplemented("environment.travel_suggestions.get");
});
+50
View File
@@ -0,0 +1,50 @@
/**
* `POST /api/labs/{id}/links` — file documents (the report it was read
* from) or visits against a lab result.
* `DELETE /api/labs/{id}/links` — unfile them.
*
* `{ targetKind, targetIds }` in, `{ changed, unknown }` out, through the
* shared link service like the visit and vaccination link routes; an id that
* names nothing this account owns comes back in `unknown`. Own account only,
* as the other link routes.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
import { notImplemented } from "@/lib/http/not-implemented";
import { labResultLinkSchema } from "@/lib/validations/relation-links";
type RouteParams = { params: Promise<{ id: string }> };
async function handle(
request: NextRequest,
{ params }: RouteParams,
verb: "link" | "unlink",
): Promise<Response> {
await requireAuth();
await params;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = labResultLinkSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "labs.link.invalid",
});
}
return notImplemented(`labs.link.${verb}`);
}
export const POST = apiHandler(
async (request: NextRequest, context: RouteParams) =>
handle(request, context, "link"),
);
export const DELETE = apiHandler(
async (request: NextRequest, context: RouteParams) =>
handle(request, context, "unlink"),
);
@@ -0,0 +1,32 @@
/**
* `POST /api/medications/{id}/ingredients/extract` — read a supplement label.
*
* Multipart `file` (JPEG, PNG, HEIC, WebP or PDF, at most 10 MB). Answers 202
* with an AI run; `GET /api/ai-runs/{id}` (`SUPPLEMENT_LABEL_EXTRACT`) carries
* a proposal of ingredients, unmatched lines and a serving note. Nothing is
* stored until the composition is saved through the PUT beside this route.
* Gated by the `supplementLabel` AI capability (a `frugal` usage level makes
* it unavailable), six runs an hour, budget reserved like the other document
* AI runs. Needs the `nutrients` module.
*
* Contract authority: `requireRecordAuth("manage", "medications")`. Until the
* extraction lands the stub resolves the caller's own record only.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { requireModuleEnabled } from "@/lib/modules/gate";
type RouteParams = { params: Promise<{ id: string }> };
export const POST = apiHandler(
async (_request: NextRequest, { params }: RouteParams) => {
const { user } = await requireAuth();
await params;
const gate = await requireModuleEnabled(user.id, "nutrients");
if (!gate.enabled) return gate.response;
return notImplemented("medications.ingredients.extract");
},
);
@@ -0,0 +1,50 @@
/**
* `PUT /api/medications/{id}/ingredients` — a supplement's composition per
* unit (tablet, capsule).
*
* `{ ingredients: [{ nutrient, amount, unit }], source }` replaces the set.
* Amounts are stored in the catalogue's canonical unit (an input unit such as
* `IU` is kept beside it as `enteredAmount` / `enteredUnit`). The change
* re-counts every taken dose of this medication into the day's micronutrients
* (`recompute.queued`). Needs the `nutrients` module.
*
* Contract authority: `requireRecordAuth("manage", "medications")`. Until the
* composition lands the stub resolves the caller's own record only.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
import { notImplemented } from "@/lib/http/not-implemented";
import { withIdempotency } from "@/lib/idempotency";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { medicationIngredientsPutSchema } from "@/lib/validations/medication-ingredients";
type RouteParams = { params: Promise<{ id: string }> };
export const PUT = apiHandler(
withIdempotency<[NextRequest, RouteParams]>(putIngredients),
);
async function putIngredients(
request: NextRequest,
{ params }: RouteParams,
): Promise<Response> {
const { user } = await requireAuth();
await params;
const gate = await requireModuleEnabled(user.id, "nutrients");
if (!gate.enabled) return gate.response;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = medicationIngredientsPutSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "medications.ingredients.invalid",
});
}
return notImplemented("medications.ingredients.put");
}
@@ -0,0 +1,50 @@
/**
* `POST /api/medications/{id}/links` — file conditions (the indication),
* documents or visits against a medication.
* `DELETE /api/medications/{id}/links` — unfile them.
*
* `{ targetKind, targetIds }` in, `{ changed, unknown }` out, through the
* shared link service like the visit and vaccination link routes; an id that
* names nothing this account owns comes back in `unknown`. Own account only,
* as the other link routes.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { returnAllZodIssues, safeJson } from "@/lib/api-response";
import { notImplemented } from "@/lib/http/not-implemented";
import { medicationLinkSchema } from "@/lib/validations/relation-links";
type RouteParams = { params: Promise<{ id: string }> };
async function handle(
request: NextRequest,
{ params }: RouteParams,
verb: "link" | "unlink",
): Promise<Response> {
await requireAuth();
await params;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 16 * 1024,
});
if (jsonError) return jsonError;
const parsed = medicationLinkSchema.safeParse(body);
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422, {
errorCode: "medication.link.invalid",
});
}
return notImplemented(`medication.link.${verb}`);
}
export const POST = apiHandler(
async (request: NextRequest, context: RouteParams) =>
handle(request, context, "link"),
);
export const DELETE = apiHandler(
async (request: NextRequest, context: RouteParams) =>
handle(request, context, "unlink"),
);
+22
View File
@@ -0,0 +1,22 @@
/**
* `GET /api/nutrients/catalog` — the nutrient catalogue as the forms use it.
*
* One entry per code: canonical unit, label in the account's language,
* whether HealthKit may send it (`syncable`), the input units with their
* factor to the canonical unit, and the tolerable upper intake level with its
* scope and source (or null). Clients build the composition form from this
* and never convert units themselves.
*/
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { notImplemented } from "@/lib/http/not-implemented";
import { requireModuleEnabled } from "@/lib/modules/gate";
export const dynamic = "force-dynamic";
export const GET = apiHandler(async () => {
const { user } = await requireRecordAuth("read", "measurements");
const gate = await requireModuleEnabled(user.id, "nutrients");
if (!gate.enabled) return gate.response;
return notImplemented("nutrients.catalog.get");
});
+42
View File
@@ -184,6 +184,42 @@ const dayNotable = z
})
.meta({ id: "DayNotable" });
/**
* v1.43 — the day's movement context from Dawarich: daily values only, never
* a coordinate or a place name. The record owner's only, like the
* environment readings; a delegate never receives it.
*/
export const dayMovementSchema = z
.object({
source: z.literal("dawarich"),
distance: z.object({
unit: z
.enum(["km", "mi"])
.describe("Resolved from the account's unit preference."),
total: z.number().nullable(),
driving: z.number().nullable(),
train: z.number().nullable(),
}),
placesVisited: z.number().int().nullable(),
awayFromHomeMinutes: z
.number()
.int()
.nullable()
.describe("`null` means no coverage that day, not zero."),
travel: z
.object({
isTravelDay: z.boolean(),
countryCode: z.string().nullable(),
})
.nullable(),
fetchedAt: z.iso.datetime({ offset: true }),
})
.meta({
id: "DayMovement",
description:
"Distances to one decimal in `distance.unit`. Hide a null field; never show it as zero.",
});
export const dayResponseSchema = z
.object({
date: dateKeySchema,
@@ -212,6 +248,12 @@ export const dayResponseSchema = z
.describe(
"Sections the caller cannot see. A section of a switched-off module is left out entirely rather than listed.",
),
movement: dayMovementSchema
.nullable()
.optional()
.describe(
"v1.43 — the day's movement context; absent for a delegate, with the `environment` module off, with no Dawarich connection, and on older servers.",
),
})
.meta({ id: "Day" });
+34
View File
@@ -0,0 +1,34 @@
/**
* The answer of a route whose contract is published but whose behaviour has
* not landed on this server yet.
*
* A route answers this only after it has authenticated the caller, resolved
* the record, applied its module gate and validated the request exactly as
* the published contract says, so a client already sees the real refusals
* (401, 403, 404, 422, 429). What is missing is the effect, and the body says
* so plainly instead of pretending: `501` with `errorCode: "not_implemented"`.
* Nothing calls these routes in normal use: every client path to them sits
* behind a server feature flag that stays `false` until the route is filled.
*
* `contract-stub-inventory.test.ts` freezes the set of route files that still
* answer this; it has to be empty before a release is tagged.
*/
import { apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
export const NOT_IMPLEMENTED_ERROR_CODE = "not_implemented";
/**
* 501 for an operation whose contract exists and whose effect does not yet.
* `operation` is a stable `<surface>.<noun>.<verb>` name for the wide event.
*/
export function notImplemented(operation: string): Response {
annotate({
action: { name: "contract.not_implemented" },
meta: { operation },
});
return apiError("Not implemented on this server yet", 501, {
errorCode: NOT_IMPLEMENTED_ERROR_CODE,
operation,
});
}
+12
View File
@@ -43,6 +43,8 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"ai.provider.none",
"ai.record.notPermitted",
"ai.unavailable",
"ai.usage.invalid",
"ai.usage.rateLimited",
],
ai_provider: ["ai_provider.invalid", "ai_provider.no_fields"],
aiRuns: [
@@ -54,9 +56,13 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
allergy: ["allergy.invalid"],
analytics: ["analytics.invalid_query"],
anamnesis: [
"anamnesis.auditC.invalid",
"anamnesis.auditC.rateLimited",
"anamnesis.fact.conflict",
"anamnesis.fact.currentExists",
"anamnesis.fact.invalidValue",
"anamnesis.lifeConfirm.invalid",
"anamnesis.lifeSections.invalid",
],
auth: [
"auth.admin.required",
@@ -118,6 +124,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
],
"cycle-prefs": ["cycle-prefs.body.invalid_json", "cycle-prefs.invalid"],
"disable-coach": ["disable-coach.body.invalid_json"],
dawarich: ["dawarich.invalid", "dawarich.rate_limited"],
day: ["day.invalid"],
demo: ["demo.readOnly"],
documents: [
@@ -211,10 +218,12 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
labs: [
"labs.biomarker.create.invalid",
"labs.biomarker.duplicate",
"labs.biomarker.merge.invalid",
"labs.biomarker.notFound",
"labs.biomarker.update.invalid",
"labs.biomarker.update.referenceRangeInvalid",
"labs.create.invalid",
"labs.link.invalid",
"labs.ocr.budgetExceeded",
"labs.ocr.extractFailed",
"labs.ocr.fileTooLarge",
@@ -257,6 +266,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
medication: [
"medication.intake.import.invalid_format",
"medication.intake.notTracked",
"medication.link.invalid",
],
medications: [
"medications.category.invalid",
@@ -269,6 +279,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"medications.course.oneShot",
"medications.course.overlap",
"medications.course.windowRequired",
"medications.ingredients.invalid",
"medications.intake.bulk.apple_health_not_mirrored",
"medications.intake.bulk.invalid",
"medications.intake.bulk.too_large",
@@ -435,6 +446,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
"modules_conflict",
"mood_tag_layout_conflict",
"not_configured",
"not_implemented",
"notification_prefs_conflict",
"oidc_only",
"private_origin_not_approved",
+31 -2
View File
@@ -20,6 +20,7 @@
*
* Part of the OpenAPI route table; aggregated in `./index.ts`.
*/
import { HANDOVER_METHODS } from "@/lib/validations/claim-sso";
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
@@ -521,9 +522,31 @@ const accountPayload = z
.describe(
"Medications take `trackIntake`: a medication can be kept as a record whose doses are never due, and a dose written against one is refused with `medication.intake.notTracked`.",
),
cumulativeHourBuckets: z
.boolean()
.optional()
.describe(
"v1.43 — `POST /api/measurements/batch` takes hourly `stats:<HK>:<YYYY-MM-DD>T<HH>` rows for steps, active energy, walking and running distance and flights climbed. Send them only when this is `true`: an older server stores every hour as its own measurement.",
),
syncTriggerBackfill: z
.boolean()
.optional()
.describe(
'v1.43 — `syncTrigger: "backfill"` is accepted on the measurement batch and the intake bulk. Send it only when this is `true`: an older server refuses the whole batch.',
),
biomarkerMerge: z
.boolean()
.optional()
.describe("v1.43 — `POST /api/biomarkers/{id}/merge` is available."),
lifeCircumstances: z
.boolean()
.optional()
.describe(
"v1.43 — the life-circumstances routes under `/api/anamnesis/life-circumstances` and `/api/anamnesis/audit-c` are available.",
),
})
.describe(
"What this server understands, for a client released apart from it. Constant across accounts; a flag appears once the server supports the feature and stays `true`. Absent on servers older than v1.42.0. A client that meets a flag it does not know ignores it.",
"What this server understands, for a client released apart from it. Constant across accounts. A flag that is absent or `false` means this server does not have the feature; a client checks for `true` before it calls a route or sends a request form the flag names. Absent on servers older than v1.42.0. A client that meets a flag it does not know ignores it.",
),
accountAccess: accountAccessBlock,
moduleAccess: moduleAccessMap,
@@ -605,7 +628,13 @@ const managedProfileHandoverStatus = z
available: z
.boolean()
.describe(
"False on an instance that allows single sign-on only, where a handover cannot be created.",
"Whether a handover can be created on this instance. Up to v1.42 false on an instance that allows single sign-on only; from v1.43 true whenever `methods` is not empty.",
),
methods: z
.array(z.enum(HANDOVER_METHODS))
.optional()
.describe(
"v1.43 — how a link can be redeemed here: `password` (not on a single-sign-on-only instance) and `sso` (whenever an identity provider is configured). Absent on older servers.",
),
open: z
.object({
+4 -1
View File
@@ -23,6 +23,7 @@ import {
documentSuggestResponse,
documentSummaryResponse,
} from "./documents";
import { supplementLabelExtractResult } from "./medications";
import { ocrExtractResponse } from "./ocr";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
@@ -35,6 +36,7 @@ const aiRun = z
"DOCUMENT_SUMMARY",
"DOCUMENT_SUGGEST",
"DOCUMENT_EXTRACT",
"SUPPLEMENT_LABEL_EXTRACT",
]),
documentId: z
.string()
@@ -48,10 +50,11 @@ const aiRun = z
documentSummaryResponse,
documentSuggestResponse,
documentExtractRunResult,
supplementLabelExtractResult,
])
.nullable()
.describe(
"Present once `status` is `SUCCEEDED`: the body the queuing route answers with synchronously. `DOCUMENT_INDEX` carries `DocumentIndexResponse`, `LABS_OCR_EXTRACT` carries `OcrExtractResponse`, `DOCUMENT_SUMMARY` carries `DocumentSummaryResponse`, `DOCUMENT_SUGGEST` carries `DocumentSuggestResponse`, `DOCUMENT_EXTRACT` carries `DocumentExtractRunResult` (not the extract route's detail body). Decode by `kind`; treat an unknown `kind` as a run this client did not start.",
"Present once `status` is `SUCCEEDED`: the body the queuing route answers with synchronously. `DOCUMENT_INDEX` carries `DocumentIndexResponse`, `LABS_OCR_EXTRACT` carries `OcrExtractResponse`, `DOCUMENT_SUMMARY` carries `DocumentSummaryResponse`, `DOCUMENT_SUGGEST` carries `DocumentSuggestResponse`, `DOCUMENT_EXTRACT` carries `DocumentExtractRunResult` (not the extract route's detail body), `SUPPLEMENT_LABEL_EXTRACT` (v1.43) carries `SupplementLabelExtractResult`. Decode by `kind`; treat an unknown `kind` as a run this client did not start.",
),
error: z
.object({
+118
View File
@@ -0,0 +1,118 @@
/**
* OpenAPI route table — AI usage levels (v1.43, #1217).
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. The request
* body reuses the runtime schema from `@/lib/validations/ai-usage`;
* `AiUsageState` is the block `GET /api/auth/me` publishes as `ai.usage`
* (see `AiAccountBlock` in `./profile`), and the PUT answers the same shape.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import { AI_USAGE_LEVELS, aiUsagePutSchema } from "@/lib/validations/ai-usage";
import {
dataEnvelope,
errorEnvelope,
notImplementedResponse,
stdResponses,
} from "./shared";
export const aiUsageLevel = z.enum(AI_USAGE_LEVELS).meta({
id: "AiUsageLevel",
description:
"How much background AI work an account asks for, in ceiling order. `frugal`: status cards regenerate at most every 24 h after a sync and every 6 h after a hand entry, a smaller snapshot, no background reasoning, a short daily briefing (`recommendations` holds at most one entry, every field stays present), no automatic document reading, no supplement label reading. `standard`: the operator's sync gap (default 6 h), 1 h after a hand entry, background reasoning at the job default. `full`: 1 h after any ingest (the behaviour before v1.43). The interactive Coach is the same at every level.",
});
export const aiUsageState = z
.object({
level: aiUsageLevel.describe("The level in effect for this record."),
preference: aiUsageLevel
.nullable()
.describe(
"The account's stored choice; `null` follows the instance default.",
),
defaultLevel: aiUsageLevel.describe(
"The level an account without its own choice runs at.",
),
maxLevel: aiUsageLevel.describe(
"The highest level the operator allows on this server.",
),
source: z
.enum(["user", "default", "admin_cap"])
.describe(
"Where `level` comes from: the stored choice, the instance default, or the operator's ceiling capping a stored choice above it (the choice is kept).",
),
options: z
.array(
z.object({
level: aiUsageLevel,
allowed: z.boolean().describe("`level` is at or below `maxLevel`."),
estimatedTokensPerDay: z
.number()
.int()
.nullable()
.describe(
"An estimate, from this account's measured use scaled to the level. `null` without three days of history. Show it as an estimate; never compute one on the client.",
),
}),
)
.length(3)
.describe("Always three entries, in the order frugal, standard, full."),
measuredTokensPerDay: z
.number()
.int()
.nullable()
.describe(
"Mean tokens per day over the last seven full days (Coach and background work together); `null` without history.",
),
measuredDays: z
.number()
.int()
.min(0)
.max(7)
.describe("How many days stand behind `measuredTokensPerDay`."),
})
.meta({
id: "AiUsageState",
description:
"The account's AI usage level, resolved on the server. Clients render the picker from `options` and never derive allowance or estimates themselves. Absent on servers older than v1.43.",
});
const aiUsagePutRequest = aiUsagePutSchema.meta({
id: "AiUsagePutRequest",
description:
"Set the account's own AI usage level; `null` returns to the instance default. A level above `maxLevel` is refused, never capped silently.",
});
export const aiUsagePaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/auth/me/ai-usage": {
put: {
tags: ["Profile"],
summary: "Set the AI usage level (v1.43)",
description:
"Own account only: a cookie session or a full-scope token. Answers the resolved `AiUsageState`. 422 `ai.usage.invalid` for a malformed body; 422 `ai.usage.aboveCeiling` with `meta.maxLevel` for a level above the operator's ceiling; 429 `ai.usage.rateLimited` after 30 changes in an hour.",
requestBody: {
required: true,
content: { "application/json": { schema: aiUsagePutRequest } },
},
responses: {
"200": {
description: "The resolved AI usage state after the change.",
content: {
"application/json": {
schema: dataEnvelope(aiUsageState, "PutAiUsageResponse"),
},
},
},
...stdResponses,
"422": {
description:
"`ai.usage.invalid` (body) or `ai.usage.aboveCeiling` (`meta.maxLevel` names the ceiling).",
content: { "application/json": { schema: errorEnvelope } },
},
...notImplementedResponse,
},
},
},
};
+59
View File
@@ -10,6 +10,7 @@
* Schemas come from `src/lib/validations/*` where shared with the
* runtime request parsing, so the wire contract stays single-source.
*/
import { HANDOVER_METHODS, claimSsoSchema } from "@/lib/validations/claim-sso";
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import {
@@ -42,6 +43,7 @@ import {
} from "@/lib/validations/step-up";
import {
dataEnvelope,
notImplementedResponse,
profileEmailRateLimitResponse,
stdResponses,
errorEnvelope,
@@ -730,9 +732,28 @@ const claimPreview = z
.describe(
"Each guardian and the access they keep after the claim, as proposed. The new owner confirms or changes it after signing in.",
),
methods: z
.array(z.enum(HANDOVER_METHODS))
.optional()
.describe(
"v1.43 — how this link can be redeemed: `password` and/or `sso`. Absent on older servers.",
),
ssoProviderName: z
.string()
.nullable()
.optional()
.describe(
'v1.43 — the identity provider\'s display name for the "Continue with …" button; null without one.',
),
})
.meta({ id: "ProfileClaimPreview" });
const claimSsoRequest = claimSsoSchema.meta({
id: "ProfileClaimSsoRequest",
description:
"`token` is the `hlp_` token from the handover link, in the body; `username` follows the registration rules and may not start with `managed-`.",
});
const claimRequest = claimSchema.meta({
id: "ProfileClaimRequest",
description:
@@ -740,6 +761,44 @@ const claimRequest = claimSchema.meta({
});
export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/auth/claim/sso": {
post: {
// No credential: the token in the body is the credential.
security: [],
tags: ["Auth"],
summary: "Claim a managed profile through single sign-on (v1.43)",
description:
"Web only. Checks the link and the username, sets a sealed `hl_claim` cookie (10 minutes, `httpOnly`) and answers where to continue: `/api/auth/oidc/login?intent=claim`. The OIDC callback binds the identity provider's subject to the profile and signs the new owner in; a subject already bound to an account, or a verified email another account holds, is refused there. Works on a single-sign-on-only instance. 404 `profile_claim.invalid` (no usable link) or `profile_claim.sso_unavailable` (no identity provider configured); 409 `profile_claim.taken` (username) or `auth.already_authenticated`; rate-limited per source address like the password claim.",
requestBody: {
required: true,
content: { "application/json": { schema: claimSsoRequest } },
},
responses: {
"200": {
description: "Continue at `redirectTo`.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ redirectTo: z.string() }),
"ProfileClaimSsoEnvelope",
),
},
},
},
"404": {
description:
"`profile_claim.invalid` or `profile_claim.sso_unavailable`.",
content: { "application/json": { schema: errorEnvelope } },
},
"409": {
description: "`profile_claim.taken` or `auth.already_authenticated`.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
...notImplementedResponse,
},
},
},
"/api/auth/claim/preview": {
post: {
// No credential: the person claiming the profile has none yet. The
+7
View File
@@ -9,6 +9,7 @@
* under the owner's name — and both are read-only in fact as well as in
* declaration.
*/
import { recordBenchmark } from "./trend-verdict";
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
@@ -54,6 +55,12 @@ const personalRecord = z
source: measurementSourceEnum,
externalId: z.string().nullable(),
createdAt: z.iso.datetime({ offset: true }),
benchmark: recordBenchmark
.nullable()
.optional()
.describe(
"v1.43 — an age- and sex-aware comparison band where a cited norm exists, in the record's unit; null otherwise.",
),
})
.meta({
id: "PersonalRecord",
+83 -2
View File
@@ -18,12 +18,18 @@ import {
createBiomarkerSchema,
updateBiomarkerSchema,
} from "@/lib/validations/biomarkers";
import {
BIOMARKER_MERGE_CONVERSIONS,
BIOMARKER_MERGE_REFUSALS,
biomarkerMergeSchema,
} from "@/lib/validations/biomarker-merge";
import {
dataEnvelope,
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
notImplementedResponse,
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
@@ -57,6 +63,12 @@ const biomarkerRow = z
hasContext: z.boolean(),
context: z.string().nullable(),
hidden: z.boolean(),
aliases: z
.array(z.string())
.optional()
.describe(
"v1.43 — other spellings that resolve to this marker (folded in by a merge, chosen at an import, or typed on the marker), sorted, without the marker's own name. Absent on older servers.",
),
createdAt: z.string(),
updatedAt: z.string(),
})
@@ -79,11 +91,47 @@ const conflict = {
const notFound = {
"404": {
description: "Biomarker not found (or owned by another user).",
description:
"Biomarker not found (or owned by another user). `meta.errorCode` = `labs.biomarker.notFound`. Since v1.43 a marker that was merged into another answers with `meta.mergedInto` naming the marker it went into: drop the cached marker and open that one.",
content: { "application/json": { schema: errorEnvelope } },
},
};
const biomarkerMergeRequest = biomarkerMergeSchema.meta({
id: "BiomarkerMergeRequest",
description:
"Fold `sourceIds` (1..10, unique, without the target) into the marker in the path. `dryRun` (default false) answers the same result and writes nothing; a client shows it before asking to confirm, because a merge cannot be undone.",
});
const biomarkerMergeResult = z
.object({
dryRun: z.boolean(),
target: biomarkerRow.describe(
"The marker after the merge (unchanged on a dry run).",
),
sources: z.array(
z.object({
id: z.string(),
name: z.string(),
unit: z.string(),
readingCount: z.number().int(),
conversion: z.enum(BIOMARKER_MERGE_CONVERSIONS),
refusal: z.enum(BIOMARKER_MERGE_REFUSALS).nullable(),
}),
),
movedReadings: z.number().int(),
convertedReadings: z.number().int(),
aliasesAdded: z
.array(z.string())
.describe("Names that become aliases of the target."),
mergeId: z.string().nullable().describe("`null` on a dry run."),
})
.meta({
id: "BiomarkerMergeResult",
description:
"What a merge did, or would do on a dry run. Readings in another unit are converted where the analyte's conversion table knows it; the printed value stays in `sourceValue` / `sourceUnit` on each reading.",
});
export const biomarkerPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/biomarkers": {
get: {
@@ -181,9 +229,10 @@ export const biomarkerPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Labs"],
summary: "Delete a biomarker",
description:
"Hard-deletes the catalog definition. The `onDelete: SetNull` FK unlinks every reading (they keep their legacy `analyte` / `unit`), so the history survives. Audits as `biomarker.delete`.",
"Hard-deletes the catalog definition together with every reading filed under it, in one transaction. Since v1.43 it needs the same record authority as the edit (manage on the labs section), so a manager of a shared record may delete and the owner is unaffected. Audits as `biomarker.delete` with the marker's name, unit and the number of readings removed.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
...recordRefusal(),
"200": {
description: "Deletion succeeded.",
content: {
@@ -200,4 +249,36 @@ export const biomarkerPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/biomarkers/{id}/merge": {
post: {
tags: ["Labs"],
summary: "Merge markers into this one (v1.43)",
description:
"Moves every reading of the source markers onto the marker in the path, converting units where the analyte's conversion table knows them, turns the source names into aliases and deletes the sources, in one transaction. A source in a unit without a known conversion refuses the whole merge with 422 `labs.biomarker.merge.unitMismatch` (`meta.sources` names each `{ id, unit, targetUnit, reason }`) and nothing moves. 404 `labs.biomarker.notFound` with `meta.ids` when the target or a source is missing; 422 `labs.biomarker.merge.invalid` for a malformed body or a target among the sources. No undo. Only when `features.biomarkerMerge` is true.",
parameters: [idempotencyKeyParameter],
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
content: { "application/json": { schema: biomarkerMergeRequest } },
},
responses: {
...idempotentWrite(),
"200": {
description: "The merge result (or the dry-run preview).",
content: {
"application/json": {
schema: dataEnvelope(
biomarkerMergeResult,
"MergeBiomarkersResponse",
),
},
},
},
...notFound,
...stdResponses,
...recordWriteRateLimitResponse,
...notImplementedResponse,
},
},
},
};
+40 -1
View File
@@ -5,6 +5,7 @@
* Schemas come from `src/lib/validations/*` where shared with the
* runtime request parsing, so the wire contract stays single-source.
*/
import { cycleDeleteOutcome, cycleRestoredStart } from "./trend-verdict";
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import {
@@ -327,9 +328,20 @@ const cycleHistoryResponse = z.object({
}),
});
const cycleDeleteOutcomeEnvelope = dataEnvelope(
cycleDeleteOutcome,
"CycleDeleteOutcomeEnvelope",
);
const cyclePeriodResponse = z.object({
cycle: menstrualCycleDto.nullable(),
dayLog: cycleDayLogDto.nullable(),
absorbed: cycleRestoredStart
.nullable()
.optional()
.describe(
'v1.43 — on `action: "start"`: a later start within the period length folded into this one, or null.',
),
});
const cyclePhaseCrosstabRow = z
@@ -767,7 +779,20 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "Day-log updated.",
content: {
"application/json": {
schema: dataEnvelope(cycleDayLogDto, "CycleDayLogPatchEnvelope"),
schema: dataEnvelope(
z.intersection(
cycleDayLogDto,
z.object({
restored: z
.array(cycleRestoredStart)
.optional()
.describe(
"v1.43 — starts given back because this edit cleared the flow on a start day.",
),
}),
),
"CycleDayLogPatchEnvelope",
),
},
},
},
@@ -786,6 +811,13 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
"Sets `deletedAt` + bumps `syncVersion`; surfaces as a tombstone on the next `/api/sync/changes` page. 204. Idempotent.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description:
"v1.43 — with `Prefer: return=representation`: what the delete changed.",
content: {
"application/json": { schema: cycleDeleteOutcomeEnvelope },
},
},
"204": { description: "Soft-deleted (no body)." },
"404": {
description: "Day-log not found / not owned.",
@@ -941,6 +973,13 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
"Sets `deletedAt` + bumps `syncVersion`; tombstones on the next sync page. 204. Idempotent.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description:
"v1.43 — with `Prefer: return=representation`: what the delete changed.",
content: {
"application/json": { schema: cycleDeleteOutcomeEnvelope },
},
},
"204": { description: "Soft-deleted (no body)." },
"404": {
description: "Cycle not found / not owned.",
+8
View File
@@ -14,6 +14,7 @@
* the single-source `@/lib/dashboard-layout` module so the contract cannot
* drift. Part of the OpenAPI route table; aggregated in `./index.ts`.
*/
import { trendSentiment, trendVerdict } from "./trend-verdict";
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
@@ -243,6 +244,13 @@ const dashboardMetricCard = z
.describe(
"The metric's newest reading, for the 'last reading N days ago' caption when it is older than the sparkline window.",
),
direction: trendVerdict
.nullable()
.optional()
.describe("v1.43 — which way a change of this metric counts."),
trendSentiment: trendSentiment
.optional()
.describe("v1.43 — the verdict applied to this card's `trend`."),
})
.meta({
id: "DashboardMetricCard",
+315
View File
@@ -0,0 +1,315 @@
/**
* OpenAPI route table — Dawarich movement context (v1.43).
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. Request bodies
* reuse the runtime schemas from `@/lib/validations/dawarich`. Daily values
* only; no coordinate or place name is stored. Every route belongs to the
* `environment` module and is the caller's own account. The day block
* (`DayMovement`) is published with `GET /api/day/{date}`.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import {
dawarichConnectSchema,
dawarichDisconnectSchema,
dawarichSettingsSchema,
dawarichSyncSchema,
} from "@/lib/validations/dawarich";
import { connectionHealth } from "./integrations";
import {
dataEnvelope,
errorEnvelope,
moduleDisabledResponse,
notImplementedResponse,
stdResponses,
} from "./shared";
const dawarichConnectRequest = dawarichConnectSchema.meta({
id: "DawarichConnectRequest",
description:
"`url` (http or https, at most 2048) and `apiKey` (1..512); both sealed at rest. A private host needs its origin in `DAWARICH_PRIVATE_ORIGINS`.",
});
const dawarichSettings = dawarichSettingsSchema.meta({
id: "DawarichSettings",
description:
"The home place (an id on the Dawarich instance) and the home country (ISO 3166-1 alpha-2). Without a home country the most frequent country of the last 365 days is used, and without one there is no travel day.",
});
const dawarichStatus = z
.object({
connected: z.boolean(),
configured: z.boolean(),
homePlaceSet: z.boolean(),
homeCountry: z.string().nullable(),
daysStored: z.number().int(),
oldestDay: z.string().nullable(),
latestDay: z.string().nullable(),
health: connectionHealth.nullable(),
})
.meta({
id: "DawarichStatus",
description:
"The Dawarich connection. `health` is the shape every connection publishes; there are no legacy ledger fields.",
});
const travelSuggestion = z
.object({
id: z.string(),
startDate: z.string(),
endDate: z.string(),
countryCode: z.string(),
})
.meta({
id: "TravelSuggestion",
description:
"A run of consecutive travel days no stored trip covers. Accept it through `POST /api/environment/travel` (the person picks the place).",
});
const responses = (
ok: Record<string, unknown>,
extra: Record<string, unknown> = {},
) => ({
...ok,
...moduleDisabledResponse,
...extra,
...stdResponses,
...notImplementedResponse,
});
const errorOnly = (description: string) => ({
description,
content: { "application/json": { schema: errorEnvelope } },
});
export const dawarichPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/dawarich/connect": {
post: {
tags: ["Integrations"],
summary: "Connect Dawarich (v1.43)",
description:
"Checks the key against the instance, stores the connection sealed and starts a 30-day backfill. 422 `dawarich.invalid`, `private_origin_not_approved`, `dawarich.credentials_rejected` or `dawarich.unreachable`; 429 `dawarich.rate_limited` (ten a minute).",
requestBody: {
required: true,
content: { "application/json": { schema: dawarichConnectRequest } },
},
responses: responses({
"200": {
description: "Connected.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ connected: z.literal(true) }),
"DawarichConnectEnvelope",
),
},
},
},
}),
},
},
"/api/dawarich/status": {
get: {
tags: ["Integrations"],
summary: "Dawarich connection status (v1.43)",
responses: responses({
"200": {
description: "The connection.",
content: {
"application/json": {
schema: dataEnvelope(dawarichStatus, "DawarichStatusEnvelope"),
},
},
},
}),
},
},
"/api/dawarich/places": {
get: {
tags: ["Integrations"],
summary: "Places on the Dawarich instance (v1.43)",
description:
"Read live from Dawarich to pick the home place (at most 200, filtered by `q`); never stored. 409 `dawarich.not_connected`; 502 `dawarich.unreachable`.",
requestParams: {
query: z.object({ q: z.string().max(80).optional() }),
},
responses: responses(
{
"200": {
description: "The places.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({
places: z.array(
z.object({ id: z.number().int(), name: z.string() }),
),
}),
"DawarichPlacesEnvelope",
),
},
},
},
},
{
"409": errorOnly("`dawarich.not_connected`."),
"502": errorOnly("`dawarich.unreachable`."),
},
),
},
},
"/api/dawarich/settings": {
patch: {
tags: ["Integrations"],
summary: "Dawarich home place and country (v1.43)",
description:
"422 `dawarich.invalid`; 422 `dawarich.home_place_unknown` for a place id the instance does not have.",
requestBody: {
required: true,
content: { "application/json": { schema: dawarichSettings } },
},
responses: responses({
"200": {
description: "The settings after the change.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({
homePlaceId: z.number().int().nullable(),
homeCountry: z.string().nullable(),
}),
"DawarichSettingsEnvelope",
),
},
},
},
}),
},
},
"/api/dawarich/test": {
post: {
tags: ["Integrations"],
summary: "Probe the Dawarich connection (v1.43)",
description:
"Answers the shared integration probe shape; a failed probe is an error status. The probe classes are those of the other `/test` routes.",
responses: responses({
"200": {
description: "The probe reached the instance.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({
ok: z.literal(true),
lastSyncedAt: z.iso.datetime({ offset: true }).nullable(),
latencyMs: z.number().int(),
}),
"DawarichProbeEnvelope",
),
},
},
},
}),
},
},
"/api/dawarich/sync": {
post: {
tags: ["Integrations"],
summary: "Fetch Dawarich daily values again (v1.43)",
description:
"From `fromDate` (at most 365 days back). 409 `dawarich.not_connected`; 422 `dawarich.invalid`.",
requestBody: {
required: false,
content: { "application/json": { schema: dawarichSyncSchema } },
},
responses: responses(
{
"202": {
description: "Queued.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ enqueued: z.boolean() }),
"DawarichSyncEnvelope",
),
},
},
},
},
{ "409": errorOnly("`dawarich.not_connected`.") },
),
},
},
"/api/dawarich/disconnect": {
post: {
tags: ["Integrations"],
summary: "Disconnect Dawarich (v1.43)",
description:
"Forgets the connection; with `deleteData: true` the stored days go too.",
requestBody: {
required: false,
content: { "application/json": { schema: dawarichDisconnectSchema } },
},
responses: responses({
"200": {
description: "Disconnected.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({
disconnected: z.literal(true),
deletedDays: z.number().int(),
}),
"DawarichDisconnectEnvelope",
),
},
},
},
}),
},
},
"/api/environment/travel/suggestions": {
get: {
tags: ["Environment"],
summary: "Suggested trips from Dawarich travel days (v1.43)",
responses: responses({
"200": {
description: "The suggestions.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ suggestions: z.array(travelSuggestion) }),
"TravelSuggestionsEnvelope",
),
},
},
},
}),
},
},
"/api/environment/travel/suggestions/{id}/dismiss": {
post: {
tags: ["Environment"],
summary: "Dismiss a suggested trip (v1.43)",
description: "404 `environment.travel_suggestion.not_found`.",
requestParams: { path: z.object({ id: z.string() }) },
responses: responses(
{
"200": {
description: "Dismissed.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ dismissed: z.literal(true) }),
"TravelSuggestionDismissEnvelope",
),
},
},
},
},
{ "404": errorOnly("`environment.travel_suggestion.not_found`.") },
),
},
},
};
+28 -1
View File
@@ -14,6 +14,7 @@
* stores until the confirm route commits the user's approvals; a low-confidence
* fact fails closed.
*/
import { withRelated } from "./relation-links";
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
@@ -169,6 +170,32 @@ const extractedFact = z
provenance: factProvenance,
committedRecordId: z.string().nullable(),
committedRecordType: z.string().nullable(),
biomarkerId: z
.string()
.nullable()
.optional()
.describe(
"v1.43, OBSERVATION only — the marker matched automatically (name, alias or catalogue abbreviation); null without a match.",
),
matchKind: z
.enum(["name", "alias", "catalog"])
.nullable()
.optional()
.describe("v1.43, OBSERVATION only — how `biomarkerId` was found."),
suggestions: z
.array(
z.object({
biomarkerId: z.string(),
name: z.string(),
unit: z.string(),
matchKind: z.enum(["catalog", "similar"]),
}),
)
.max(3)
.optional()
.describe(
"v1.43, OBSERVATION only — up to three markers to choose from when nothing matched.",
),
})
.meta({
id: "ExtractedFact",
@@ -862,7 +889,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: {
"application/json": {
schema: dataEnvelope(
inboundDocumentDetail,
withRelated(inboundDocumentDetail),
"InboundDocumentDetailEnvelope",
),
},
+5 -1
View File
@@ -14,6 +14,7 @@
* reaches no response, because every preventive-care read excludes it. The
* published contract stays at two members deliberately.
*/
import { withRelated } from "./relation-links";
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
@@ -465,7 +466,10 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "The visit.",
content: {
"application/json": {
schema: dataEnvelope(encounter, "GetEncounterEnvelope"),
schema: dataEnvelope(
withRelated(encounter),
"GetEncounterEnvelope",
),
},
},
},
+5 -1
View File
@@ -13,6 +13,7 @@
* the module off (or an operator-disabled instance) 403s with
* `errorCode:"illness.disabled"` even with a valid Bearer token.
*/
import { withRelated } from "./relation-links";
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
@@ -350,7 +351,10 @@ export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "The episode.",
content: {
"application/json": {
schema: dataEnvelope(illnessEpisode, "GetIllnessEpisodeEnvelope"),
schema: dataEnvelope(
withRelated(illnessEpisode),
"GetIllnessEpisodeEnvelope",
),
},
},
},
+13
View File
@@ -28,6 +28,10 @@ import {
recordScopeParameter,
} from "./account-sharing";
import { adminDiagnosticPaths, adminInvitePaths } from "./admin";
import { aiUsagePaths } from "./ai-usage";
import { dawarichPaths } from "./dawarich";
import { lifeCircumstancesPaths } from "./life-circumstances";
import { relationLinkPaths } from "./relation-links";
import { adminBackupPaths } from "./admin-backups";
import { allergyPaths } from "./allergies";
import { analyticsPaths } from "./analytics";
@@ -71,6 +75,7 @@ import { measurementPaths } from "./measurements";
import { measurementReminderPaths } from "./measurement-reminders";
import { mentalHealthPaths } from "./mental-health";
import {
medicationIngredientPaths,
medicationPaths,
medicationResource,
medicationDoseHistoryImportFatalReasonEnum,
@@ -187,6 +192,14 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
// order is load-bearing).
...dayPaths,
...searchPaths,
// v1.43 — the new contract surfaces: AI usage levels, supplement
// composition, life circumstances and AUDIT-C, Dawarich movement context and
// the relation links (appended, spread order is load-bearing).
...aiUsagePaths,
...medicationIngredientPaths,
...lifeCircumstancesPaths,
...dawarichPaths,
...relationLinkPaths,
// The environmental-context overview. Its own module because nothing else
// owns the surface (appended, spread order is load-bearing).
//
@@ -5,6 +5,7 @@
* Schemas come from `src/lib/validations/*` where shared with the
* runtime request parsing, so the wire contract stays single-source.
*/
import { trendSentiment, trendVerdict } from "../trend-verdict";
import { z } from "zod/v4";
import { measurementSourceEnum, measurementTypeEnum } from "../shared";
import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry";
@@ -1258,6 +1259,12 @@ export const dashboardSnapshotResponse = z
"Server-computed band / target math resolved from the user's profile facts, so the client reads these numbers instead of recomputing them. Every sub-field is null only when the driving profile fact is missing (no date of birth for blood pressure, no height and no weight target for weight).",
),
tiles: z.object({
directions: z
.record(z.string(), trendVerdict)
.optional()
.describe(
"v1.43 — per measurement type, which way a change counts. `weightTrend` stays.",
),
summaries: dataSummaryRecord,
lastSeenByType: z.record(z.string(), z.unknown()),
mood: z.object({
@@ -2478,6 +2485,14 @@ const targetItem = z
.describe(
"Per-tile extras. Only the medication-compliance tile carries any today; every other tile omits the key.",
),
direction: trendVerdict
.nullable()
.optional()
.describe("v1.43 — which way a change of this target counts."),
trendSentiment: trendSentiment
.nullable()
.optional()
.describe("v1.43 — the verdict applied to this tile's change."),
})
.meta({
id: "TargetItem",
+7
View File
@@ -252,6 +252,13 @@ const appleHealthMetricFreshnessEntry = metricFreshnessEntry
.describe(
'When a batch with `syncTrigger: "background"` last carried this type (since v1.42.4). A later `foreground`, `push` or `manual` batch moves `lastReceivedAt` and `lastTrigger` but leaves this standing, so opening the app does not hide an earlier background arrival. Null when the type has never arrived in the background as far as the server recorded: before v1.42.4 only the latest arrival was kept, so a type whose latest arrival then was a background one starts with that date and every other type starts null. Same omission rules as `lastReceivedAt`.',
),
lastBackfillAt: z.iso
.datetime({ offset: true })
.nullable()
.optional()
.describe(
'v1.43 — when a batch with `syncTrigger: "backfill"` last carried this type. A backfill moves only this and `syncProgress`, never `lastReceivedAt`, `lastTrigger`, `lastBackgroundAt` or the account-level sync stamps, so catching up history does not read as a run of healthy daily syncs. Absent on older servers.',
),
})
.meta({
id: "AppleHealthMetricFreshnessEntry",
+5 -1
View File
@@ -7,6 +7,7 @@
* here (the route serialises a derived `rangeStatus` + `hasNote` the input
* schema doesn't carry).
*/
import { withRelated } from "./relation-links";
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
@@ -233,7 +234,10 @@ export const labsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "Lab-result detail.",
content: {
"application/json": {
schema: dataEnvelope(labResultDetail, "GetLabResultResponse"),
schema: dataEnvelope(
withRelated(labResultDetail),
"GetLabResultResponse",
),
},
},
},
@@ -0,0 +1,356 @@
/**
* OpenAPI route table — life circumstances and AUDIT-C (v1.43, #614).
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. Request bodies
* reuse the runtime schemas from `@/lib/validations/life-circumstances`. Every
* route here is new: the new fact kinds never appear on
* `GET /api/anamnesis/facts`, and AUDIT-C never on
* `/api/mental-health/assessments`, because older app builds decode both
* lists closed. Only when `features.lifeCircumstances` is true.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import {
AUDIT_C_CUTOFF_SOURCES,
LIFE_AI_SECTIONS,
LIFE_FACT_KINDS,
LIFE_SECTION_KEYS,
auditCRequestSchema,
lifeAiSectionsPatchSchema,
lifeConfirmSchema,
} from "@/lib/validations/life-circumstances";
import {
dataEnvelope,
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
notImplementedResponse,
recordRefusal,
stdResponses,
} from "./shared";
const lifeFactRevision = z
.object({
id: z.string(),
kind: z.enum(["SMOKING_STATUS", "SHIFT_SCHEDULE", ...LIFE_FACT_KINDS]),
value: z
.unknown()
.describe(
"`SMOKING_STATUS` and `SHIFT_SCHEDULE` carry their string value; every other kind an object with `schema: 1` whose shape is fixed per kind (see the life-circumstances contract). `null` when `unreadable`.",
),
unreadable: z
.boolean()
.describe("The sealed value could not be opened on this host."),
validFrom: z.iso.datetime({ offset: true }),
validUntil: z.null(),
provenance: z.enum(["USER_REPORTED", "USER_CORRECTION"]),
createdAt: z.iso.datetime({ offset: true }),
})
.meta({
id: "LifeFactRevision",
description: "The current revision of one life-circumstance fact.",
});
const auditCCutoff = z.object({
value: z.number().int(),
sex: z.enum(["male", "female"]),
source: z.enum(AUDIT_C_CUTOFF_SOURCES),
sourceLabel: z.string(),
sourceUrl: z.string(),
});
const auditCResult = z
.object({
id: z.string(),
takenAt: z.iso.datetime({ offset: true }),
items: z.tuple([z.number().int(), z.number().int(), z.number().int()]),
totalScore: z.number().int().min(0).max(12),
locale: z.string(),
cutoff: auditCCutoff
.nullable()
.describe(
"The screening cut-off for the entry's language and the profile's sex (German S3 guideline, Korean KNHANES, else Bradley 2007). `null` when the profile has no sex; show the sum only.",
),
aboveCutoff: z.boolean().nullable(),
validatedLocale: z
.boolean()
.describe(
"Whether the item wording shown was a validated translation for `locale`.",
),
})
.meta({
id: "AuditCResult",
description:
"One AUDIT-C entry, scored on the server. A screening result, not a diagnosis.",
});
const screeningHint = z.object({
id: z.enum(["lung_ldct", "aaa_ultrasound"]),
status: z.enum(["suggested", "not_eligible", "unknown"]),
reasonKeys: z.array(z.string()),
region: z.enum(["DE", "US"]),
sourceLabel: z.string(),
sourceUrl: z.string(),
});
const lifeCircumstances = z
.object({
sections: z
.array(
z.object({
key: z.enum(LIFE_SECTION_KEYS),
aiSection: z.enum([
"SMOKING_STATUS",
"ALCOHOL_PATTERN",
"ACTIVITY",
"WORK",
"HABITS",
"GOALS",
]),
aiIncluded: z.boolean(),
lastConfirmedAt: z.iso.datetime({ offset: true }).nullable(),
reviewDue: z
.boolean()
.describe("Older than 365 days; show the quiet yearly check."),
facts: z.array(lifeFactRevision),
}),
)
.describe("Always all six, in this order."),
extraAiSections: z.object({ SHIFT_SCHEDULE: z.boolean() }),
derived: z.object({
packYears: z.object({
value: z.number().nullable(),
status: z.enum(["computed", "not_applicable", "missing_inputs"]),
}),
activityMinutesPerWeek: z.object({
value: z.number().int().nullable(),
meetsWho150: z.boolean().nullable(),
measuredStepsAvailable: z.boolean(),
}),
freeWeekdays: z.object({
days: z.array(z.number().int().min(1).max(7)),
source: z.enum(["fact", "default_weekend"]),
}),
screeningHints: z.array(screeningHint),
}),
auditC: z.object({
latest: auditCResult.nullable(),
history: z
.array(
z.object({
id: z.string(),
takenAt: z.iso.datetime({ offset: true }),
totalScore: z.number().int(),
}),
)
.describe("Newest first, at most 20."),
offerDue: z.boolean(),
instrument: z.object({
items: z
.array(
z.object({
stem: z.string(),
options: z.array(
z.object({ value: z.number().int(), label: z.string() }),
),
}),
)
.describe(
"The three items verbatim in the account language; render as delivered.",
),
locale: z.string(),
validatedLocale: z.boolean(),
standardDrink: z.string(),
attribution: z.string(),
}),
}),
takeOver: z.array(
z.object({
field: z.enum(["conditions", "allergies"]),
freeTextPresent: z.boolean(),
structuredCount: z.number().int(),
available: z.boolean(),
}),
),
})
.meta({
id: "LifeCircumstances",
description:
"The six life-circumstance sections with their answers, AI switches and review state, the derived values with their sources, AUDIT-C, and the take-over hints for the free-text conditions and allergies. Every number is resolved on the server.",
});
const lifeAiSectionsPatchRequest = lifeAiSectionsPatchSchema.meta({
id: "LifeAiSectionsPatchRequest",
});
const lifeConfirmRequest = lifeConfirmSchema.meta({
id: "LifeConfirmRequest",
});
const auditCRequest = auditCRequestSchema.meta({ id: "AuditCRequest" });
export const lifeCircumstancesPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/anamnesis/life-circumstances": {
get: {
tags: ["Anamnesis"],
summary: "Life circumstances (v1.43)",
description:
"The life-circumstance sections of the record. Delegates read it under the `profile` grant, like the facts.",
responses: {
...recordRefusal(),
"200": {
description: "The life circumstances.",
content: {
"application/json": {
schema: dataEnvelope(
lifeCircumstances,
"LifeCircumstancesEnvelope",
),
},
},
},
...stdResponses,
...notImplementedResponse,
},
},
},
"/api/anamnesis/life-circumstances/ai-sections": {
patch: {
tags: ["Anamnesis"],
summary: "Switch life-circumstance sections for the AI (v1.43)",
description:
"Sections not named keep their state. Own account only. 422 `anamnesis.lifeSections.invalid`; 409 `about_me_conflict` for a stale `baseUpdatedAt`.",
requestBody: {
required: true,
content: {
"application/json": { schema: lifeAiSectionsPatchRequest },
},
},
responses: {
"200": {
description: "The switches after the change.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({
aiSections: z.partialRecord(
z.enum(LIFE_AI_SECTIONS),
z.boolean(),
),
updatedAt: z.iso.datetime({ offset: true }),
}),
"LifeAiSectionsEnvelope",
),
},
},
},
"409": {
description: "`about_me_conflict`.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
...notImplementedResponse,
},
},
},
"/api/anamnesis/life-circumstances/confirm": {
post: {
tags: ["Anamnesis"],
summary: "Confirm a life-circumstance section is still true (v1.43)",
description:
"Stores the confirmation instant only, never a new revision. Own account only. 422 `anamnesis.lifeConfirm.invalid`.",
requestBody: {
required: true,
content: { "application/json": { schema: lifeConfirmRequest } },
},
responses: {
"200": {
description: "The confirmed section.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({
section: z.enum(LIFE_SECTION_KEYS),
lastConfirmedAt: z.iso.datetime({ offset: true }),
}),
"LifeConfirmEnvelope",
),
},
},
},
...stdResponses,
...notImplementedResponse,
},
},
},
"/api/anamnesis/audit-c": {
post: {
tags: ["Anamnesis"],
summary: "Record an AUDIT-C (v1.43)",
description:
"Scores the three items on the server. A replay with the same `externalId` answers 200 with the stored result. 422 `anamnesis.auditC.invalid`; 429 `anamnesis.auditC.rateLimited` after 30 in an hour.",
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
content: { "application/json": { schema: auditCRequest } },
},
responses: {
...idempotentWrite(),
"200": {
description: "A replay of an entry already stored.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ result: auditCResult }),
"AuditCReplayEnvelope",
),
},
},
},
"201": {
description: "The stored and scored entry.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ result: auditCResult }),
"AuditCCreatedEnvelope",
),
},
},
},
...stdResponses,
...notImplementedResponse,
},
},
},
"/api/anamnesis/audit-c/{id}": {
delete: {
tags: ["Anamnesis"],
summary: "Remove an AUDIT-C entry (v1.43)",
description: "Soft delete. 404 `anamnesis.auditC.notFound`.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "The removed entry.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({
id: z.string(),
removedAt: z.iso.datetime({ offset: true }),
}),
"AuditCRemovedEnvelope",
),
},
},
},
"404": {
description: "`anamnesis.auditC.notFound`.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
...notImplementedResponse,
},
},
},
};
+24
View File
@@ -5,6 +5,12 @@
* Schemas come from `src/lib/validations/*` where shared with the
* runtime request parsing, so the wire contract stays single-source.
*/
import {
seriesChange,
seriesReferenceLine,
trendVerdict,
recordBenchmark,
} from "./trend-verdict";
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { updateMeasurementSchema } from "@/lib/validations/measurement";
@@ -657,6 +663,12 @@ const personalRecordResource = z
.describe(
"When the detection worker wrote the row — distinct from `achievedAt`, which is when the reading happened.",
),
benchmark: recordBenchmark
.nullable()
.optional()
.describe(
"v1.43 — an age- and sex-aware comparison band where a cited norm exists, in the record's unit; null otherwise.",
),
})
.meta({
id: "PersonalRecord",
@@ -758,6 +770,18 @@ const seriesResponse = z
count: z.number().int().nonnegative(),
})
.describe("Summary over the returned points; all-zero when empty."),
direction: trendVerdict
.nullable()
.optional()
.describe("v1.43 — which way a change of this metric counts."),
change: seriesChange
.nullable()
.optional()
.describe("v1.43 — first and last day value; null under two days."),
referenceLines: z
.array(seriesReferenceLine)
.optional()
.describe("v1.43 — lines to draw; empty when there is nothing to draw."),
})
.meta({
id: "MeasurementsSeriesResponse",
@@ -8,3 +8,8 @@ export {
medicationDoseHistoryImportFatalReasonEnum,
} from "./schemas";
export { medicationPaths } from "./paths";
export {
medicationIngredient,
medicationIngredientPaths,
supplementLabelExtractResult,
} from "./ingredients";
@@ -0,0 +1,148 @@
/**
* OpenAPI route table — supplement composition (v1.43).
*
* Part of the medication route table; aggregated in `../index.ts`. The PUT
* body reuses the runtime schema from
* `@/lib/validations/medication-ingredients`.
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import { medicationIngredientsPutSchema } from "@/lib/validations/medication-ingredients";
import {
dataEnvelope,
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
moduleDisabledResponse,
notImplementedResponse,
stdResponses,
} from "../shared";
import { aiRunAccepted } from "../ai-run-accepted";
export const medicationIngredient = z
.object({
nutrient: z.string().describe("Nutrient catalogue code."),
amountPerUnit: z
.number()
.describe("Amount in one unit (tablet, capsule), canonical unit."),
unit: z.string().describe("The catalogue's canonical unit for the code."),
enteredAmount: z
.number()
.nullable()
.describe("The amount as entered when it was entered in another unit."),
enteredUnit: z
.string()
.nullable()
.describe("The input unit it was entered in (e.g. `IU`)."),
labelKey: z.string(),
label: z.string().describe("The nutrient's name in the account language."),
})
.meta({
id: "MedicationIngredient",
description:
"What one unit of a medication (usually a supplement) contains. Every taken dose adds `amountPerUnit × unitsPerDose` to the day's micronutrients under the source `SUPPLEMENT`.",
});
const medicationIngredientsPutRequest = medicationIngredientsPutSchema.meta({
id: "MedicationIngredientsPutRequest",
description:
"Replace the composition: 0..40 entries, one per nutrient code, `amount` > 0 in one of the code's `inputUnits` (`GET /api/nutrients/catalog`). `source` is `MANUAL` (default) or `LABEL_AI` after a label proposal was reviewed.",
});
export const supplementLabelExtractResult = z
.object({
ingredients: z.array(
z.object({
nutrient: z.string(),
amount: z.number(),
unit: z.string(),
confidence: z.enum(["high", "low"]),
printedText: z.string(),
}),
),
unmatched: z.array(z.object({ printedText: z.string() })),
servingNote: z
.string()
.nullable()
.describe('e.g. "per 2 capsules"; shown, never stored.'),
})
.meta({
id: "SupplementLabelExtractResult",
description:
"The `result` of an AI run of kind `SUPPLEMENT_LABEL_EXTRACT`: a proposal only. Nothing is stored until the composition is saved through `PUT /api/medications/{id}/ingredients`.",
});
export const medicationIngredientPaths: NonNullable<ZodOpenApiObject["paths"]> =
{
"/api/medications/{id}/ingredients": {
put: {
tags: ["Medications"],
summary: "Replace a supplement's composition (v1.43)",
description:
'Stores the composition per unit in canonical units and re-counts every taken dose of this medication into the day\'s micronutrients (`recompute.queued`). Needs the `nutrients` module (403 `module.disabled`, `meta.module: "nutrients"`). 404 `medications.notFound`; 422 `medications.ingredients.invalid` (body, duplicate or unknown code); 422 `medications.ingredients.unitMismatch` with `meta.index` and `meta.allowedUnits`.',
parameters: [idempotencyKeyParameter],
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
content: {
"application/json": { schema: medicationIngredientsPutRequest },
},
},
responses: {
...idempotentWrite(),
"200": {
description: "The stored composition.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({
ingredients: z.array(medicationIngredient),
recompute: z.object({ queued: z.boolean() }),
}),
"PutMedicationIngredientsResponse",
),
},
},
},
...moduleDisabledResponse,
"404": {
description: "Medication not found.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
...notImplementedResponse,
},
},
},
"/api/medications/{id}/ingredients/extract": {
post: {
tags: ["Medications"],
summary: "Read a supplement label (v1.43)",
description:
"Multipart `file` (JPEG, PNG, HEIC, WebP or PDF, at most 10 MB). 202 with an AI run; poll `GET /api/ai-runs/{id}` (kind `SUPPLEMENT_LABEL_EXTRACT`, result `SupplementLabelExtractResult`). Needs `ai.capabilities.supplementLabel.available` (refused like every AI capability, reason `usage_level` included) and the `nutrients` module. 422 `medications.ingredients.extract.fileType`, 413 `medications.ingredients.extract.fileTooLarge`, 429 `medications.ingredients.extract.rateLimited` (six an hour).",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
content: {
"multipart/form-data": {
schema: z.object({
file: z.string().meta({ format: "binary" }),
}),
},
},
},
responses: {
...aiRunAccepted,
...moduleDisabledResponse,
"413": {
description: "`medications.ingredients.extract.fileTooLarge`.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
...notImplementedResponse,
},
},
},
};
@@ -5,6 +5,8 @@
* Schemas come from `src/lib/validations/*` where shared with the
* runtime request parsing, so the wire contract stays single-source.
*/
import { relatedList } from "../relation-links";
import { medicationIngredient } from "./ingredients";
import { z } from "zod/v4";
import { baseUpdatedAtField } from "../shared";
import {
@@ -420,6 +422,12 @@ export const medicationListEntry = medicationResource
.describe(
"v1.37.19 — projected whole days the usable stock covers under the slot-aware burn rate (the same math the low-stock notification engine runs, so the wire and the push can never disagree). NULL = inventory tracking off or no consuming cadence derivable; 0 = tracking on, supply ran out. Read-only — computed, not stored.",
),
hasIngredients: z
.boolean()
.optional()
.describe(
"v1.43 — whether a composition per unit is stored (`GET /api/medications/{id}` carries it as `ingredients`). Absent on older servers.",
),
})
.meta({
id: "MedicationListEntry",
@@ -468,6 +476,13 @@ export const medicationDetailEntry = medicationResource
.describe(
"v1.37.19 — projected whole days the usable stock covers under the slot-aware burn rate; NULL = tracking off or no consuming cadence.",
),
ingredients: z
.array(medicationIngredient)
.optional()
.describe(
"v1.43 — the composition per unit, empty when none is stored. Absent on older servers.",
),
related: relatedList,
})
.meta({
id: "MedicationDetail",
+7
View File
@@ -19,6 +19,7 @@
* twin was in the YAML, which left grant-scope reviewers unable to see the
* cross-section read at all.
*/
import { moodPattern } from "./trend-verdict";
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
@@ -1197,6 +1198,12 @@ const moodAggregatesResponse = z
.describe(
"Mood against five health channels, keyed `sleep`, `steps`, `pulse`, `weight`, `bloodPressureSystolic`. Every key is always present; an empty channel carries `result: null` with no points.",
),
patterns: z
.array(moodPattern)
.optional()
.describe(
"v1.43 — the patterns that passed the gate over the `?days` window, on the account's mood day. Empty means none was found.",
),
})
.meta({
id: "MoodAggregates",
+51
View File
@@ -9,6 +9,7 @@
* per type from here (this is the documented home of that table).
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import { NUTRIENT_DEFINITIONS } from "@/lib/nutrients/catalog";
import {
@@ -18,6 +19,7 @@ import {
nutrientDailySeriesSchema,
nutrientOverviewQuerySchema,
nutrientOverviewSchema,
nutrientUpperLimitSchema,
nutrientWaterWriteSchema,
nutrientWaterWriteResponseSchema,
} from "@/lib/validations/nutrients";
@@ -26,6 +28,7 @@ import {
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
notImplementedResponse,
recordRefusal,
stdResponses,
} from "./shared";
@@ -49,7 +52,55 @@ const catalogTable = NUTRIENT_DEFINITIONS.map(
(d) => `| ${d.code} | ${d.hkIdentifier} | ${d.unit} |`,
).join("\n");
const nutrientCatalogEntry = z
.object({
code: z.string(),
unit: z.string().describe("The canonical unit amounts are stored in."),
labelKey: z.string(),
label: z.string().describe("The nutrient's name in the account language."),
syncable: z
.boolean()
.describe(
"Whether HealthKit may send this code in the nutrient batch. `dha` and `epa` are not syncable: they appear in responses only.",
),
inputUnits: z
.array(z.object({ unit: z.string(), factorToCanonical: z.number() }))
.describe(
"Units an amount may be entered in, with the factor to the canonical unit (vitamin D: `IU` = 0.025 µg).",
),
upperLimit: nutrientUpperLimitSchema.nullable(),
})
.meta({
id: "NutrientCatalogEntry",
description:
"One nutrient of the catalogue as the composition form uses it. Clients build the form from this and never convert units themselves.",
});
export const nutrientPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/nutrients/catalog": {
get: {
tags: ["Nutrients"],
summary: "The nutrient catalogue (v1.43)",
description:
"Every nutrient code with its canonical unit, label in the account language, input units and upper intake level. Behind the opt-in `nutrients` module (403 `module.disabled` when off).",
responses: {
...recordRefusal(NUTRIENTS_DISABLED_DESCRIPTION),
"200": {
description: "The catalogue.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ entries: z.array(nutrientCatalogEntry) }),
"NutrientCatalogEnvelope",
),
},
},
},
...stdResponses,
...notImplementedResponse,
},
},
},
"/api/nutrients/batch": {
post: {
parameters: [idempotencyKeyParameter],
+26
View File
@@ -82,6 +82,32 @@ const extractedRow = z
"The analyte the matched marker is (the catalog slug, e.g. `fasting-glucose` or `ldl`, or a registry-only key such as `urea`), or null when the marker does not exist yet or its name matches none. With `markerUnit` it tells a client whether a row in another unit can be converted on commit.",
}),
duplicateOf: z.string().nullable(),
biomarkerId: z
.string()
.nullable()
.optional()
.describe(
"v1.43 — the marker this row was matched to automatically (by name, by an alias of the account, or by a catalogue abbreviation); null without a match.",
),
matchKind: z
.enum(["name", "alias", "catalog"])
.nullable()
.optional()
.describe("v1.43 — how `biomarkerId` was found."),
suggestions: z
.array(
z.object({
biomarkerId: z.string(),
name: z.string(),
unit: z.string(),
matchKind: z.enum(["catalog", "similar"]),
}),
)
.max(3)
.optional()
.describe(
"v1.43 — up to three markers to choose from when nothing matched; empty when `biomarkerId` is set.",
),
})
.meta({
id: "OcrExtractedRow",
+10 -1
View File
@@ -5,6 +5,7 @@
* Schemas come from `src/lib/validations/*` where shared with the
* runtime request parsing, so the wire contract stays single-source.
*/
import { aiUsageState } from "./ai-usage";
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { notificationPrefsSchema } from "@/lib/validations/notification-prefs";
@@ -198,7 +199,15 @@ export const aiProviderState = z
});
export const aiAccountBlock = z
.object({ capabilities: aiCapabilities, provider: aiProviderState })
.object({
capabilities: aiCapabilities,
provider: aiProviderState,
usage: aiUsageState
.optional()
.describe(
"v1.43 — the account's AI usage level. Absent on older servers; hide the setting then.",
),
})
.meta({
id: "AiAccountBlock",
description:
+142
View File
@@ -0,0 +1,142 @@
/**
* OpenAPI route table — relation links (v1.43).
*
* Part of the OpenAPI route table; aggregated in `./index.ts`. The link
* bodies reuse the runtime schemas from `@/lib/validations/relation-links`.
* `RelatedLink` is the entry of the `related` list six detail responses carry
* (lab result, visit, document, condition episode, medication, vaccination).
*/
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import {
RELATED_KINDS,
RELATED_VIA,
labResultLinkSchema,
medicationLinkSchema,
} from "@/lib/validations/relation-links";
import {
dataEnvelope,
errorEnvelope,
notImplementedResponse,
stdResponses,
} from "./shared";
export const relatedLink = z
.object({
kind: z.enum(RELATED_KINDS),
id: z.string(),
label: z.string(),
date: z
.string()
.nullable()
.describe("`YYYY-MM-DD` or an ISO instant, as the record carries it."),
href: z
.string()
.describe(
"The web path of the related record, the same form as a push `url`.",
),
via: z
.enum(RELATED_VIA)
.describe(
"`direct` for a stored link; `encounter` when the relation runs through a visit both are filed against; `efficacyTarget` for a medication whose efficacy target is this lab marker.",
),
})
.meta({
id: "RelatedLink",
description:
"One record related to the one shown. Only kinds whose module is on and that the caller's sharing grant covers are listed. Skip an unknown `kind`.",
});
/** The optional `related` list a detail response carries since v1.43. */
export const relatedList = z
.array(relatedLink)
.optional()
.describe(
"v1.43 — records related to this one, resolved on the server. Absent on older servers.",
);
/**
* A detail response with its `related` list: the published component of the
* record (by `$ref`) and the list beside it, so list endpoints that share the
* component do not claim the field.
*/
export function withRelated<T extends z.ZodType>(detail: T) {
return z.intersection(detail, z.object({ related: relatedList }));
}
const linkResult = z
.object({
changed: z.number().int(),
unknown: z
.array(z.string())
.describe("Ids that name nothing this account owns; nothing failed."),
})
.meta({ id: "RelationLinkResult" });
const medicationLinkRequest = medicationLinkSchema.meta({
id: "MedicationLinkRequest",
});
const labResultLinkRequest = labResultLinkSchema.meta({
id: "LabResultLinkRequest",
});
function linkOperations(
path: string,
noun: string,
request: z.ZodType,
invalidCode: string,
idPrefix: string,
) {
const op = (verb: "post" | "delete") => ({
tags: ["Links"],
summary:
verb === "post"
? `File records against a ${noun} (v1.43)`
: `Unfile records from a ${noun} (v1.43)`,
description: `Idempotent; at most 100 ids. An id that names nothing this account owns comes back in \`unknown\` instead of failing. 404 when the ${noun} is not found; 422 \`${invalidCode}\` for a malformed body. Own account only, as the other link routes.`,
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
content: { "application/json": { schema: request } },
},
responses: {
"200": {
description: "How many links changed, and which ids were unknown.",
content: {
"application/json": {
schema: dataEnvelope(
linkResult,
`${idPrefix}${verb === "post" ? "Link" : "Unlink"}Response`,
),
},
},
},
"404": {
description: `The ${noun} was not found.`,
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
...notImplementedResponse,
},
});
return { [path]: { post: op("post"), delete: op("delete") } };
}
export const relationLinkPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...linkOperations(
"/api/medications/{id}/links",
"medication",
medicationLinkRequest,
"medication.link.invalid",
"Medication",
),
...linkOperations(
"/api/labs/{id}/links",
"lab result",
labResultLinkRequest,
"labs.link.invalid",
"LabResult",
),
};
+13
View File
@@ -551,6 +551,19 @@ export function recordRefusal(...alsoRefusesFor: string[]): RefusalResponse {
export const MODULE_DISABLED_DESCRIPTION =
'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.';
/**
* The 501 a published operation answers while its behaviour has not landed on
* this server (`src/lib/http/not-implemented.ts`). Spread into an operation's
* responses with the stub, and removed with it.
*/
export const notImplementedResponse = {
"501": {
description:
"Not implemented on this server yet: the contract is published, the effect is not. `meta.errorCode` = `not_implemented`, `meta.operation` names the operation. Authentication, module gates and request validation already answer as documented. A client reaches this only by ignoring the server feature flag that guards the operation.",
content: { "application/json": { schema: errorEnvelope } },
},
};
export const moduleDisabledResponse = {
"403": {
description: MODULE_DISABLED_DESCRIPTION,
+140
View File
@@ -0,0 +1,140 @@
/**
* OpenAPI schemas — server-resolved verdicts for the native client (v1.43,
* ios #116 items 3, 4, 5, 6 and 9).
*
* Shared by the dashboard, insights, measurement, record, mood and cycle
* route tables. Every value here is resolved on the server; clients render
* it and never apply thresholds of their own.
*/
import { z } from "zod/v4";
export const trendVerdict = z
.object({
direction: z
.enum(["up-good", "up-bad", "neutral", "hold"])
.describe(
"How a change counts: `up-good` a rise is progress, `up-bad` a fall is progress, `hold` the value is inside its band so staying is progress, `neutral` no judgement.",
),
targetPosition: z
.enum(["below", "inside", "above"])
.nullable()
.describe("Where the value sits against the band; null without one."),
basis: z
.enum(["personal_target", "profile_default", "metric_default"])
.describe(
"What the verdict was judged against: the person's own target, a profile-derived default (e.g. the age-based blood pressure target), or the metric's default direction.",
),
})
.meta({
id: "TrendVerdict",
description:
"Which way a change of a metric counts as progress for this person. The generalisation of `tiles.weightTrend`.",
});
export const trendSentiment = z.enum(["positive", "negative", "neutral"]).meta({
id: "TrendSentiment",
description:
"The verdict applied to one delivered change, with the noise floor applied. Colour the change from this.",
});
export const seriesChange = z
.object({
from: z.number(),
to: z.number(),
delta: z.number(),
sentiment: trendSentiment,
})
.meta({
id: "SeriesChange",
description:
"First and last day value in the window (by the metric's day statistic) and how the change counts. Null under two days.",
});
export const seriesReferenceLine = z
.object({
value: z.number().describe("In the unit of the response."),
kind: z.enum(["lower", "upper"]),
source: z.enum(["personal", "clinical"]),
appliesTo: z
.enum(["value", "secondary"])
.describe("`secondary` is the diastolic value of a blood pressure."),
labelKey: z.string(),
label: z.string().describe("In the account language."),
})
.meta({
id: "SeriesReferenceLine",
description:
"A horizontal line to draw on the chart. A personal line replaces the clinical one for the same edge. Empty list when there is nothing to draw.",
});
export const recordBenchmark = z
.object({
low: z.number(),
high: z.number(),
unit: z.string(),
source: z.string(),
sourceLabel: z.string(),
})
.meta({
id: "RecordBenchmark",
description:
"An age- and sex-aware comparison band from a cited norm, in the record's unit.",
});
export const moodPattern = z
.object({
id: z
.string()
.describe(
"Stable: `previous_day`, `weekend`, `best_weekday`, `notes`, `multiple_entries` or `delayed_tag:<tagKey>`.",
),
kind: z.enum([
"previous_day",
"weekend",
"best_weekday",
"notes",
"delayed_tag",
"multiple_entries",
]),
strength: z.enum(["weak", "moderate", "strong"]),
direction: z.enum(["higher", "lower", "none"]),
values: z.object({
subject: z.number(),
comparison: z.number(),
delta: z.number(),
weekday: z.number().int().min(0).max(6).optional(),
tagKey: z.string().optional(),
tagLabel: z.string().optional(),
}),
sampleSize: z.object({
subject: z.number().int(),
comparison: z.number().int(),
}),
})
.meta({
id: "MoodPattern",
description:
"A mood pattern that passed the server's gate (at least 8 days on each side, Welch test, false discovery control). Skip an unknown `kind`.",
});
export const cycleRestoredStart = z
.object({
cycleId: z.string(),
startDate: z.string(),
})
.meta({
id: "CycleRestoredStart",
description:
"A cycle start that was folded into another start, or given back after the start it was folded into went away.",
});
export const cycleDeleteOutcome = z
.object({
removedCycleId: z.string().nullable(),
restored: z.array(cycleRestoredStart),
})
.meta({
id: "CycleDeleteOutcome",
description:
"What a delete changed, answered with 200 when the request sent `Prefer: return=representation` (RFC 7240; the response carries `Preference-Applied`). Without the header the delete answers 204 as before.",
});
+5 -1
View File
@@ -18,6 +18,7 @@
* two-member `origin` enum. The match key that connects them is a server-side
* column and reaches no response.
*/
import { withRelated } from "./relation-links";
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
@@ -470,7 +471,10 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "The dose.",
content: {
"application/json": {
schema: dataEnvelope(vaccination, "GetVaccinationEnvelope"),
schema: dataEnvelope(
withRelated(vaccination),
"GetVaccinationEnvelope",
),
},
},
},
+27
View File
@@ -0,0 +1,27 @@
/**
* v1.43 (#1217) — AI usage in three levels.
*
* The wire spells a level in lower case; the database enum (`AiUsageLevel`)
* in upper case. The order of {@link AI_USAGE_LEVELS} is the ceiling order:
* a level is allowed when it is at or below the operator's `maxLevel`.
*/
import { z } from "zod/v4";
export const AI_USAGE_LEVELS = ["frugal", "standard", "full"] as const;
export type AiUsageLevelWire = (typeof AI_USAGE_LEVELS)[number];
export const aiUsageLevelSchema = z.enum(AI_USAGE_LEVELS);
/** `PUT /api/auth/me/ai-usage`: the account's own choice; `null` follows the instance default. */
export const aiUsagePutSchema = z.strictObject({
level: aiUsageLevelSchema.nullable(),
});
export type AiUsagePutInput = z.infer<typeof aiUsagePutSchema>;
/** The `aiUsage` block of `PUT /api/admin/settings/assistant-flags`. */
export const adminAiUsageSchema = z.strictObject({
maxLevel: aiUsageLevelSchema,
defaultLevel: aiUsageLevelSchema,
syncRegenGapHours: z.number().int().min(1).max(48).nullable(),
});
export type AdminAiUsageInput = z.infer<typeof adminAiUsageSchema>;
+45
View File
@@ -0,0 +1,45 @@
/**
* v1.43 (D1223) — merging biomarkers. `{id}` in the path is the marker that
* stays; `sourceIds` are folded into it. That the target is not among the
* sources is checked by the route, which knows the path id.
*/
import { z } from "zod/v4";
export const BIOMARKER_MERGE_MAX_SOURCES = 10;
export const biomarkerMergeSchema = z
.strictObject({
sourceIds: z
.array(z.string().trim().min(1).max(64))
.min(1)
.max(BIOMARKER_MERGE_MAX_SOURCES),
dryRun: z.boolean().optional().default(false),
})
.superRefine((value, ctx) => {
if (new Set(value.sourceIds).size !== value.sourceIds.length) {
ctx.addIssue({
code: "custom",
path: ["sourceIds"],
message: "sourceIds must be unique",
});
}
});
export type BiomarkerMergeInput = z.infer<typeof biomarkerMergeSchema>;
export const BIOMARKER_MERGE_CONVERSIONS = [
"none",
"converted",
"refused",
] as const;
export const BIOMARKER_MERGE_REFUSALS = [
"unknown_analyte",
"unknown_unit",
"not_finite",
] as const;
/** Where an alias came from (`biomarker_aliases.source`). */
export const BIOMARKER_ALIAS_SOURCES = [
"MERGE",
"IMPORT_CHOICE",
"MANUAL",
] as const;
+17
View File
@@ -0,0 +1,17 @@
/**
* v1.43 (#959) — claiming a managed profile through single sign-on. The
* handover token and the chosen username ride a sealed cookie into the OIDC
* round trip; the token never rides a URL.
*/
import { z } from "zod/v4";
import { accountUsernameSchema } from "@/lib/validations/auth";
export const claimSsoSchema = z.strictObject({
token: z.string().min(1).max(80),
username: accountUsernameSchema,
});
export type ClaimSsoInput = z.infer<typeof claimSsoSchema>;
/** How a handover link can be redeemed on this server. */
export const HANDOVER_METHODS = ["password", "sso"] as const;
+48
View File
@@ -0,0 +1,48 @@
/**
* v1.43 — Dawarich as movement context (daily values only). Part of the
* `environment` module; every route is the caller's own record.
*/
import { z } from "zod/v4";
const dayKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Use YYYY-MM-DD");
export const dawarichConnectSchema = z.strictObject({
url: z
.string()
.trim()
.max(2048)
.url()
.refine((value) => /^https?:\/\//i.test(value), "Use http or https"),
apiKey: z.string().trim().min(1).max(512),
});
export type DawarichConnectInput = z.infer<typeof dawarichConnectSchema>;
export const dawarichPlacesQuerySchema = z.strictObject({
q: z.string().trim().max(80).optional(),
});
export const dawarichSettingsSchema = z
.strictObject({
homePlaceId: z.number().int().positive().nullable().optional(),
homeCountry: z
.string()
.regex(/^[A-Z]{2}$/, "ISO 3166-1 alpha-2")
.nullable()
.optional(),
})
.refine(
(value) =>
value.homePlaceId !== undefined || value.homeCountry !== undefined,
"Send homePlaceId, homeCountry or both",
);
export type DawarichSettingsInput = z.infer<typeof dawarichSettingsSchema>;
/** At most 365 days back, checked by the route against the account's today. */
export const DAWARICH_SYNC_MAX_DAYS = 365;
export const dawarichSyncSchema = z.strictObject({
fromDate: dayKey.optional(),
});
export const dawarichDisconnectSchema = z.strictObject({
deleteData: z.boolean().optional().default(false),
});
+221
View File
@@ -0,0 +1,221 @@
/**
* v1.43 (#614) — life circumstances: six sections of dated, sealed,
* self-reported context, and AUDIT-C for the alcohol section.
*
* Each new fact kind stores a JSON object with `schema: 1`; the kinds are
* deliberately NOT in `HEALTH_PROFILE_FACT_KINDS` (health-profile-facts.ts),
* which is what `GET /api/anamnesis/facts` lists: older app builds decode that
* enum closed. They are read through `GET /api/anamnesis/life-circumstances`.
* Every question allows "no answer" (`null`, or an empty list).
*/
import { z } from "zod/v4";
import { assertStableExternalId } from "@/lib/validations/external-id";
// ── Fact kinds and their values ──────────────────────────────────────────────
export const LIFE_FACT_KINDS = [
"TOBACCO_DETAIL",
"ACTIVITY_HABIT",
"WORK_CONTEXT",
"FREE_WEEKDAYS",
"HABITS",
"HEALTH_GOALS",
] as const;
export type LifeFactKind = (typeof LIFE_FACT_KINDS)[number];
const schemaVersion = z.literal(1);
const currentYear = () => new Date().getUTCFullYear();
export const TOBACCO_PRODUCTS = [
"cigarettes",
"cigars_pipe",
"e_cigarette",
"heated_tobacco",
"snus_pouches",
] as const;
export const tobaccoDetailValueSchema = z
.strictObject({
schema: schemaVersion,
products: z.array(z.enum(TOBACCO_PRODUCTS)).max(TOBACCO_PRODUCTS.length),
cigarettesPerDay: z.number().int().min(1).max(100).nullable(),
startYear: z
.number()
.int()
.min(1900)
.refine((year) => year <= currentYear(), "Not in the future")
.nullable(),
quitMonth: z
.string()
.regex(/^\d{4}-(0[1-9]|1[0-2])$/, "Use YYYY-MM")
.nullable(),
secondhandSmoke: z.boolean().nullable(),
})
.refine(
(value) =>
value.cigarettesPerDay === null || value.products.includes("cigarettes"),
{
path: ["cigarettesPerDay"],
message: "Only with cigarettes",
},
);
export const activityHabitValueSchema = z.strictObject({
schema: schemaVersion,
moderateDaysPerWeek: z.number().int().min(0).max(7).nullable(),
moderateMinutesPerDay: z.number().int().min(0).max(600).nullable(),
sittingHoursWorkday: z.number().min(0).max(24).multipleOf(0.5).nullable(),
});
export const WORK_SITUATIONS = ["none", "indoor", "outdoor", "mixed"] as const;
export const WORK_PHYSICAL_NATURE = [
"mostly_sitting",
"standing_walking",
"heavy_physical",
] as const;
export const WORK_EXPOSURES = [
"heat",
"cold",
"dust",
"fumes_solvents",
"noise",
] as const;
export const workContextValueSchema = z.strictObject({
schema: schemaVersion,
situation: z.enum(WORK_SITUATIONS).nullable(),
physicalNature: z.enum(WORK_PHYSICAL_NATURE).nullable(),
nightWork: z.boolean().nullable(),
exposures: z.array(z.enum(WORK_EXPOSURES)).max(WORK_EXPOSURES.length),
});
export const freeWeekdaysValueSchema = z.strictObject({
schema: schemaVersion,
/** ISO weekdays, 1 = Monday. */
days: z.array(z.number().int().min(1).max(7)).max(7),
});
export const DIET_PATTERNS = [
"mixed",
"vegetarian",
"vegan",
"pescetarian",
"other",
] as const;
export const DIET_SPECIFICS = [
"intermittent_fasting",
"low_carb",
"prescribed_diet",
] as const;
export const LATEST_CAFFEINE = [
"before_12",
"12_15",
"15_18",
"after_18",
] as const;
export const habitsValueSchema = z.strictObject({
schema: schemaVersion,
napRegularly: z.boolean().nullable(),
snoringReported: z.boolean().nullable(),
dietPattern: z.enum(DIET_PATTERNS).nullable(),
dietSpecifics: z.array(z.enum(DIET_SPECIFICS)).max(DIET_SPECIFICS.length),
lateMealsPerWeek: z.number().int().min(0).max(7).nullable(),
fruitVegPortionsPerDay: z.number().int().min(0).max(15).nullable(),
caffeineDrinksPerDay: z.number().int().min(0).max(20).nullable(),
latestCaffeine: z.enum(LATEST_CAFFEINE).nullable(),
});
export const HEALTH_GOALS = [
"weight",
"blood_pressure",
"blood_sugar",
"sleep",
"fitness",
"stress_recovery",
"medication_routine",
"quit_smoking",
"less_alcohol",
] as const;
export const healthGoalsValueSchema = z.strictObject({
schema: schemaVersion,
goals: z.array(z.enum(HEALTH_GOALS)).max(3),
});
/** The value schema per kind, the one map every writer and reader uses. */
export const LIFE_FACT_VALUE_SCHEMAS = {
TOBACCO_DETAIL: tobaccoDetailValueSchema,
ACTIVITY_HABIT: activityHabitValueSchema,
WORK_CONTEXT: workContextValueSchema,
FREE_WEEKDAYS: freeWeekdaysValueSchema,
HABITS: habitsValueSchema,
HEALTH_GOALS: healthGoalsValueSchema,
} as const satisfies Record<LifeFactKind, z.ZodType>;
// ── Sections ─────────────────────────────────────────────────────────────────
export const LIFE_SECTION_KEYS = [
"tobacco",
"alcohol",
"activity",
"work",
"habits",
"goals",
] as const;
export type LifeSectionKey = (typeof LIFE_SECTION_KEYS)[number];
/** The AI sections the life-circumstances surface switches. */
export const LIFE_AI_SECTIONS = [
"SMOKING_STATUS",
"ALCOHOL_PATTERN",
"SHIFT_SCHEDULE",
"ACTIVITY",
"WORK",
"HABITS",
"GOALS",
] as const;
export type LifeAiSection = (typeof LIFE_AI_SECTIONS)[number];
/** `PATCH /api/anamnesis/life-circumstances/ai-sections`. */
export const lifeAiSectionsPatchSchema = z
.strictObject({
sections: z.partialRecord(z.enum(LIFE_AI_SECTIONS), z.boolean()),
baseUpdatedAt: z.iso.datetime({ offset: true }).optional(),
})
.refine((value) => Object.keys(value.sections).length > 0, {
path: ["sections"],
message: "At least one section",
});
export type LifeAiSectionsPatchInput = z.infer<
typeof lifeAiSectionsPatchSchema
>;
/** `POST /api/anamnesis/life-circumstances/confirm`. */
export const lifeConfirmSchema = z.strictObject({
section: z.enum(LIFE_SECTION_KEYS),
});
// ── AUDIT-C ──────────────────────────────────────────────────────────────────
const auditCItem = z.number().int().min(0).max(4);
/** `POST /api/anamnesis/audit-c`. */
export const auditCRequestSchema = z
.strictObject({
items: z.tuple([auditCItem, auditCItem, auditCItem]),
takenAt: z.iso.datetime({ offset: true }).optional(),
tz: z.string().min(1).max(64).optional(),
locale: z.string().min(2).max(16).optional(),
source: z.enum(["WEB", "IOS"]),
externalId: z.string().min(1).max(128).optional(),
})
.superRefine(assertStableExternalId);
export type AuditCRequestInput = z.infer<typeof auditCRequestSchema>;
export const AUDIT_C_CUTOFF_SOURCES = [
"S3_ALCOHOL_DE",
"KNHANES_KR",
"BRADLEY_2007",
] as const;
@@ -0,0 +1,52 @@
/**
* v1.43 — a supplement's composition per unit (tablet, capsule).
*
* The shape is checked here; whether `nutrient` is a catalogue code and
* `unit` one of its input units is checked against the nutrient catalogue by
* the route (422 `medications.ingredients.unitMismatch` carries the index and
* the allowed units), so the catalogue stays the one place units live.
*/
import { z } from "zod/v4";
export const MEDICATION_INGREDIENTS_MAX = 40;
export const MEDICATION_INGREDIENT_SOURCES = ["MANUAL", "LABEL_AI"] as const;
export const medicationIngredientInputSchema = z.strictObject({
nutrient: z.string().trim().min(1).max(40),
amount: z.number().finite().positive(),
unit: z.string().trim().min(1).max(8),
});
export const medicationIngredientsPutSchema = z
.strictObject({
ingredients: z
.array(medicationIngredientInputSchema)
.max(MEDICATION_INGREDIENTS_MAX),
source: z.enum(MEDICATION_INGREDIENT_SOURCES).optional().default("MANUAL"),
})
.superRefine((value, ctx) => {
const seen = new Set<string>();
value.ingredients.forEach((ingredient, index) => {
if (seen.has(ingredient.nutrient)) {
ctx.addIssue({
code: "custom",
path: ["ingredients", index, "nutrient"],
message: "Each nutrient may appear once",
});
}
seen.add(ingredient.nutrient);
});
});
export type MedicationIngredientsPutInput = z.infer<
typeof medicationIngredientsPutSchema
>;
/** Label photo upload limits for `POST /api/medications/{id}/ingredients/extract`. */
export const SUPPLEMENT_LABEL_MAX_BYTES = 10 * 1024 * 1024;
export const SUPPLEMENT_LABEL_MIME_TYPES = [
"image/jpeg",
"image/png",
"image/heic",
"image/webp",
"application/pdf",
] as const;
+64 -1
View File
@@ -88,6 +88,36 @@ export const nutrientOverviewQuerySchema = z.object({
days: z.coerce.number().int().min(1).max(365).default(14),
});
/**
* v1.43 — the tolerable upper intake level of a nutrient (EFSA), applied by
* the server to the day's total or to the supplemental share as `scope` says.
*/
export const nutrientUpperLimitSchema = z
.object({
value: z.number(),
unit: z.string(),
scope: z.enum(["total", "supplemental"]),
source: z.string(),
})
.meta({
id: "NutrientUpperLimit",
description:
"The tolerable upper intake level, with the unit, whether it applies to the total intake or to the supplemental share only, and the cited source. `null` where no level is established.",
});
/** v1.43 — a day total split by where it came from. */
export const nutrientBySourceSchema = z
.object({
APPLE_HEALTH: z.number().optional(),
MANUAL: z.number().optional(),
SUPPLEMENT: z.number().optional(),
})
.meta({
id: "NutrientAmountBySource",
description:
"The amount split by source: synced from Apple Health, entered by hand, and counted from taken supplement doses. A source with nothing is absent.",
});
export const nutrientOverviewSchema = z
.object({
windowDays: z.number().int().min(1),
@@ -98,6 +128,20 @@ export const nutrientOverviewSchema = z
latestDay: z.string(),
latestAmount: z.number(),
daysWithData: z.number().int().min(1),
latestBySource: nutrientBySourceSchema
.optional()
.describe("v1.43 — `latestAmount` split by source."),
upperLimit: nutrientUpperLimitSchema
.nullable()
.optional()
.describe("v1.43 — the nutrient's upper intake level, or null."),
latestAboveUpperLimit: z
.boolean()
.nullable()
.optional()
.describe(
"v1.43 — whether the latest day is above the upper limit; null without one.",
),
}),
),
lastAttempt: z
@@ -177,8 +221,27 @@ export const nutrientDailySeriesSchema = z
nutrient: nutrientCodeEnum,
unit: z.string(),
windowDays: z.number().int().min(1),
days: z.array(z.object({ day: z.string(), amount: z.number() })),
days: z.array(
z.object({
day: z.string(),
amount: z.number(),
bySource: nutrientBySourceSchema
.optional()
.describe("v1.43 — the day's amount split by source."),
aboveUpperLimit: z
.boolean()
.nullable()
.optional()
.describe(
"v1.43 — whether the day is above the upper limit; null without one.",
),
}),
),
reference: resolvedNutrientReferenceSchema.nullable(),
upperLimit: nutrientUpperLimitSchema
.nullable()
.optional()
.describe("v1.43 — the nutrient's upper intake level, or null."),
})
.meta({
id: "NutrientDailySeries",
+14
View File
@@ -0,0 +1,14 @@
/**
* v1.43 (#1107 follow-up) — the admin pulse day-mean dry run and apply step.
*/
import { z } from "zod/v4";
export const PULSE_DAY_MEAN_METHODS = [
"hour_mean_v1",
"weighted_hour_mean_v2",
] as const;
export const pulseDayMeanActionSchema = z.strictObject({
action: z.enum(["dry_run", "apply"]),
});
export type PulseDayMeanActionInput = z.infer<typeof pulseDayMeanActionSchema>;
+39
View File
@@ -0,0 +1,39 @@
/**
* v1.43 — the relation links that close the relation matrix. Same body and
* answer as the existing link routes (`{ targetKind, targetIds }` in,
* `{ changed, unknown }` out).
*/
import { z } from "zod/v4";
const targetIds = z.array(z.string().trim().min(1).max(64)).min(1).max(100);
/** `POST/DELETE /api/medications/{id}/links`. */
export const MEDICATION_LINK_TARGETS = [
"conditionEpisode",
"document",
"encounter",
] as const;
export const medicationLinkSchema = z.strictObject({
targetKind: z.enum(MEDICATION_LINK_TARGETS),
targetIds,
});
export type MedicationLinkInput = z.infer<typeof medicationLinkSchema>;
/** `POST/DELETE /api/labs/{id}/links`. */
export const LAB_RESULT_LINK_TARGETS = ["document", "encounter"] as const;
export const labResultLinkSchema = z.strictObject({
targetKind: z.enum(LAB_RESULT_LINK_TARGETS),
targetIds,
});
export type LabResultLinkInput = z.infer<typeof labResultLinkSchema>;
/** `related[]` on the six detail responses. */
export const RELATED_KINDS = [
"labResult",
"encounter",
"document",
"conditionEpisode",
"medication",
"vaccination",
] as const;
export const RELATED_VIA = ["direct", "encounter", "efficacyTarget"] as const;