docs(api): correct the drifted request/response contracts

Field-by-field comparison of the handlers against the registered Zod
schemas across measurements, workouts, medications, daily and auth. The
spec was corrected to match the handlers; no runtime behaviour changed.

Measurements:
- GET /api/measurements returns `{ measurements, meta: { total, limit,
  offset, … } }` on all seven of its modes. The spec claimed a top-level
  `total` that no arm has ever emitted.
- The list rows are stored rows minus the notes ciphertext, or synthesised
  day-sum / sleep-segment rows, or pre-folded aggregate buckets. The
  resource declared seven fields; the rest are now on the wire contract.
- POST accepts a bare ARRAY body (the combined blood-pressure write) and
  answers with an array, and returns 409 on the dedup index.
- GET/PUT/DELETE /api/measurements/{id} carried no path entry at all.

Workouts: both GET operations run the module gate and answer 403
`module.disabled`; the reusable block existed but was never spread. The
batch ingest stays deliberately ungated and undocumented for it.

Medications: `nextDueAt` / `nextDueOverdue` are computed by the read paths
and absent from the create/update results, so they cannot be required on
the shared shape (the list row keeps them required). `stockUnitsRemaining`
sums a Decimal(12,4) and is fractional, not an integer. The cadence chips
object documented four fields that do not exist and missed the five that
do. `IntakeSource` admits APPLE_HEALTH.

Auth: the token bundle had its optionality inverted — `user` is always
returned, the token pair only for native callers. Adds the undocumented
403 `oidc_only`, the passkey 403/404, the TOTP enrolment 409s, the
attestation 400, the no-key-registered 409, the security-key 404s, the
refresh `revoke` response shape, and the path/query parameters three
operations read but never declared.

Daily was checked and is accurate; so are the batch ingest, series,
series-batch, bulk-delete, restore and by-external-ids routes.
This commit is contained in:
Marc-André Bombeck committed 2026-07-19 11:15:17 +02:00
1 parent 860cb61b0b
commit b00187e659
7 files changed
+1641 -188

No files matched your search

+855 -163
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,249 @@
import { describe, expect, it } from "vitest";
import { z } from "zod/v4";
import { measurementPaths } from "../routes/measurements";
import { workoutPaths } from "../routes/workouts";
/**
* Contract guard: the documented request / response shapes must match what the
* handlers actually put on the wire.
*
* The fixtures below are transcribed from the handler return statements, not
* from the schemas — so a schema that drifts away from the handler fails here
* rather than shipping a spec the iOS client generates the wrong model from.
*
* Sources:
* - src/app/api/measurements/route.ts — the seven `apiSuccess` returns on GET
* (every one of them `{ measurements, meta: { total, limit, offset, … } }`),
* the array-body branch on POST, and the 409 duplicate arm.
* - src/app/api/workouts/route.ts + src/app/api/workouts/[id]/route.ts —
* `requireModuleEnabled(user.id, "workouts")`, which answers 403 with
* `meta.errorCode = "module.disabled"`.
*/
/** Pull the Zod schema out of a registered response entry. */
function responseSchema(
paths: typeof measurementPaths,
path: string,
method: "get" | "post",
status: string,
): z.ZodType {
const responses = paths[path]?.[method]?.responses as
| Record<
string,
{ content?: Record<string, { schema?: unknown }> } | undefined
>
| undefined;
const schema = responses?.[status]?.content?.["application/json"]?.schema;
if (!schema) {
throw new Error(`no ${method.toUpperCase()} ${path} ${status} schema`);
}
return schema as z.ZodType;
}
function requestSchema(
paths: typeof measurementPaths,
path: string,
method: "post",
): z.ZodType {
const operation = paths[path]?.[method];
const schema = operation?.requestBody?.content?.["application/json"]?.schema;
if (!schema)
throw new Error(`no ${method.toUpperCase()} ${path} body schema`);
return schema as z.ZodType;
}
const listResponse = responseSchema(
measurementPaths,
"/api/measurements",
"get",
"200",
);
describe("GET /api/measurements — documented response matches the handler", () => {
it("accepts the default paged envelope (route.ts `{ measurements, meta }`)", () => {
const wire = {
data: {
measurements: [
{
id: "cm000000000000000000000",
userId: "cm111111111111111111111",
type: "WEIGHT",
value: 80.5,
unit: "kg",
source: "MANUAL",
measuredAt: "2026-07-19T08:00:00.000Z",
notes: null,
valueMin: null,
valueMax: null,
externalId: null,
externalSourceVersion: null,
glucoseContext: null,
sleepStage: null,
rhythmClassification: null,
deviceType: null,
syncVersion: 1,
deletedAt: null,
createdAt: "2026-07-19T08:00:00.000Z",
updatedAt: "2026-07-19T08:00:00.000Z",
},
],
meta: { total: 1, limit: 50, offset: 0 },
},
error: null,
};
expect(listResponse.safeParse(wire).success).toBe(true);
});
it("rejects the old `{ measurements, total }` envelope the spec used to claim", () => {
const stale = {
data: { measurements: [], total: 0 },
error: null,
};
expect(listResponse.safeParse(stale).success).toBe(false);
});
it("accepts the collapsed day-sum mode (dayKey / sampleCount / partial)", () => {
const wire = {
data: {
measurements: [
{
id: "day:ACTIVITY_STEPS:2026-07-18",
type: "ACTIVITY_STEPS",
value: 9000,
unit: "steps",
source: "APPLE_HEALTH",
measuredAt: "2026-07-18T12:00:00.000Z",
notes: null,
dayKey: "2026-07-18",
sampleCount: 12,
partial: true,
},
],
meta: {
total: 1,
limit: 50,
offset: 0,
groupBy: "day",
droppedDuplicates: 3,
},
},
error: null,
};
expect(listResponse.safeParse(wire).success).toBe(true);
});
it("accepts the aggregate mode's pre-folded buckets", () => {
const wire = {
data: {
measurements: [
{
type: "PULSE",
value: 62.5,
measuredAt: "2026-07-18T00:00:00.000Z",
count: 240,
},
],
meta: { total: 1, limit: 400, offset: 0, aggregate: "daily" },
},
error: null,
};
expect(listResponse.safeParse(wire).success).toBe(true);
});
it("accepts the per-night collapse mode", () => {
const wire = {
data: {
measurements: [
{
id: "sleep-seg:2026-07-18:0",
type: "SLEEP_DURATION",
value: 430,
unit: "minutes",
source: "APPLE_HEALTH",
measuredAt: "2026-07-18T06:10:00.000Z",
sleepStage: "ASLEEP_CORE",
notes: null,
},
],
meta: { total: 1, limit: 50, offset: 0, groupBy: "night" },
},
error: null,
};
expect(listResponse.safeParse(wire).success).toBe(true);
});
});
describe("POST /api/measurements — documented body + statuses match the handler", () => {
const body = requestSchema(measurementPaths, "/api/measurements", "post");
const created = responseSchema(
measurementPaths,
"/api/measurements",
"post",
"201",
);
const single = {
type: "PULSE",
value: 61,
unit: "bpm",
measuredAt: "2026-07-19T08:00:00.000Z",
};
it("accepts the single-object body", () => {
expect(body.safeParse(single).success).toBe(true);
});
it("accepts the ARRAY body the handler branches on (route.ts `Array.isArray(body)`)", () => {
// The combined blood-pressure write — the reason the array branch exists.
const systolic = {
type: "BLOOD_PRESSURE_SYS",
value: 122,
unit: "mmHg",
measuredAt: "2026-07-19T08:00:00.000Z",
};
const diastolic = { ...systolic, type: "BLOOD_PRESSURE_DIA", value: 78 };
expect(body.safeParse([systolic, diastolic]).success).toBe(true);
});
it("accepts an array of created rows on 201, matching the array branch", () => {
const wire = {
data: [
{
id: "cm000000000000000000000",
type: "PULSE",
value: 61,
unit: "bpm",
source: "MANUAL",
measuredAt: "2026-07-19T08:00:00.000Z",
notes: null,
},
],
error: null,
};
expect(created.safeParse(wire).success).toBe(true);
});
it("documents the 409 the duplicate-dedup arm returns", () => {
const responses = measurementPaths["/api/measurements"]?.post?.responses;
expect(Object.keys(responses ?? {})).toContain("409");
});
});
describe("workouts — the module gate's 403 is documented", () => {
it.each(["/api/workouts", "/api/workouts/{id}"])(
"GET %s declares 403",
(path) => {
const responses = workoutPaths[path]?.get?.responses;
expect(Object.keys(responses ?? {})).toContain("403");
expect(responses?.["403"]?.description).toContain("module.disabled");
},
);
it("leaves the deliberately ungated batch ingest without a module 403", () => {
// src/app/api/workouts/batch/route.ts runs no `requireModuleEnabled` —
// synced data still lands while the surface is hidden.
const responses = workoutPaths["/api/workouts/batch"]?.post?.responses;
expect(Object.keys(responses ?? {})).not.toContain("403");
});
});
@@ -0,0 +1,180 @@
import { describe, expect, it } from "vitest";
import { authPaths } from "../routes/auth";
import { medicationPaths } from "../routes/medications";
import {
medicationCadenceChips,
medicationDetailEntry,
medicationIntakeEventResource,
medicationListEntry,
} from "../routes/medications/schemas";
/**
* Contract guard for the medications + auth surfaces. Every fixture is
* transcribed from a handler return statement, so a schema that drifts away
* from the handler fails here.
*/
function statusesFor(
paths: typeof authPaths,
path: string,
method: "get" | "post" | "put" | "patch" | "delete",
): string[] {
const responses = paths[path]?.[method]?.responses as
Record<string, unknown> | undefined;
return Object.keys(responses ?? {});
}
function hasParams(
paths: typeof authPaths,
path: string,
method: "get" | "delete" | "patch",
): boolean {
return Boolean(paths[path]?.[method]?.requestParams);
}
describe("medications — documented shapes match the handlers", () => {
it("keeps `nextDueAt` off the create / update result, which returns the stored row", () => {
// src/app/api/medications/route.ts — `apiSuccess({ ...medication,
// unitsPerDose, category }, 201)`, and [id]/route.ts for the PUT. Both
// spread the stored Prisma row; `nextDueAt` / `nextDueOverdue` are
// computed by the READ paths only and are simply absent here. The shared
// detail shape backs BOTH the write results and the single GET, so it
// cannot require them.
expect(
medicationDetailEntry.shape.nextDueAt.safeParse(undefined).success,
).toBe(true);
expect(
medicationDetailEntry.shape.nextDueOverdue.safeParse(undefined).success,
).toBe(true);
});
it("still requires the computed due fields on a list row, which always has them", () => {
// src/lib/medications/list-read.ts computes both on every row, so the
// list entry must not inherit the base's optionality.
expect(
medicationListEntry.shape.nextDueAt.safeParse(undefined).success,
).toBe(false);
expect(
medicationListEntry.shape.nextDueOverdue.safeParse(undefined).success,
).toBe(false);
});
it("accepts a fractional stock unit count (unitsRemaining is Decimal(12,4))", () => {
// src/lib/medications/list-read.ts sums `unitsRemaining`, which the schema
// itself documents as fractional ("a half-tablet dose leaves 29.5 of 30").
const shape = medicationListEntry.shape.stockUnitsRemaining;
expect(shape.safeParse(29.5).success).toBe(true);
});
it("matches `complianceChips()`'s actual return on the cadence chips", () => {
// src/lib/medications/scheduling/compliance.ts returns exactly these five.
const chips = {
adherenceRate: null,
currentStreak: 0,
longestStreak: 4,
missedLast30: 2,
windowDays: 30,
};
const parsed = medicationCadenceChips.safeParse(chips);
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
});
it("rejects the fields the cadence chips never carried", () => {
const stale = {
adherenceRate: 90,
streakDays: 3,
expectedSlots: 30,
actualDoses: 27,
};
expect(medicationCadenceChips.safeParse(stale).success).toBe(false);
});
it("admits APPLE_HEALTH as an intake source (IntakeSource enum, v1.28)", () => {
const event = {
id: "cm000000000000000000000",
userId: "cm111111111111111111111",
medicationId: "cm222222222222222222222",
scheduledFor: "2026-07-19T08:00:00.000Z",
takenAt: "2026-07-19T08:05:00.000Z",
skipped: false,
source: "APPLE_HEALTH",
idempotencyKey: null,
createdAt: "2026-07-19T08:05:00.000Z",
};
const parsed = medicationIntakeEventResource.safeParse(event);
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
});
});
describe("auth — documented shapes and statuses match the handlers", () => {
it("documents the 403 oidc_only refusal on password login", () => {
expect(statusesFor(authPaths, "/api/auth/login", "post")).toContain("403");
});
it("documents the 403 + 404 arms on passkey assertion verification", () => {
const statuses = statusesFor(
authPaths,
"/api/auth/passkey/login-verify",
"post",
);
expect(statuses).toContain("403");
expect(statuses).toContain("404");
});
it("documents the 409 on both TOTP enrolment steps", () => {
expect(
statusesFor(authPaths, "/api/auth/me/mfa/totp/setup", "post"),
).toContain("409");
expect(
statusesFor(authPaths, "/api/auth/me/mfa/totp/confirm", "post"),
).toContain("409");
});
it("documents the 400 on a failed security-key attestation", () => {
expect(
statusesFor(
authPaths,
"/api/auth/me/mfa/webauthn/register/verify",
"post",
),
).toContain("400");
});
it("documents the 409 when no security key is registered to assert against", () => {
expect(
statusesFor(authPaths, "/api/auth/mfa/webauthn/verify/options", "post"),
).toContain("409");
});
it("documents the 404 on both security-key {id} verbs", () => {
const path = "/api/auth/me/mfa/webauthn/{id}";
expect(statusesFor(authPaths, path, "patch")).toContain("404");
expect(statusesFor(authPaths, path, "delete")).toContain("404");
});
it("declares the path / query parameters the handlers actually read", () => {
expect(hasParams(authPaths, "/api/auth/me/sessions/{id}", "delete")).toBe(
true,
);
expect(
hasParams(authPaths, "/api/auth/me/trusted-devices/{id}", "delete"),
).toBe(true);
expect(
hasParams(authPaths, "/api/auth/me/mfa/webauthn/{id}", "patch"),
).toBe(true);
expect(hasParams(authPaths, "/api/auth/me/security-activity", "get")).toBe(
true,
);
});
});
describe("medications — the module gate stays documented where it applies", () => {
it("keeps the daily digest's module 403", () => {
// Sanity anchor: an unrelated documented 403 must not regress while the
// medication + auth statuses above are being added.
expect(statusesFor(medicationPaths, "/api/medications", "get")).toContain(
"200",
);
});
});
+73 -5
View File
@@ -66,9 +66,14 @@ const refreshRequest = z
const accessRefreshBundle = z
.object({
user: z.object({ id: z.string(), username: z.string() }).optional(),
token: z.string().describe("Access token (`hlk_<64hex>`)."),
tokenExpiresAt: z.iso.datetime({ offset: true }),
user: z.object({ id: z.string(), username: z.string() }),
token: z
.string()
.optional()
.describe(
"Access token (`hlk_<64hex>`); only present for native-policy callers. A browser caller gets the session cookie instead.",
),
tokenExpiresAt: z.iso.datetime({ offset: true }).optional(),
refreshToken: z
.string()
.optional()
@@ -327,6 +332,11 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"403": {
description:
"Password login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`; sign in through SSO instead.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
@@ -397,6 +407,10 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"409": {
description: "A second factor is already active on this account.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
@@ -423,6 +437,11 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"409": {
description:
"A second factor is already active, or enrollment was never started (no pending secret to confirm).",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
@@ -537,6 +556,10 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"400": {
description: "Security key verification failed (bad attestation).",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
@@ -545,6 +568,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
patch: {
tags: ["Auth"],
summary: "Rename a registered security key",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
content: { "application/json": { schema: mfaWebauthnRenameSchema } },
@@ -561,6 +585,10 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"404": {
description: "No such security key for this account.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
@@ -569,6 +597,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Remove a registered security key (step-up gated)",
description:
"Requires a fresh second-factor step-up (cookie session). Bearer can never satisfy the gate.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Security key removed.",
@@ -581,6 +610,10 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"404": {
description: "No such security key for this account.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
@@ -609,6 +642,11 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"409": {
description:
"No security key is registered on the account — fall back to the TOTP / recovery-code challenge.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
@@ -662,6 +700,16 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"403": {
description:
"Passkey login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`.",
content: { "application/json": { schema: errorEnvelope } },
},
"404": {
description:
"The assertion resolved to a user row that no longer exists.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
@@ -679,10 +727,17 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
responses: {
"200": {
description: "Rotation succeeded — new pair issued.",
description:
"Rotation succeeded — new pair issued. When the request carried `revoke: true` the body is `{ revoked }` instead: the token family is invalidated and no new pair is minted.",
content: {
"application/json": {
schema: dataEnvelope(accessRefreshBundle, "RefreshResponse"),
schema: z.union([
dataEnvelope(accessRefreshBundle, "RefreshResponse"),
dataEnvelope(
z.object({ revoked: z.boolean() }),
"RefreshRevokeResponse",
),
]),
},
},
},
@@ -735,6 +790,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Revoke a single web session",
description:
"v1.23 — revokes one session by id, scoped to the authenticated user (a foreign id returns 404, never another user's row).",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Session revoked.",
@@ -803,6 +859,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Revoke a single trusted device",
description:
"v1.23 — revokes one trusted device by id, scoped to the authenticated user (a foreign id returns 404).",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Trusted device revoked.",
@@ -829,6 +886,17 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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.",
requestParams: {
query: z.object({
limit: z.coerce
.number()
.int()
.min(1)
.max(100)
.optional()
.describe("Page size; defaults to 50, clamped to 100."),
}),
},
responses: {
"200": {
description: "Recent security events for the caller.",
+238 -10
View File
@@ -11,11 +11,17 @@ import {
createMeasurementSchema,
listMeasurementsSchema,
measurementTypeEnum,
updateMeasurementSchema,
measurementSourceEnum,
} from "@/lib/validations/measurement";
import { seriesBatchQuerySchema } from "@/lib/validations/series-batch";
import { deviceTypeEnum } from "@/lib/validations/source-priority";
import { dataEnvelope, moduleDisabledResponse, stdResponses } from "./shared";
import {
dataEnvelope,
errorEnvelope,
moduleDisabledResponse,
stdResponses,
} from "./shared";
const batchEntrySchema = z
.object({
@@ -196,19 +202,140 @@ const restoreMeasurementsResponse = z
export const measurementResource = z
.object({
id: z.string(),
id: z
.string()
.describe(
"Stored row id, or a synthetic key (`day:<type>:<dayKey>`, `sleep-seg:<dayKey>:<n>`) on the collapsed list modes.",
),
type: measurementTypeEnum,
value: z.number(),
unit: z.string(),
measuredAt: z.iso.datetime({ offset: true }),
source: measurementSourceEnum,
notes: z.string().nullable().optional(),
// The list / detail reads hand back the whole stored row minus the
// `notesEncrypted` ciphertext, so every remaining scalar column is on the
// wire. Optional rather than required because the collapsed list modes
// (day-sum, sleep-segment) synthesise rows that carry only the display
// fields above.
userId: z.string().optional(),
valueMin: z
.number()
.nullable()
.optional()
.describe(
"Bucket MINIMUM — set only on an hourly heart-rate `stats:` row.",
),
valueMax: z
.number()
.nullable()
.optional()
.describe(
"Bucket MAXIMUM — set only on an hourly heart-rate `stats:` row.",
),
externalId: z
.string()
.nullable()
.optional()
.describe("External-system dedup key; null for manual entries."),
externalSourceVersion: z.string().nullable().optional(),
glucoseContext: z
.string()
.nullable()
.optional()
.describe("Set only on BLOOD_GLUCOSE rows."),
sleepStage: z
.string()
.nullable()
.optional()
.describe("Set only on SLEEP_DURATION rows."),
rhythmClassification: z
.string()
.nullable()
.optional()
.describe("Device classification verdict; set only on EVENT rows."),
deviceType: z
.string()
.nullable()
.optional()
.describe("`watch | band | ring | phone | scale | other | unknown`."),
syncVersion: z
.number()
.int()
.optional()
.describe(
"Bumped on every server-side mutation; the last-writer-wins input for cross-device reconciliation.",
),
deletedAt: z.iso
.datetime({ offset: true })
.nullable()
.optional()
.describe("Soft-delete tombstone; null on a live row."),
createdAt: z.iso.datetime({ offset: true }).optional(),
updatedAt: z.iso.datetime({ offset: true }).optional(),
// Synthesised-row extras — present only on the collapsed day-sum mode.
dayKey: z.string().optional(),
sampleCount: z.number().int().optional(),
partial: z
.boolean()
.optional()
.describe(
"True on the one bucket whose day was cut off by the read cap, so its sum understates the real total.",
),
})
.meta({
id: "MeasurementResource",
description: "Server-shaped measurement row returned by GET endpoints.",
description:
"Server-shaped measurement row. GET endpoints return the whole stored row with the decrypted note on `notes` and the `notesEncrypted` ciphertext stripped; the collapsed list modes synthesise rows carrying the display fields plus `dayKey` / `sampleCount`.",
});
/**
* The aggregate list modes (`aggregate=daily` and the coarser grains) return
* pre-folded buckets, not stored rows — no `id`, `unit` or `source`.
*/
const aggregatedMeasurementBucket = z
.object({
type: measurementTypeEnum,
value: z.number().describe("The bucket's aggregated value."),
measuredAt: z.iso.datetime({ offset: true }).describe("Bucket start."),
count: z.number().int().nonnegative().optional(),
unit: z.string().optional(),
})
.meta({
id: "AggregatedMeasurementBucket",
description:
"Pre-folded bucket returned by the `aggregate=` list modes instead of a stored row.",
});
/** `meta` on every `GET /api/measurements` response mode. */
const listMeasurementsMeta = z
.object({
total: z.number().int().nonnegative(),
limit: z.number().int().nonnegative(),
offset: z.number().int().nonnegative(),
dayKey: z
.string()
.optional()
.describe("Echoed on the per-sample drill-down modes."),
groupBy: z
.enum(["day", "night"])
.optional()
.describe("Set on the collapsed day-sum / per-night modes."),
aggregate: z
.string()
.optional()
.describe("Set on the aggregate modes — the requested grain."),
droppedDuplicates: z
.number()
.int()
.nonnegative()
.optional()
.describe(
"Rows discarded by the cross-source canonical picker on the day-collapse mode.",
),
})
.meta({ id: "ListMeasurementsMeta" });
// ── Sleep night (v1.11.5 hypnogram source) ──────────────────────────
const sleepStageEnum = z.enum([
@@ -597,8 +724,10 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"application/json": {
schema: dataEnvelope(
z.object({
measurements: z.array(measurementResource),
total: z.number().int().nonnegative(),
measurements: z.array(
z.union([measurementResource, aggregatedMeasurementBucket]),
),
meta: listMeasurementsMeta,
}),
"ListMeasurementsResponse",
),
@@ -610,25 +739,124 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
post: {
tags: ["Measurements"],
summary: "Create one measurement",
summary: "Create one measurement (or a small array)",
description:
"Single ingest. Use `/api/measurements/batch` for Apple Health upload streams.",
"Single ingest. The body may also be a bare ARRAY of the same objects — the mode the iOS client uses for a combined blood-pressure + pulse or dual-value glucose write — in which case `data` is the array of created rows in request order. Use `/api/measurements/batch` for Apple Health upload streams.",
requestBody: {
required: true,
content: { "application/json": { schema: createMeasurementSchema } },
content: {
"application/json": {
schema: z.union([
createMeasurementSchema,
z.array(createMeasurementSchema),
]),
},
},
},
responses: {
"201": {
description: "Created.",
description:
"Created. A single resource for the object body; an array of resources, in request order, for the array body.",
content: {
"application/json": {
schema: dataEnvelope(
measurementResource,
z.union([measurementResource, z.array(measurementResource)]),
"CreateMeasurementResponse",
),
},
},
},
"409": {
description:
"A measurement with this data already exists — the `(userId, type, source, externalId)` dedup index rejected the write.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/measurements/{id}": {
get: {
tags: ["Measurements"],
summary: "Measurement detail",
description:
"Single stored row, scoped to the caller. A row owned by another user surfaces as 404 (existence channel sealed).",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Measurement.",
content: {
"application/json": {
schema: dataEnvelope(
measurementResource,
"GetMeasurementResponse",
),
},
},
},
"404": {
description: "Measurement not found (or owned by another user).",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
put: {
tags: ["Measurements"],
summary: "Update a measurement",
description:
"Edits a manually entered row. Rows owned by a connected source are refused with 409 `measurement.update.server_owned_source`; a timestamp collision with an existing row is refused with 409 `measurement.duplicate_timestamp`.",
requestParams: { path: z.object({ id: z.string() }) },
requestBody: {
required: true,
content: { "application/json": { schema: updateMeasurementSchema } },
},
responses: {
"200": {
description: "Updated measurement.",
content: {
"application/json": {
schema: dataEnvelope(
measurementResource,
"UpdateMeasurementResponse",
),
},
},
},
"404": {
description: "Measurement not found (or owned by another user).",
content: { "application/json": { schema: errorEnvelope } },
},
"409": {
description:
"Refused: the row comes from a connected source (`meta.errorCode` = `measurement.update.server_owned_source`), or another row already carries this timestamp (`measurement.duplicate_timestamp`).",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
delete: {
tags: ["Measurements"],
summary: "Delete a measurement",
description:
"Soft-deletes the row (tombstone) so paired clients can reconcile the deletion.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Deleted.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ deleted: z.literal(true) }),
"DeleteMeasurementResponse",
),
},
},
},
"404": {
description: "Measurement not found (or owned by another user).",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
+38 -9
View File
@@ -177,13 +177,15 @@ export const medicationResource = z
nextDueAt: z.iso
.datetime({ offset: true })
.nullable()
.optional()
.describe(
"v1.7.0 server-computed next due instant across all the medication's schedules (earliest `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved slot's anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule era), this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next future slot. The list GET is cached 60 s, so a 60 s staleness is accepted.",
"Present on the READ paths only — the list and single GET compute it; the create / update responses return the stored row and omit it. v1.7.0 server-computed next due instant across all the medication's schedules (earliest `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved slot's anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule era), this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next future slot. The list GET is cached 60 s, so a 60 s staleness is accepted.",
),
nextDueOverdue: z
.boolean()
.optional()
.describe(
"v1.16.4 — true when `nextDueAt` is an OPEN overdue slot: its anchor has passed, `now` is still inside the slot's catch-up band, and no taken / skipped / auto-missed row resolves it. False for a regular future next-due (and when `nextDueAt` is null). Read-only — computed, not stored.",
"Present on the READ paths only, alongside `nextDueAt`. v1.16.4 — true when `nextDueAt` is an OPEN overdue slot: its anchor has passed, `now` is still inside the slot's catch-up band, and no taken / skipped / auto-missed row resolves it. False for a regular future next-due (and when `nextDueAt` is null). Read-only — computed, not stored.",
),
startsOn: z.iso
.datetime({ offset: true })
@@ -220,6 +222,10 @@ export const medicationResource = z
export const medicationListEntry = medicationResource
.extend({
category: medicationCategoryEnum,
// The list read always computes these two, so they are required here
// even though the shared base leaves them optional for the write paths.
nextDueAt: z.iso.datetime({ offset: true }).nullable(),
nextDueOverdue: z.boolean(),
lastTakenAt: z.iso
.datetime({ offset: true })
.nullable()
@@ -235,7 +241,6 @@ export const medicationListEntry = medicationResource
),
stockUnitsRemaining: z
.number()
.int()
.nullable()
.describe(
"v1.16.10 — usable inventory units left across the medication's containers (sum of `unitsRemaining` over ACTIVE / IN_USE items with units left). NULL = inventory tracking off (no items ever registered); 0 = tracking on, supply ran out. Read-only — aggregated, not stored.",
@@ -362,7 +367,7 @@ export const medicationIntakeEventResource = z
scheduledFor: z.iso.datetime({ offset: true }),
takenAt: z.iso.datetime({ offset: true }).nullable(),
skipped: z.boolean(),
source: z.enum(["WEB", "API", "REMINDER", "IMPORT"]),
source: z.enum(["WEB", "API", "REMINDER", "IMPORT", "APPLE_HEALTH"]),
idempotencyKey: z.string().nullable(),
createdAt: z.iso.datetime({ offset: true }),
})
@@ -388,15 +393,39 @@ export const medicationCadenceTimelinePoint = z
export const medicationCadenceChips = z
.object({
adherenceRate: z.number(),
streakDays: z.number().int().nonnegative(),
expectedSlots: z.number().int().nonnegative(),
actualDoses: z.number().int().nonnegative(),
adherenceRate: z
.number()
.nullable()
.describe(
"0-100, taken / (taken + missed). Skipped doses are excluded from the denominator — a deliberate decision, not a compliance failure. NULL when no dose was expected in the window (brand-new medication, paused).",
),
currentStreak: z
.number()
.int()
.nonnegative()
.describe(
"Consecutive days ending at `asOf` where every expected dose was taken or skipped. Days with no expected dose advance the streak; missed days break it.",
),
longestStreak: z
.number()
.int()
.nonnegative()
.describe("Longest all-taken-or-skipped run anywhere in the window."),
missedLast30: z
.number()
.int()
.nonnegative()
.describe("Count of missed doses inside the window."),
windowDays: z
.number()
.int()
.nonnegative()
.describe("Window size used — mirrors the input for the chart legend."),
})
.meta({
id: "MedicationCadenceChips",
description:
"Four compliance summary values for the medication detail page chip row.",
"Compliance summary values for the medication detail page chip row.",
});
export const medicationCadenceResponse = z
+8 -1
View File
@@ -9,7 +9,12 @@ import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { measurementSourceEnum } from "@/lib/validations/measurement";
import { createBatchWorkoutSchema } from "@/lib/validations/workout";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
import {
dataEnvelope,
errorEnvelope,
moduleDisabledResponse,
stdResponses,
} from "./shared";
// v1.4.25 W16b — typed workout batch ingest response envelope. Mirrors
// the measurements batch shape but reports the `workouts` count rather
@@ -205,6 +210,7 @@ export const workoutPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
...moduleDisabledResponse,
...stdResponses,
},
},
@@ -234,6 +240,7 @@ export const workoutPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "Workout not found (or owned by another user).",
content: { "application/json": { schema: errorEnvelope } },
},
...moduleDisabledResponse,
...stdResponses,
},
},