From f443ab6615b530c9805e749e2d18a28a3bd3d81f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 25 Sep 2026 07:31:13 +0200 Subject: [PATCH] feat(measurements): accept sleep stages by name on POST /api/measurements A bridge such as Tasker or Home Assistant could only send sleep stages through the batch route, in HealthKit's terms: the category identifier, a stage codepoint from 0 to 5, and a value it had to work out in minutes. It can now post one segment to `POST /api/measurements` with `type: "SLEEP_DURATION"`, a `sleepStage` of `IN_BED`, `ASLEEP`, `AWAKE`, `CORE`, `DEEP` or `REM`, and either `startDate` + `endDate` or `measuredAt` (the end) + `value` (minutes). The row is the one the batch route writes for the same segment, and the dedupe is the batch route's: the opposite-source same-reading check, then one reconcile over both unique identities. Without an `externalId` the server derives one from the stage and both instants, so a re-post answers 200 `duplicate`, a corrected start answers 200 `updated`, and a new segment answers 201 `inserted`. The narrow `measurements:write` token is accepted and still writes only MANUAL rows. Every other body on this route behaves as before. Requested in #972. --- docs/api/openapi.yaml | 205 +++++++++- src/app/api/measurements/route.ts | 190 +++++++++ src/lib/openapi/routes/measurements.ts | 44 +- src/lib/validations/sleep-stage-entry.ts | 210 ++++++++++ .../measurements-named-sleep-stage.test.ts | 376 ++++++++++++++++++ 5 files changed, 1020 insertions(+), 5 deletions(-) create mode 100644 src/lib/validations/sleep-stage-entry.ts create mode 100644 tests/integration/measurements-named-sleep-stage.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 2533625aa..ea39acef7 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -3753,6 +3753,12 @@ paths: in request order. Use `/api/measurements/batch` for Apple Health upload streams. + Sleep stages by name (since v1.39.1): an object with `type: SLEEP_DURATION`, a `sleepStage` of `IN_BED`, + `ASLEEP`, `AWAKE`, `CORE`, `DEEP` or `REM`, and the segment as `startDate` + `endDate` or as `measuredAt` (its + end) + `value` (minutes). It is stored and deduplicated exactly as the batch route stores a HealthKit sleep + sample, so a bridge can send stages without the HealthKit codepoints. See `NamedSleepStageEntry`. + + Also reachable with a narrow `measurements:write` Bearer, which is the supported way to push readings from a scale, a watch bridge or a home-automation rule. That credential is confined to its owner's own record — a request carrying the account selector is refused 403 before any sharing grant is read, however the record is @@ -3768,12 +3774,21 @@ paths: schema: anyOf: - $ref: "#/components/schemas/CreateMeasurementRequest" + - $ref: "#/components/schemas/NamedSleepStageEntry" - type: array items: $ref: "#/components/schemas/CreateMeasurementRequest" responses: + "200": + description: "Named sleep-stage body only: the segment was already stored (`status: duplicate`) or a stored one was + corrected in place (`status: updated`). Every other body answers 201 or an error, as before." + content: + application/json: + schema: + $ref: "#/components/schemas/NamedSleepStageDuplicateResponse" "201": - description: Created. A single resource for the object body; an array of resources, in request order, for the array body. + description: "Created. A single resource for the object body; an array of resources, in request order, for the array + body; for a named sleep-stage body, the stored row with `status: inserted`." content: application/json: schema: @@ -3782,7 +3797,7 @@ paths: "403": *a9 "409": description: A measurement with this data already exists — the `(userId, type, source, externalId)` dedup index rejected - the write. + the write. Never for a named sleep-stage body, which answers a re-post with 200 instead. content: application/json: schema: @@ -22124,6 +22139,61 @@ components: `glucoseContext` stays REQUIRED on a `BLOOD_GLUCOSE` row here: this is the hand-entry surface, where the person taking the reading knows whether it was fasting or after a meal. The bulk ingest paths (CSV import, JSON import, device sync) accept a contextless reading, because a sensor export classifies nothing per sample." + NamedSleepStageEntry: + type: object + properties: + type: + type: string + const: SLEEP_DURATION + sleepStage: + type: string + enum: + - IN_BED + - ASLEEP + - AWAKE + - CORE + - DEEP + - REM + startDate: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + endDate: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + measuredAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + value: + type: number + externalId: + type: string + minLength: 1 + maxLength: 120 + source: + default: MANUAL + type: string + enum: + - MANUAL + - APPLE_HEALTH + deviceType: + anyOf: + - type: string + minLength: 1 + maxLength: 32 + - type: "null" + required: + - type + - sleepStage + description: "One sleep-stage segment with the stage named (since v1.39.1), for a bridge that does not speak HealthKit. + Chosen whenever the object carries a string `sleepStage`. `type` is `SLEEP_DURATION`. The segment is either + `startDate` + `endDate`, or `measuredAt` (its END) + `value` (its length in minutes); a `value` sent beside the + dates must agree with them to within a minute. Stored as the batch route stores a HealthKit sleep sample: value + in minutes, `measuredAt` at the end, the stage in `sleepStage`. Without an `externalId` the server derives one + from the stage and both instants, so sending the same segment again is a `duplicate`, and a segment whose start + moved is `updated` rather than added." AppleHealthBatchRequest: type: object properties: @@ -39588,6 +39658,136 @@ components: - limit - offset additionalProperties: false + NamedSleepStageDuplicateResponse: + type: object + properties: + data: + $ref: "#/components/schemas/NamedSleepStageMeasurement" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + NamedSleepStageMeasurement: + type: object + properties: + id: + type: string + description: Stored row id, or a synthetic key (`day::`, `sleep-seg::`) on the collapsed list + modes. + type: + $ref: "#/components/schemas/MeasurementType" + value: + type: number + unit: + type: string + measuredAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + source: + $ref: "#/components/schemas/MeasurementSource" + notes: + anyOf: + - type: string + - type: "null" + userId: + type: string + valueMin: + description: Bucket MINIMUM — set only on an hourly heart-rate `stats:` row. + anyOf: + - type: number + - type: "null" + valueMax: + description: Bucket MAXIMUM — set only on an hourly heart-rate `stats:` row. + anyOf: + - type: number + - type: "null" + externalId: + description: External-system dedup key; null for manual entries. + anyOf: + - type: string + - type: "null" + externalSourceVersion: + anyOf: + - type: string + - type: "null" + glucoseContext: + description: Set only on BLOOD_GLUCOSE rows, and null there too when the writer recorded no classification (a sensor + export, a device sync). Never invent one. + anyOf: + - type: string + - type: "null" + sleepStage: + description: Set only on SLEEP_DURATION rows. + anyOf: + - type: string + - type: "null" + rhythmClassification: + description: Device classification verdict; set only on EVENT rows. + anyOf: + - type: string + - type: "null" + deviceType: + description: "`watch | band | ring | phone | scale | other | unknown`." + anyOf: + - type: string + - type: "null" + syncVersion: + description: Bumped on every server-side mutation; the last-writer-wins input for cross-device reconciliation. + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + deletedAt: + description: Soft-delete tombstone; null on a live row. + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + dayKey: + type: string + sampleCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + partial: + description: True on the one bucket whose day was cut off by the read cap, so its sum understates the real total. + type: boolean + status: + type: string + enum: + - inserted + - updated + - duplicate + description: "`inserted` — a new row (HTTP 201). `duplicate` — the segment was already stored, by this route or the + batch route, and nothing changed (HTTP 200). `updated` — a stored segment with the same stage, source and + end was corrected in place, for example a later start (HTTP 200)." + required: + - id + - type + - value + - unit + - measuredAt + - source + - status + additionalProperties: false + description: The stored SLEEP_DURATION row for a named sleep-stage segment, with what the call did to it. CreateMeasurementResponse: type: object properties: @@ -39597,6 +39797,7 @@ components: - type: array items: $ref: "#/components/schemas/MeasurementResource" + - $ref: "#/components/schemas/NamedSleepStageMeasurement" error: type: "null" meta: diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index a1eb337a1..6e9591cfc 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -54,6 +54,21 @@ import { pickMainNightAndNaps, } from "@/lib/analytics/sleep-night"; import { resolveUserTimezone } from "@/lib/tz/resolver"; +import { + isNamedSleepStageBody, + sleepStageEntrySchema, + toSleepStageSegment, +} from "@/lib/validations/sleep-stage-entry"; +import { + MeasurementReconciliationError, + reconcileExternalMeasurement, +} from "@/lib/measurements/reconcile-external-measurement"; +import { + MEASURED_AT_TOLERANCE_MS, + isMergeableSource, + isSameReadingAcrossSource, + oppositeMergeSource, +} from "@/lib/measurements/cross-source-merge"; import { NextRequest } from "next/server"; import type { MeasurementType, @@ -742,6 +757,13 @@ async function postMeasurement(request: NextRequest) { }); } + // One sleep-stage segment, the stage named rather than coded. A bridge + // (Tasker, Home Assistant) posts here with its narrow token; the row and + // its dedupe are the batch route's. See `sleep-stage-entry.ts`. + if (isNamedSleepStageBody(body)) { + return postNamedSleepStage(request, user.id, body); + } + // Batch mode (array of measurements, e.g. combined BP + Pulse) if (Array.isArray(body)) { const parsed = createBatchMeasurementSchema.safeParse({ @@ -1046,3 +1068,171 @@ async function postMeasurement(request: NextRequest) { return apiSuccess(shapeMeasurementNotes(measurement), 201); } + +/** + * `POST /api/measurements` with a named `sleepStage`: one segment, stored as + * the batch route stores a HealthKit sleep sample. + * + * The same three steps as the batch route, in the same order: a same-reading + * row of the opposite source (MANUAL against APPLE_HEALTH) makes this one a + * duplicate; otherwise `reconcileExternalMeasurement` settles both unique + * identities at once, reporting an exact external-id match as a duplicate + * and adopting a row that only shares the natural identity. So a bridge that + * re-posts the same segment gets one row, whichever path it used first. + * + * `201` with the stored row when it was inserted, `200` when it was already + * there (`duplicate`) or was corrected in place (`updated`). The row carries + * a `status` saying which. + */ +async function postNamedSleepStage( + request: NextRequest, + userId: string, + body: unknown, +): Promise { + const parsed = sleepStageEntrySchema.safeParse(body); + if (!parsed.success) { + annotate({ + action: { name: "measurements.create.sleep-stage.validation-failed" }, + meta: { issue_count: sanitiseZodIssues(parsed.error.issues).length }, + }); + return returnAllZodIssues(parsed.error, 422, { + errorCode: "measurement.create.invalid", + }); + } + + const segment = toSleepStageSegment(parsed.data); + const source = parsed.data.source as MeasurementSource; + const type: MeasurementType = "SLEEP_DURATION"; + + // Cross-source same reading, exactly as the batch route decides it. + if (isMergeableSource(source)) { + const candidates = await prisma.measurement.findMany({ + where: { + userId, + deletedAt: null, + type, + source: oppositeMergeSource(source), + measuredAt: { + gte: new Date(segment.end.getTime() - MEASURED_AT_TOLERANCE_MS), + lte: new Date(segment.end.getTime() + MEASURED_AT_TOLERANCE_MS), + }, + }, + select: { + id: true, + type: true, + source: true, + value: true, + measuredAt: true, + }, + }); + const twin = candidates.find((candidate) => + isSameReadingAcrossSource( + { type, source, value: segment.minutes, measuredAt: segment.end }, + candidate, + ), + ); + if (twin) { + return namedSleepStageResponse(twin.id, "duplicate", { + stage: segment.stage, + source, + crossSource: true, + }); + } + } + + const verdict = await prisma.$transaction((tx) => + reconcileExternalMeasurement( + tx, + { + userId, + type, + value: segment.minutes, + unit: getUnitForType(type), + source, + measuredAt: segment.end, + externalId: segment.externalId, + externalSourceVersion: null, + sleepStage: segment.stage, + deviceType: parsed.data.deviceType ?? null, + }, + { exactExternalMatch: "duplicate" }, + ), + ); + + if (verdict.status === "failed") { + throw new MeasurementReconciliationError(verdict); + } + if (verdict.status === "rejected_range") { + // The schema applies the same band, so this is the two gates disagreeing. + return apiError("Value out of plausible range", 422, { + errorCode: "measurement.create.invalid", + }); + } + + const status = + verdict.status === "inserted" + ? "inserted" + : verdict.status === "duplicate" + ? "duplicate" + : "updated"; + + if (status !== "duplicate") { + await auditLog("measurement.create", { + userId, + ipAddress: getClientIp(request), + details: { + type, + measurementId: verdict.row.id, + sleepStage: segment.stage, + status, + }, + }); + invalidateUserMeasurements(userId, { evict: true }); + fireAndForget(enqueueReminderSatisfy(userId), { + action: "reminder.satisfy.enqueue", + }); + if (status === "inserted") { + void emitInsertedMeasurementArrivals( + userId, + [verdict.row], + "manual", + ).catch(() => {}); + void maybeEnqueueMorningRefresh(userId, [segment.end]).catch(() => {}); + } + await afterMeasurementMutation(userId, [ + { type, measuredAt: segment.end }, + ...(verdict.dirtyIdentities ?? []), + ]); + } + + return namedSleepStageResponse(verdict.row.id, status, { + stage: segment.stage, + source, + crossSource: false, + }); +} + +async function namedSleepStageResponse( + id: string, + status: "inserted" | "updated" | "duplicate", + meta: { stage: string; source: string; crossSource: boolean }, +): Promise { + annotate({ + action: { + name: "measurement.create.sleep-stage", + entity_type: "measurement", + entity_id: id, + }, + meta: { + status, + stage: meta.stage, + source: meta.source, + cross_source_merge: meta.crossSource, + }, + }); + const row = await prisma.measurement.findUniqueOrThrow({ where: { id } }); + return apiSuccess( + { ...shapeMeasurementNotes(row), status }, + status === "inserted" ? 201 : 200, + ); +} diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 340673fc4..067d61641 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -10,6 +10,7 @@ import type { ZodOpenApiObject } from "zod-openapi"; import { updateMeasurementSchema } from "@/lib/validations/measurement"; import { seriesBatchQuerySchema } from "@/lib/validations/series-batch"; import { deviceTypeEnum } from "@/lib/validations/source-priority"; +import { sleepStageEntrySchema } from "@/lib/validations/sleep-stage-entry"; import { MODULE_DISABLED_DESCRIPTION, createMeasurementSchema, @@ -329,6 +330,21 @@ export const measurementResource = z "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 answer to a named-stage `POST /api/measurements` (since v1.39.1). */ +const namedSleepStageResult = measurementResource + .extend({ + status: z + .enum(["inserted", "updated", "duplicate"]) + .describe( + "`inserted` — a new row (HTTP 201). `duplicate` — the segment was already stored, by this route or the batch route, and nothing changed (HTTP 200). `updated` — a stored segment with the same stage, source and end was corrected in place, for example a later start (HTTP 200).", + ), + }) + .meta({ + id: "NamedSleepStageMeasurement", + description: + "The stored SLEEP_DURATION row for a named sleep-stage segment, with what the call did to it.", + }); + /** * The aggregate list modes (`aggregate=daily` and the coarser grains) return * pre-folded buckets, not stored rows — no `id`, `unit` or `source`. @@ -866,6 +882,7 @@ export const measurementPaths: NonNullable = { summary: "Create one measurement (or a small array)", description: "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.\n\n" + + "Sleep stages by name (since v1.39.1): an object with `type: SLEEP_DURATION`, a `sleepStage` of `IN_BED`, `ASLEEP`, `AWAKE`, `CORE`, `DEEP` or `REM`, and the segment as `startDate` + `endDate` or as `measuredAt` (its end) + `value` (minutes). It is stored and deduplicated exactly as the batch route stores a HealthKit sleep sample, so a bridge can send stages without the HealthKit codepoints. See `NamedSleepStageEntry`.\n\n" + "Also reachable with a narrow `measurements:write` Bearer, which is the supported way to push readings from a scale, a watch bridge or a home-automation rule. That credential is confined to its owner's own record — a request carrying the account selector is refused 403 before any sharing grant is read, however the record is actually shared — and to `MANUAL` attribution, so an explicit `APPLE_HEALTH` source is refused 422 (`measurement.create.source_not_permitted`). The GET on this same path, and the PUT and DELETE beside it, name no scope and so refuse it.", parameters: [idempotencyKeyParameter], requestBody: { @@ -874,6 +891,11 @@ export const measurementPaths: NonNullable = { "application/json": { schema: z.union([ createMeasurementSchema, + sleepStageEntrySchema.meta({ + id: "NamedSleepStageEntry", + description: + "One sleep-stage segment with the stage named (since v1.39.1), for a bridge that does not speak HealthKit. Chosen whenever the object carries a string `sleepStage`. `type` is `SLEEP_DURATION`. The segment is either `startDate` + `endDate`, or `measuredAt` (its END) + `value` (its length in minutes); a `value` sent beside the dates must agree with them to within a minute. Stored as the batch route stores a HealthKit sleep sample: value in minutes, `measuredAt` at the end, the stage in `sleepStage`. Without an `externalId` the server derives one from the stage and both instants, so sending the same segment again is a `duplicate`, and a segment whose start moved is `updated` rather than added.", + }), z.array(createMeasurementSchema), ]), }, @@ -884,19 +906,35 @@ export const measurementPaths: NonNullable = { ...recordRefusal(), "201": { description: - "Created. A single resource for the object body; an array of resources, in request order, for the array body.", + "Created. A single resource for the object body; an array of resources, in request order, for the array body; for a named sleep-stage body, the stored row with `status: inserted`.", content: { "application/json": { schema: dataEnvelope( - z.union([measurementResource, z.array(measurementResource)]), + z.union([ + measurementResource, + z.array(measurementResource), + namedSleepStageResult, + ]), "CreateMeasurementResponse", ), }, }, }, + "200": { + description: + "Named sleep-stage body only: the segment was already stored (`status: duplicate`) or a stored one was corrected in place (`status: updated`). Every other body answers 201 or an error, as before.", + content: { + "application/json": { + schema: dataEnvelope( + namedSleepStageResult, + "NamedSleepStageDuplicateResponse", + ), + }, + }, + }, "409": { description: - "A measurement with this data already exists — the `(userId, type, source, externalId)` dedup index rejected the write.", + "A measurement with this data already exists — the `(userId, type, source, externalId)` dedup index rejected the write. Never for a named sleep-stage body, which answers a re-post with 200 instead.", content: { "application/json": { schema: errorEnvelope } }, }, // After the spread: `stdResponses` carries a generic 422 that would diff --git a/src/lib/validations/sleep-stage-entry.ts b/src/lib/validations/sleep-stage-entry.ts new file mode 100644 index 000000000..3949c5390 --- /dev/null +++ b/src/lib/validations/sleep-stage-entry.ts @@ -0,0 +1,210 @@ +/** + * One sleep-stage segment, named rather than coded, for `POST /api/measurements`. + * + * Until v1.39.1 a stage could only arrive through the batch route, in the + * HealthKit dialect: `hkIdentifier: HKCategoryTypeIdentifierSleepAnalysis`, a + * `sleepStage` codepoint 0..5 whose meaning is Apple's, and a value the client + * had to work out in minutes. A bridge such as Tasker or Home Assistant knows + * none of that. This shape lets it say what it has: the stage by name, and the + * segment either as its start and end or as its end and length. + * + * The row that lands is the row the batch route would have written for the + * same segment: `SLEEP_DURATION`, value in minutes, `measuredAt` at the + * segment's END (Apple's convention, which every sleep reader assumes), the + * stage in `sleepStage`. Dedupe is the batch route's too: the + * `(type, source, externalId)` identity and the + * `(type, measuredAt, source, sleepStage)` identity, reconciled in one step. + */ +import { z } from "zod/v4"; + +import { validateEntryInstant } from "./entry-instant"; +import { assertStableExternalId } from "@/lib/validations/external-id"; +import { + validateMeasurementRange, + writableMeasurementSourceEnum, +} from "./measurement"; + +/** The stage names the `SleepStage` column stores, as a caller writes them. */ +export const SLEEP_STAGE_NAMES = [ + "IN_BED", + "ASLEEP", + "AWAKE", + "CORE", + "DEEP", + "REM", +] as const; + +export type SleepStageName = (typeof SLEEP_STAGE_NAMES)[number]; + +/** A difference of up to a minute between `value` and the dates is rounding. */ +const VALUE_TOLERANCE_MINUTES = 1; + +const instant = () => + validateEntryInstant( + z.iso.datetime({ offset: true }).transform((s) => new Date(s)), + ); + +export const sleepStageEntrySchema = z + .object({ + type: z.literal("SLEEP_DURATION"), + sleepStage: z.enum(SLEEP_STAGE_NAMES), + startDate: instant().optional(), + endDate: instant().optional(), + /** The segment's END, when it is sent as an end and a length. */ + measuredAt: instant().optional(), + /** Minutes. Required with `measuredAt`; optional with start and end. */ + value: z.number().finite().optional(), + externalId: z.string().min(1).max(120).optional(), + source: writableMeasurementSourceEnum.optional().default("MANUAL"), + deviceType: z.string().min(1).max(32).nullable().optional(), + }) + .superRefine((entry, ctx) => { + assertStableExternalId(entry, ctx); + + const hasDates = + entry.startDate !== undefined || entry.endDate !== undefined; + const hasEndAndLength = entry.measuredAt !== undefined; + if (hasDates && hasEndAndLength) { + ctx.addIssue({ + code: "custom", + path: ["measuredAt"], + message: + "Send the segment either as startDate and endDate, or as measuredAt (its end) and value (its length in minutes), not both", + }); + return; + } + if (hasDates) { + if (entry.startDate === undefined || entry.endDate === undefined) { + ctx.addIssue({ + code: "custom", + path: [entry.startDate === undefined ? "startDate" : "endDate"], + message: "startDate and endDate are sent together", + }); + return; + } + if (entry.endDate < entry.startDate) { + ctx.addIssue({ + code: "custom", + path: ["endDate"], + message: "endDate must not be before startDate", + }); + return; + } + const minutes = + (entry.endDate.getTime() - entry.startDate.getTime()) / 60_000; + if ( + entry.value !== undefined && + Math.abs(entry.value - minutes) > VALUE_TOLERANCE_MINUTES + ) { + ctx.addIssue({ + code: "custom", + path: ["value"], + message: + "value disagrees with startDate and endDate; send the dates alone, or a value that matches them", + }); + return; + } + if (validateMeasurementRange("SLEEP_DURATION", minutes) !== null) { + ctx.addIssue({ + code: "custom", + path: ["endDate"], + message: "A sleep segment lasts between 0 and 1440 minutes", + }); + } + return; + } + if (hasEndAndLength) { + if (entry.value === undefined) { + ctx.addIssue({ + code: "custom", + path: ["value"], + message: + "value (the segment's length in minutes) is required with measuredAt", + }); + return; + } + if (validateMeasurementRange("SLEEP_DURATION", entry.value) !== null) { + ctx.addIssue({ + code: "custom", + path: ["value"], + message: "A sleep segment lasts between 0 and 1440 minutes", + }); + } + return; + } + ctx.addIssue({ + code: "custom", + path: ["startDate"], + message: + "Send the segment as startDate and endDate, or as measuredAt (its end) and value (its length in minutes)", + }); + }); + +export type SleepStageEntry = z.infer; + +/** A parsed entry, reduced to the segment it describes. */ +export interface SleepStageSegment { + stage: SleepStageName; + start: Date; + end: Date; + minutes: number; + /** + * The caller's id, or one derived from the stage and the two instants, so a + * bridge that sends no id still re-posts onto the same row. + */ + externalId: string; +} + +/** + * The id a segment gets when the caller sends none. The stage and both + * instants, so the same segment sent twice is one row, and a segment whose + * start was corrected keeps its end (and so its natural identity) and is + * updated rather than duplicated. + */ +export function derivedSleepStageExternalId( + stage: SleepStageName, + start: Date, + end: Date, +): string { + return `sleep-stage:${stage}:${start.toISOString()}/${end.toISOString()}`; +} + +export function toSleepStageSegment(entry: SleepStageEntry): SleepStageSegment { + let start: Date; + let end: Date; + let minutes: number; + if (entry.startDate && entry.endDate) { + start = entry.startDate; + end = entry.endDate; + minutes = (end.getTime() - start.getTime()) / 60_000; + } else { + // The refinement guarantees the end-and-length form here. + end = entry.measuredAt!; + minutes = entry.value!; + start = new Date(end.getTime() - Math.round(minutes * 60_000)); + } + return { + stage: entry.sleepStage, + start, + end, + minutes, + externalId: + entry.externalId ?? + derivedSleepStageExternalId(entry.sleepStage, start, end), + }; +} + +/** + * Whether a single-object body asks for the named-stage form. Only a string + * `sleepStage` does. The plain create schema never read the field, so a body + * that carried one before was stored without its stage; it is now stored with + * it, or refused with the reason. + */ +export function isNamedSleepStageBody(body: unknown): boolean { + return ( + typeof body === "object" && + body !== null && + !Array.isArray(body) && + typeof (body as { sleepStage?: unknown }).sleepStage === "string" + ); +} diff --git a/tests/integration/measurements-named-sleep-stage.test.ts b/tests/integration/measurements-named-sleep-stage.test.ts new file mode 100644 index 000000000..b3462f040 --- /dev/null +++ b/tests/integration/measurements-named-sleep-stage.test.ts @@ -0,0 +1,376 @@ +/** + * `POST /api/measurements` with a named `sleepStage`, against real Postgres. + * + * A bridge (Tasker, Home Assistant) sends one segment with the stage by name + * and its narrow `measurements:write` token. What this pins: + * + * - the row is the row the batch route writes for the same segment: + * SLEEP_DURATION, minutes, `measuredAt` at the segment's end, the stage; + * - re-posting the same segment is one row, whether the bridge sends an id + * or not, and whichever of the two routes it used first; + * - a corrected start updates the row instead of adding a second one; + * - the scoped token's source rule still holds: MANUAL, never APPLE_HEALTH; + * - a body the shape does not describe is a 422 with every issue listed. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +process.env.API_TOKEN_HMAC_KEY ??= + "test-hmac-key-named-sleep-stage-ingest-32-bytes-min-0123456789"; + +const { hashToken } = await import("@/lib/auth/hmac"); + +const USER_ID = "user-named-sleep-stage"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +const { POST } = await import("@/app/api/measurements/route"); +const { POST: POST_BATCH } = await import("@/app/api/measurements/batch/route"); + +let token = ""; + +beforeEach(async () => { + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + cookieJar.clear(); + headerJar.clear(); + await prisma.user.create({ + data: { + id: USER_ID, + username: "named-sleep", + email: "named-sleep@example.test", + timezone: "UTC", + }, + }); + token = `hlk_sleepstage_${"0".repeat(48)}`; + await prisma.apiToken.create({ + data: { + userId: USER_ID, + name: "bridge", + tokenHash: hashToken(token), + permissions: ["measurements:write"], + }, + }); + headerJar.set("authorization", `Bearer ${token}`); +}); + +function post(body: unknown, url = "https://health.example/api/measurements") { + return new NextRequest(url, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify(body), + }); +} + +const END = new Date(Date.now() - 6 * 3_600_000); +END.setUTCSeconds(0, 0); +const START = new Date(END.getTime() - 42 * 60_000); + +function rows() { + return getPrismaClient().measurement.findMany({ + where: { userId: USER_ID, type: "SLEEP_DURATION" }, + orderBy: { measuredAt: "asc" }, + }); +} + +type Created = { + data: { + id: string; + status: string; + type: string; + value: number; + unit: string; + sleepStage: string | null; + source: string; + measuredAt: string; + externalId: string | null; + }; +}; + +describe("POST /api/measurements — named sleep stage", () => { + it("stores a start/end segment as the batch route would", async () => { + const res = await POST( + post({ + type: "SLEEP_DURATION", + sleepStage: "REM", + startDate: START.toISOString(), + endDate: END.toISOString(), + }), + ); + expect(res.status).toBe(201); + const body = (await res.json()) as Created; + expect(body.data).toMatchObject({ + status: "inserted", + type: "SLEEP_DURATION", + value: 42, + unit: "minutes", + sleepStage: "REM", + source: "MANUAL", + measuredAt: END.toISOString(), + }); + const [row] = await rows(); + expect(row).toMatchObject({ + value: 42, + unit: "minutes", + sleepStage: "REM", + source: "MANUAL", + }); + expect(row!.measuredAt.toISOString()).toBe(END.toISOString()); + expect(row!.externalId).toBe( + `sleep-stage:REM:${START.toISOString()}/${END.toISOString()}`, + ); + }); + + it("accepts the end-and-length form and lands on the same row", async () => { + const first = await POST( + post({ + type: "SLEEP_DURATION", + sleepStage: "DEEP", + measuredAt: END.toISOString(), + value: 42, + }), + ); + expect(first.status).toBe(201); + const second = await POST( + post({ + type: "SLEEP_DURATION", + sleepStage: "DEEP", + startDate: START.toISOString(), + endDate: END.toISOString(), + }), + ); + expect(second.status).toBe(200); + expect(((await second.json()) as Created).data.status).toBe("duplicate"); + expect(await rows()).toHaveLength(1); + }); + + it("answers a re-post of the same segment with the stored row, not a second one", async () => { + const body = { + type: "SLEEP_DURATION", + sleepStage: "CORE", + startDate: START.toISOString(), + endDate: END.toISOString(), + }; + const first = (await (await POST(post(body))).json()) as Created; + const again = await POST(post(body)); + expect(again.status).toBe(200); + const second = (await again.json()) as Created; + expect(second.data.status).toBe("duplicate"); + expect(second.data.id).toBe(first.data.id); + expect(await rows()).toHaveLength(1); + }); + + it("updates the row when the same segment comes back with a corrected start", async () => { + await POST( + post({ + type: "SLEEP_DURATION", + sleepStage: "AWAKE", + startDate: START.toISOString(), + endDate: END.toISOString(), + }), + ); + const corrected = await POST( + post({ + type: "SLEEP_DURATION", + sleepStage: "AWAKE", + startDate: new Date(START.getTime() + 12 * 60_000).toISOString(), + endDate: END.toISOString(), + }), + ); + expect(corrected.status).toBe(200); + expect(((await corrected.json()) as Created).data.status).toBe("updated"); + const all = await rows(); + expect(all).toHaveLength(1); + expect(all[0]!.value).toBe(30); + }); + + it("keeps two stages that end at the same instant apart", async () => { + for (const sleepStage of ["IN_BED", "ASLEEP"]) { + const res = await POST( + post({ + type: "SLEEP_DURATION", + sleepStage, + startDate: START.toISOString(), + endDate: END.toISOString(), + }), + ); + expect(res.status).toBe(201); + } + expect((await rows()).map((r) => r.sleepStage).sort()).toEqual([ + "ASLEEP", + "IN_BED", + ]); + }); + + it("dedupes against the batch route: same source, same id, one row", async () => { + const externalId = "tasker-night-1-rem"; + const viaBatch = await POST_BATCH( + post( + { + entries: [ + { + hkIdentifier: "HKCategoryTypeIdentifierSleepAnalysis", + value: 42, + unit: "min", + startDate: START.toISOString(), + endDate: END.toISOString(), + sleepStage: 5, + externalId, + }, + ], + }, + "https://health.example/api/measurements/batch", + ), + ); + expect(viaBatch.status).toBe(200); + const viaNamed = await POST( + post({ + type: "SLEEP_DURATION", + sleepStage: "REM", + startDate: START.toISOString(), + endDate: END.toISOString(), + externalId, + }), + ); + expect(viaNamed.status).toBe(200); + expect(((await viaNamed.json()) as Created).data.status).toBe("duplicate"); + const all = await rows(); + expect(all).toHaveLength(1); + expect(all[0]).toMatchObject({ sleepStage: "REM", source: "MANUAL" }); + }); + + it("treats a same-reading APPLE_HEALTH row as the same segment", async () => { + await getPrismaClient().measurement.create({ + data: { + userId: USER_ID, + type: "SLEEP_DURATION", + value: 42, + unit: "minutes", + source: "APPLE_HEALTH", + measuredAt: END, + sleepStage: "REM", + externalId: "hk-uuid-1", + }, + }); + const res = await POST( + post({ + type: "SLEEP_DURATION", + sleepStage: "REM", + startDate: START.toISOString(), + endDate: END.toISOString(), + }), + ); + expect(res.status).toBe(200); + expect(((await res.json()) as Created).data.status).toBe("duplicate"); + expect(await rows()).toHaveLength(1); + }); + + it("refuses APPLE_HEALTH from the scoped token", async () => { + const res = await POST( + post({ + type: "SLEEP_DURATION", + sleepStage: "REM", + startDate: START.toISOString(), + endDate: END.toISOString(), + source: "APPLE_HEALTH", + }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { meta?: { errorCode?: string } }; + expect(body.meta?.errorCode).toBe( + "measurement.create.source_not_permitted", + ); + expect(await rows()).toHaveLength(0); + }); + + it("refuses an unknown stage, a missing end, a mismatched value and an unstable id, listing each", async () => { + for (const body of [ + { + type: "SLEEP_DURATION", + sleepStage: "LIGHT", + startDate: START.toISOString(), + endDate: END.toISOString(), + }, + { + type: "SLEEP_DURATION", + sleepStage: "REM", + startDate: START.toISOString(), + }, + { + type: "SLEEP_DURATION", + sleepStage: "REM", + startDate: START.toISOString(), + endDate: END.toISOString(), + value: 90, + }, + { + type: "SLEEP_DURATION", + sleepStage: "REM", + startDate: START.toISOString(), + endDate: END.toISOString(), + externalId: "0xdeadbeef", + }, + { + type: "WEIGHT", + sleepStage: "REM", + measuredAt: END.toISOString(), + value: 70, + }, + ]) { + const res = await POST(post(body)); + expect(res.status).toBe(422); + const parsed = (await res.json()) as { + details?: { issues: unknown[] }; + meta?: { errorCode?: string }; + }; + expect(parsed.meta?.errorCode).toBe("measurement.create.invalid"); + expect(parsed.details?.issues.length).toBeGreaterThan(0); + } + expect(await rows()).toHaveLength(0); + }); + + it("leaves the plain single create untouched", async () => { + const res = await POST( + post({ + type: "SLEEP_DURATION", + value: 420, + measuredAt: END.toISOString(), + }), + ); + expect(res.status).toBe(201); + const body = (await res.json()) as { + data: Record; + }; + expect(body.data).not.toHaveProperty("status"); + expect(body.data.sleepStage).toBeNull(); + }); +});