mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
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:
1 parent
be112df972
commit
f443ab6615
5 files changed
+1020
-5
No files matched your search
+203
-2
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user