mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
860cb61b0b
commit
b00187e659
7 files changed
+1641
-188
No files matched your search
+855
-163
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user