mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 22:26:13 +00:00
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:
1 parent
6e72de1c35
commit
3786deb503
74 files changed
+6614
-282
No files matched your search
@@ -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",
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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.",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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`.") },
|
||||
),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
@@ -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",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user