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.
This commit is contained in:
Marc-André Bombeck committed 2026-09-25 07:39:07 +02:00
1 parent be112df972
commit f443ab6615
5 files changed
+1020 -5

No files matched your search

+203 -2
View File
@@ -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:<type>:<dayKey>`, `sleep-seg:<dayKey>:<n>`) 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:
+190
View File
@@ -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<Response> {
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<Response> {
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,
);
}
+41 -3
View File
@@ -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<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
"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<ZodOpenApiObject["paths"]> = {
...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
+210
View File
@@ -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<typeof sleepStageEntrySchema>;
/** 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"
);
}
@@ -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<string, unknown>;
};
expect(body.data).not.toHaveProperty("status");
expect(body.data.sleepStage).toBeNull();
});
});