fix(api): publish what the GET handlers send

A response parsed against its own registered schema disagreed in thirteen
operations. Where the handler was right, the schema now says so; where the
handler had drifted from its siblings, it follows them.

Schema:
- measurements list and detail declare aggregationProvenance
- series-batch and sleep night stages are partial records: only the
  requested types and the recorded stages are present
- medications declare deliveryForm, the injection-site fields, externalId,
  externalSource, the low-stock bookkeeping, schedules[].doseWindows and
  nextDueScheduleId on the list
- notification prefs declare coach.nudgeAiComposed and measurementReminder
- mood insights correlations declare suppressed
- record settings profile.unitPreference is nullable, as stored
- insights/comprehensive describes the computed overview it returns rather
  than the model digest fields it never sends

Handler:
- GET /api/mood-entries/{id} shares the list projection and now carries
  tagKeys and ratedFactors
- GET and PATCH /api/custom-metrics/{id} carry latest and entryCount
- /api/analytics no longer leaks anomalyCount through the per-night sleep
  summary and the glucose meal-context split
This commit is contained in:
Marc-André Bombeck committed 2026-10-20 13:16:22 +02:00
1 parent 67cc56f707
commit aecf6f91b1
18 files changed
+1214 -283

No files matched your search

+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(
+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"]),
})