Merge branch 'fix/v1438-api' into release/v1.43.8

This commit is contained in:
Marc-André Bombeck committed 2026-10-20 15:39:39 +02:00
commit 4a0db28713
99 files changed
+5219 -1018

No files matched your search

+1
View File
@@ -392,6 +392,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
],
route: ["route.retired"],
search: ["search.invalid"],
security_activity: ["security_activity.invalid"],
"share-link": [
"share-link.document.decryptFailed",
"share-link.document.notFound",
+4 -2
View File
@@ -1708,7 +1708,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Auth"],
summary: "List recent account-security activity",
description:
"v1.23 — the SHARED security-activity feed: the caller's recent auth + export + deletion audit events with timestamp, resolved location, and a host-masked IP. `limit` query param caps at 100 (default 50). Reuses the AuditLog store; no event detail bodies are surfaced.",
"v1.23 — the SHARED security-activity feed: the caller's recent auth + export + deletion audit events with timestamp, resolved location, and a host-masked IP. `limit` is 1-100 (default 50); a value outside that is a 422 naming it. Reuses the AuditLog store; no event detail bodies are surfaced.",
requestParams: {
query: z.object({
limit: z.coerce
@@ -1717,7 +1717,9 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
.min(1)
.max(100)
.optional()
.describe("Page size; defaults to 50, clamped to 100."),
.describe(
"Page size, 1-100; defaults to 50. Out of range is a 422.",
),
}),
},
responses: {
+5 -4
View File
@@ -45,6 +45,7 @@ import {
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
listEnvelope,
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
@@ -346,7 +347,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Records"],
summary: "List visits",
description:
"Returns the caller's live visits, split into `upcoming` and `past`. Bounded and windowed; soft-deleted visits are excluded.",
"Returns the caller's live visits, split into `upcoming` (soonest first) and `past` (newest first). `limit` and `offset` apply to each list; `meta.truncated` is true when either list has rows past this page, so a client reads the next `offset` until it is false. Soft-deleted visits are excluded.",
requestParams: { query: listEncountersQuery },
responses: {
...recordRefusal(),
@@ -354,7 +355,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "The caller's visits.",
content: {
"application/json": {
schema: dataEnvelope(encounterList, "ListEncountersEnvelope"),
schema: listEnvelope(encounterList, "ListEncountersEnvelope"),
},
},
},
@@ -612,7 +613,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Records"],
summary: "List practitioners",
description:
"Returns the caller's address book, name-ordered, with an optional case-insensitive name search.",
"Returns the caller's address book, name-ordered, with an optional case-insensitive search over name, practice and specialty. Paged with `limit` and `offset`; `meta.truncated` says whether another page exists.",
requestParams: { query: listPractitionersQuery },
responses: {
...recordRefusal(),
@@ -620,7 +621,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "The caller's address book.",
content: {
"application/json": {
schema: dataEnvelope(
schema: listEnvelope(
z.array(practitioner),
"ListPractitionersEnvelope",
),
+160 -39
View File
@@ -32,45 +32,6 @@ import { isoDateTime } from "@/lib/validations/datetime";
/** The retrospective periods the narrative route accepts, read off the engine. */
const NARRATIVE_PERIOD_VALUES = Object.keys(PERIOD_DAYS);
export const insightsComprehensiveResponse = z
.object({
summary: z.string(),
recommendations: z.array(z.record(z.string(), z.unknown())),
citations: z.array(z.record(z.string(), z.unknown())),
warnings: z.array(z.record(z.string(), z.unknown())),
dailyBriefing: z.record(z.string(), z.unknown()).nullable().optional(),
trendAnnotations: z.record(z.string(), z.unknown()).nullable().optional(),
storyboardAnnotations: z
.array(z.record(z.string(), z.unknown()))
.optional(),
metricSource: z.record(z.string(), z.unknown()).optional(),
revalidating: z
.boolean()
.optional()
.describe(
"True when the body is served from last-good cache (stale-while-revalidate) while a fresh aggregation runs in the background. The client keeps polling on `revalidating` (bounded) so the open page converges on the fresh body.",
),
hasProvider: z
.boolean()
.optional()
.describe(
"Whether a configured AI provider exists for the record: provider presence and nothing else. False never means the operator switched AI off or consent is missing; `ai` says that.",
),
ai: z
.object({
briefing: aiCapabilityState,
statusText: aiCapabilityState,
})
.describe(
"Whether the AI parts that decorate the overview can be shown: the daily briefing and the per-metric status notes. Resolved per request, never cached with the body. The overview itself is computed data and loads whatever these say.",
),
})
.meta({
id: "InsightsComprehensiveResponse",
description:
"The Insights overview's computed read. No provider is called on this path; every figure is computed from the record.",
});
// v1.8.7.1 — generic per-HealthKit-metric assessment. The query enum is
// derived from the same registry the route validates against, so the
// spec, the route, and the cache scope cannot drift. The seven
@@ -1999,6 +1960,166 @@ export const dataSummary = z
"Per-measurement-type summary. Every statistic is null on an empty series rather than 0, so a client never charts a zero it was never given. `anomalyCount` and `slope90` were computed here historically and left this wire in v1.37.19 — they are not fields of this shape.",
});
/**
* A summary the overview computes in-process. The same statistics as the
* analytics summary, plus the count of readings the outlier rule flags, which
* only the in-process path can produce. Declared here rather than derived from
* either `DataSummary` copy so its trend component cannot collide with theirs.
*/
const comprehensiveTrend = z.object({
slope: z.number().describe("Least-squares slope in units per day."),
direction: z.enum(["up", "down", "stable"]),
confidence: z.number().describe("R² of the fit, 0..1."),
});
const comprehensiveSummary = z
.object({
count: z.number().int(),
latest: z.number().nullable(),
min: z.number().nullable(),
max: z.number().nullable(),
mean: z.number().nullable(),
median: z.number().nullable(),
avg7: z.number().nullable(),
avg30: z.number().nullable(),
slope7: comprehensiveTrend.nullable(),
slope30: comprehensiveTrend.nullable(),
anomalyCount: z
.number()
.int()
.nonnegative()
.describe("Readings in the series the outlier rule flags."),
avg30LastMonth: z.number().nullable().optional(),
avg30LastYear: z.number().nullable().optional(),
})
.meta({ id: "ComprehensiveMetricSummary" });
const correlationStat = z
.object({
r: z.number(),
strength: z
.enum(["stark", "moderat", "schwach", "keine"])
.describe(
"Strength bucket. The tokens are GERMAN on the wire; treat them as opaque identifiers and localise client-side.",
),
n: z.number().int(),
})
.meta({
id: "CorrelationStat",
description:
"A significant Pearson correlation (n >= 20, p < 0.05). The field holding it is null when the pairing is not significant.",
});
const classification = z.object({
category: z.string(),
color: z.string().describe("A CSS colour token for the web client."),
severity: z.enum(["normal", "elevated", "warning", "danger"]),
});
export const insightsComprehensiveResponse = z
.object({
summaries: z
.record(z.string(), comprehensiveSummary)
.describe(
"Keyed by `MeasurementType`. Only types with readings in the last 90 days are present.",
),
bmi: z.number().nullable(),
bmiClassification: classification.nullable(),
bpClassification: classification.nullable(),
bpPctInTarget: z
.number()
.nullable()
.describe(
"Share of blood-pressure readings inside the target band, 0-100.",
),
bpTargets: z
.object({
sysLow: z.number(),
sysHigh: z.number(),
diaLow: z.number(),
diaHigh: z.number(),
})
.nullable()
.describe("Age-banded targets; null without a date of birth."),
weightBpCorrelation: correlationStat.nullable(),
scatterData: z
.array(z.object({ weight: z.number(), sysBP: z.number() }))
.describe("Weight against systolic pressure, paired by day."),
bpMedicationCorrelation: correlationStat
.extend({ medicationCount: z.number().int() })
.nullable(),
bpMedicationScatterData: z.array(
z.object({ continuityPct: z.number(), sysBP: z.number() }),
),
moodSummary: comprehensiveSummary.nullable(),
moodBpCorrelation: correlationStat.nullable(),
moodBpScatterData: z.array(
z.object({ mood: z.number(), sysBP: z.number() }),
),
moodWeightCorrelation: correlationStat.nullable(),
moodWeightScatterData: z.array(
z.object({ mood: z.number(), weight: z.number() }),
),
moodPulseCorrelation: correlationStat.nullable(),
moodPulseScatterData: z.array(
z.object({ mood: z.number(), pulse: z.number() }),
),
medications: z
.array(
z.object({
id: z.string(),
name: z.string(),
dose: z.string(),
category: z.string(),
categoryLabel: z
.string()
.nullable()
.describe(
"The label of a custom category; null for a built-in one.",
),
compliance7: z.number().describe("Percent, 0-100."),
compliance30: z.number().describe("Percent, 0-100."),
streak: z.number().int(),
taken7: z.number().int(),
skipped7: z.number().int(),
missed7: z.number().int(),
}),
)
.describe("Cadence-aware compliance per active medication."),
alerts: z.array(
z.object({
level: z.enum(["success", "info", "warning", "danger"]),
title: z.string(),
message: z.string(),
}),
),
hasProvider: z
.boolean()
.describe(
"Whether a configured AI provider exists for the record: provider presence and nothing else. False never means the operator switched AI off or consent is missing; `ai` says that.",
),
dataSpanDays: z.number().int().nonnegative(),
totalMeasurements: z.number().int().nonnegative(),
revalidating: z
.boolean()
.describe(
"True when the body is served from last-good cache (stale-while-revalidate) while a fresh aggregation runs in the background. The client keeps polling on `revalidating` (bounded) so the open page converges on the fresh body.",
),
ai: z
.object({
briefing: aiCapabilityState,
statusText: aiCapabilityState,
})
.describe(
"Whether the AI parts that decorate the overview can be shown: the daily briefing and the per-metric status notes. Resolved per request, never cached with the body. The overview itself is computed data and loads whatever these say.",
),
})
.meta({
id: "InsightsComprehensiveResponse",
description:
"The Insights overview's computed read. No provider is called on this path; every figure is computed from the record. The model-written briefing is not part of it; it is read from `GET /api/insights/generate`.",
});
const lastSeenSlot = z
.object({
lastSeenAt: isoDateTime(),
+16 -3
View File
@@ -290,6 +290,13 @@ export const measurementResource = z
.optional()
.describe("External-system dedup key; null for manual entries."),
externalSourceVersion: z.string().nullable().optional(),
aggregationProvenance: z
.string()
.nullable()
.optional()
.describe(
"Authority of an aggregate stored under a `stats:` identity: `HEALTHKIT_STATISTICS`, `EXPORT_XML_SOURCE_MAX` or `LEGACY_UNKNOWN`. Null on an ordinary sample row. Decode unknown values leniently.",
),
glucoseContext: z
.string()
.nullable()
@@ -450,7 +457,11 @@ const sleepSessionResource = z.object({
.describe(
"True when the source has no per-stage onset timestamps and the server synthesised a contiguous timeline in a fixed physiological order (WHOOP). The client renders the hypnogram but labels it an approximate layout and never recomputes. False for real-series sources (Apple Health / Withings / Fitbit).",
),
stages: z.record(sleepStageEnum, z.number().int().nonnegative()),
stages: z
.partialRecord(sleepStageEnum, z.number().int().nonnegative())
.describe(
"Minutes per stage. Only the stages the night recorded are present; an absent stage is not zero minutes, it was not measured.",
),
segments: z.array(sleepSegmentResource),
sourceDiscrepancy: z
.object({
@@ -757,8 +768,10 @@ const seriesBatchRowSchema = z.object({
const seriesBatchResponse = z
.object({
series: z
.record(measurementTypeEnum, z.array(seriesBatchRowSchema))
.describe("Per-type daily series, keyed by MeasurementType."),
.partialRecord(measurementTypeEnum, z.array(seriesBatchRowSchema))
.describe(
"Per-type daily series, keyed by MeasurementType. Only the requested types are present.",
),
})
.meta({
id: "MeasurementsSeriesBatchResponse",
@@ -13,6 +13,8 @@ import {
MEDICATION_CATEGORY_VALUES,
MEDICATION_CONTAINER_TYPE_VALUES,
MEDICATION_TREATMENT_CLASS_VALUES,
doseWindowEntrySchema,
injectionSiteEnum,
} from "@/lib/validations/medication";
import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt";
import {
@@ -223,6 +225,13 @@ export const medicationScheduleResource = z
.describe(
'v1.7.0 cyclic "off" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise.',
),
doseWindows: z
.array(doseWindowEntrySchema)
.nullable()
.optional()
.describe(
"Per-dose on-time windows as stored. Null when every dose time keeps the default derivation.",
),
})
.meta({
id: "MedicationSchedule",
@@ -233,9 +242,54 @@ export const medicationScheduleResource = z
export const medicationResource = z
.object({
id: z.string(),
userId: z.string().optional().describe("The record the row belongs to."),
name: z.string(),
dose: z.string(),
treatmentClass: medicationTreatmentClassEnum,
deliveryForm: z
.enum(["ORAL", "INJECTION", "OTHER"])
.optional()
.describe(
"How the medication is taken. A one-time injection is `oneShot` with `INJECTION`.",
),
trackInjectionSites: z
.boolean()
.optional()
.describe(
"Whether intakes record an injection site. Only offered for `INJECTION`.",
),
allowedInjectionSites: z
.array(injectionSiteEnum)
.optional()
.describe("The sites the rotation offers; empty means every site."),
externalSource: z
.literal("APPLE_HEALTH")
.nullable()
.optional()
.describe(
"v1.32.25 — provenance of the medication. `APPLE_HEALTH` marks the row a read-only mirror of the iOS 26+ HealthKit Medications sync; NULL is a native HealthLog medication.",
),
externalId: z
.string()
.nullable()
.optional()
.describe(
"The source's own identifier for a mirrored medication, paired with `externalSource`; null for a native one.",
),
lowStockNotifiedAt: isoDateTime()
.nullable()
.optional()
.describe(
"When the low-stock alert last fired for this medication; null when it has not since the stock was last topped up. Server-written.",
),
lowStockNotifiedThresholdDays: z
.number()
.int()
.nullable()
.optional()
.describe(
"The runway threshold, in days, that alert fired at. Server-written.",
),
dosesPerUnit: z
.number()
.int()
@@ -371,6 +425,12 @@ export const medicationListEntry = medicationResource
// even though the shared base leaves them optional for the write paths.
nextDueAt: isoDateTime().nullable(),
nextDueOverdue: z.boolean(),
nextDueScheduleId: z
.string()
.nullable()
.describe(
"The schedule `nextDueAt` belongs to; null when nothing is due.",
),
remindersActive: z
.boolean()
.describe(
+18 -1
View File
@@ -394,7 +394,7 @@ export const metaPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "The operator's assistant switches (deprecated)",
deprecated: true,
description:
'Deprecated: read `ai` on GET /api/auth/me instead, which resolves each AI capability for the record from the switches, the record\'s modules, the provider, consent and who is asking. This route projects the operator switches alone and is removed in the first release after the native build that reads `ai` ships. Responses carry `Deprecation: true` and `Link: </api/auth/me>; rel="successor-version"`. The master is applied server-side, so every sub-switch is already false when it is off.\n\n' +
'Deprecated: read `ai` on GET /api/auth/me instead, which resolves each AI capability for the record from the switches, the record\'s modules, the provider, consent and who is asking. This route projects the operator switches alone and is removed in the first release after the native build that reads `ai` ships. Responses carry `Deprecation` (the date it was deprecated, as `@<unix seconds>`), `Sunset` (the first day it may stop answering) and `Link: </api/auth/me>; rel="successor-version"`. The master is applied server-side, so every sub-switch is already false when it is off.\n\n' +
"An ACTOR surface: it answers about the deployment, not about a record, so it keeps answering while the caller is acting on somebody else's — the Coach launcher and the assistant chrome are gated on it, and a refusal here would delete a piece of the shell rather than a piece of the data. A request that attaches the per-request account selector (the `AccountSelector` header parameter) is refused with 403 `sharing.not_permitted` rather than quietly answered.\n\n" +
"Served with `Cache-Control: private, max-age=60`: an operator's toggle propagates within a minute, and the flag read stays off the hot mount path in the meantime.",
responses: {
@@ -408,6 +408,23 @@ export const metaPaths: NonNullable<ZodOpenApiObject["paths"]> = {
),
},
},
headers: {
Deprecation: {
description:
"When this route was deprecated, as an RFC 9745 date: `@` and Unix seconds.",
schema: { type: "string" as const, pattern: "^@[0-9]+$" },
},
Sunset: {
description:
"The first day this route may stop answering (RFC 8594 HTTP-date).",
schema: { type: "string" as const },
},
Link: {
description:
'The successor, as `</api/auth/me>; rel="successor-version"`.',
schema: { type: "string" as const },
},
},
},
"403": {
description:
+6
View File
@@ -1192,6 +1192,12 @@ const moodAggregatesResponse = z
.array(z.object({ x: z.number(), y: z.number() }))
.describe("Mood (x) versus metric (y), paired by day."),
n: z.number().int().describe("Paired-day count."),
suppressed: z
.enum(["insufficientPairs", "notSignificant"])
.optional()
.describe(
"Why `result` is null: too few paired days, or a pairing that did not reach significance. Absent when `result` is set.",
),
}),
)
.describe(
+12
View File
@@ -383,6 +383,18 @@ const notificationPrefsResolved = z
nudgeFrequency: z
.enum(["weekly", "biweekly"])
.describe("Nudge frequency cap: one per 7 or per 14 days."),
nudgeAiComposed: z
.boolean()
.describe(
"Opt-in for model-written nudge copy. Off by default; the deterministic template is always the fallback.",
),
}),
measurementReminder: z.object({
clientManaged: z
.boolean()
.describe(
"True when the iOS app owns local check-up reminders; the server-side measurement-reminder APNs send is then suppressed. Default false.",
),
}),
content: z.object({
detail: z
+4 -1
View File
@@ -128,7 +128,10 @@ const profileSettings = z
gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable(),
locale: z.enum(["de", "en", "es", "fr", "it", "pl", "ko", "nl"]).nullable(),
timezone: z.string(),
unitPreference: z.enum(["metric", "imperial"]),
unitPreference: z
.enum(["metric", "imperial"])
.nullable()
.describe("Null when the record never chose one: metric applies."),
timeFormat: z.enum(["AUTO", "H12", "H24"]),
dateFormat: z.enum(["AUTO", "DMY", "MDY", "YMD"]),
})
+7 -5
View File
@@ -356,10 +356,12 @@ export const stdResponses = {
*
* The batch endpoints have been capped at 60 calls a minute since they were
* written and the per-record siblings were not capped at all, which is exactly
* backwards: the batch endpoint is the one a well-behaved client uses. The
* eleven single-record creates now share one generous per-account bucket, and
* this is the response that names it — a client that meets a ceiling should be
* able to read which one it met without a bug report.
* backwards: the batch endpoint is the one a well-behaved client uses. Every
* authenticated write now shares one generous per-account bucket, charged where
* the caller is resolved (`src/lib/auth/write-meter.ts`), and this is the
* response that names it on the single-record creates that publish it — a
* client that meets a ceiling should be able to read which one it met without
* a bug report.
*
* Spread AFTER `...stdResponses` so it replaces the generic 429 on those
* operations. The headers block is the one the standard 429 already declares.
@@ -371,7 +373,7 @@ export const stdResponses = {
export const recordWriteRateLimitResponse = {
"429": {
description:
"Rate limit exceeded. This route shares one per-account bucket with the other single-record writes — `record-write:<accountId>`, 300 requests per 60 seconds — keyed on the ACTING account, so a delegate burns their own allowance rather than the record owner's. Nothing was written. `meta.errorCode` is `record_write.rate_limited`, which is what tells this refusal apart from a route's own narrower bucket when both can answer 429. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe that bucket; back off on `Retry-After` rather than guessing. Every request counts against the bucket, refused ones included: the check runs before the body is read, so a client looping on a 422 spends the same allowance as one that writes rows. A client with more than a handful of rows to send should use the batch endpoint for its domain instead of looping this one.",
"Rate limit exceeded. This route shares one per-account bucket with every other authenticated write — `record-write:<accountId>`, 300 requests per 60 seconds — keyed on the ACTING account, so a delegate burns their own allowance rather than the record owner's. Only the bulk ingest endpoints, which meter themselves with a dedicated bucket, stay outside it. Nothing was written. `meta.errorCode` is `record_write.rate_limited`, which is what tells this refusal apart from a route's own narrower bucket when both can answer 429. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe that bucket; back off on `Retry-After` rather than guessing. Every request counts against the bucket, refused ones included: the check runs before the body is read, so a client looping on a 422 spends the same allowance as one that writes rows. A client with more than a handful of rows to send should use the batch endpoint for its domain instead of looping this one.",
content: { "application/json": { schema: errorEnvelope } },
headers: stdResponses["429"].headers,
},
+1 -1
View File
@@ -119,7 +119,7 @@ const symptomEventList = z
.meta({
id: "SymptomEventList",
description:
"A page of occurrences, newest first. `hasMore` is true when the page is full; page on with `to` set to the oldest `occurredAt` seen.",
"A page of occurrences, newest first. `hasMore` is true exactly when an occurrence past this page exists; page on with `to` set to the oldest `occurredAt` seen.",
});
const deletedResponse = z
+3 -2
View File
@@ -43,6 +43,7 @@ import {
recordRefusal,
recordWriteRateLimitResponse,
stdResponses,
listEnvelope,
} from "./shared";
import { isoDateTime } from "@/lib/validations/datetime";
@@ -413,7 +414,7 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Records"],
summary: "List vaccinations",
description:
"Returns the caller's live doses, newest first, each with its series resolved per component antigen. Soft-deleted doses are excluded.",
"Returns the caller's live doses, newest first, each with its series resolved per component antigen over the whole record, never over the page. Paged with `limit` (default 300) and `offset`; `meta.truncated` says whether another page exists. Soft-deleted doses are excluded.",
requestParams: { query: listVaccinationsQuery },
responses: {
...recordRefusal(),
@@ -421,7 +422,7 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "The caller's immunization history.",
content: {
"application/json": {
schema: dataEnvelope(vaccinationList, "ListVaccinationsEnvelope"),
schema: listEnvelope(vaccinationList, "ListVaccinationsEnvelope"),
},
},
},