mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +00:00
The sleep list capped its read to the last year only when no `from` was sent. An explicit early `from` walked every per-stage row back to that date, on a multi-source account several rows per night for years. The read window is now at most a year ending at `to` (or now). `from` narrows it but never widens it, older nights are reached by moving `to` back, and `meta.windowFrom` names the instant a cut window started, so a client can tell a cut from an empty history.
1299 lines
48 KiB
TypeScript
1299 lines
48 KiB
TypeScript
import { prisma } from "@/lib/db";
|
||
import {
|
||
apiHandler,
|
||
isScopedCredential,
|
||
requireRecordAuth,
|
||
} from "@/lib/api-handler";
|
||
import { EXTERNAL_SOURCE } from "@/lib/measurements/external-source";
|
||
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
|
||
import { annotate } from "@/lib/logging/context";
|
||
import { fireAndForget } from "@/lib/logging/fire-and-forget";
|
||
import { auditLog } from "@/lib/auth/audit";
|
||
import {
|
||
apiSuccess,
|
||
apiError,
|
||
getClientIp,
|
||
returnAllZodIssues,
|
||
safeJson,
|
||
sanitiseZodIssues,
|
||
} from "@/lib/api-response";
|
||
import { checkRecordWriteRateLimit } from "@/lib/rate-limit";
|
||
import {
|
||
createMeasurementSchema,
|
||
createBatchMeasurementSchema,
|
||
listMeasurementsSchema,
|
||
getUnitForType,
|
||
} from "@/lib/validations/measurement";
|
||
import {
|
||
BUCKET_CAP,
|
||
type AggregateGrain,
|
||
} from "@/lib/measurements/range-aggregation";
|
||
import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping";
|
||
import { metricKeyForType } from "@/lib/measurements/cumulative-day-sum";
|
||
import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority";
|
||
import {
|
||
canonicalDailyTimestamp,
|
||
dayKeyForUserTz,
|
||
} from "@/lib/measurements/drain-per-sample-cumulative";
|
||
import { localDayWindow } from "@/lib/tz/local-day";
|
||
import { withIdempotency } from "@/lib/idempotency";
|
||
import { isP2002 } from "@/lib/prisma-errors";
|
||
import { encryptNote, shapeMeasurementNotes } from "@/lib/crypto/note-cipher";
|
||
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
|
||
import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit";
|
||
import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger";
|
||
import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy";
|
||
import { runSafetyFloorCheck } from "@/lib/illness/safety-floor-check";
|
||
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
|
||
import { loadUserSourcePriority } from "@/lib/rollups/measurement-read";
|
||
import {
|
||
readDailySeries,
|
||
readLiveBuckets,
|
||
} from "@/lib/measurements/daily-series-read";
|
||
import {
|
||
reconstructSleepSessions,
|
||
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,
|
||
MeasurementSource,
|
||
GlucoseContext,
|
||
} from "@/generated/prisma/client";
|
||
import { DEFAULT_TIMEZONE } from "@/lib/tz/format";
|
||
|
||
export const GET = apiHandler(async (request: NextRequest) => {
|
||
const { user } = await requireRecordAuth("read", "measurements");
|
||
|
||
const params = Object.fromEntries(request.nextUrl.searchParams);
|
||
const parsed = listMeasurementsSchema.safeParse(params);
|
||
if (!parsed.success) {
|
||
// v1.4.43 W6 — surface every Zod issue under `details.issues` so a
|
||
// multi-field iOS query (chart loader bug, drill-down regression)
|
||
// doesn't iterate one error per round-trip. Best-effort audit-
|
||
// ledger breadcrumb keyed `measurements.list.validation-failed`.
|
||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||
annotate({
|
||
action: { name: "measurements.list.validation-failed" },
|
||
meta: { issue_count: issues.length },
|
||
});
|
||
// v1.4.49 — strip `message` from the audit-ledger row to keep
|
||
// any free-text query slice (custom `dayKey`, attacker-shaped
|
||
// `type=`) out of the persisted breadcrumb.
|
||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||
stripValuesFromMessage: true,
|
||
});
|
||
// v1.36.0 — through `auditLog()` rather than a bare `prisma.auditLog
|
||
// .create`, because that helper is the only thing that stamps
|
||
// `actorUserId`. Filed under the resolved record either way; without the
|
||
// stamp a delegate's malformed query would read as the owner's own.
|
||
void auditLog("measurements.list.validation-failed", {
|
||
userId: user.id,
|
||
details: { issues: auditIssues },
|
||
}).catch(() => {
|
||
/* swallow — 422 response is the contract */
|
||
});
|
||
return returnAllZodIssues(parsed.error, 422);
|
||
}
|
||
|
||
const {
|
||
type,
|
||
from,
|
||
to,
|
||
limit,
|
||
offset,
|
||
sortBy,
|
||
sortDir,
|
||
aggregate,
|
||
source,
|
||
sourceEq,
|
||
valueMin,
|
||
valueMax,
|
||
groupBy,
|
||
dayKey,
|
||
} = parsed.data;
|
||
|
||
const where = {
|
||
userId: user.id,
|
||
// v1.4.40 W-DELETED — soft-deleted readings are invisible to every
|
||
// list / count / aggregate read. The dedicated `sync/state` route
|
||
// keeps surfacing them via its own filter so iOS can reconcile
|
||
// tombstones; every other consumer (list view, analytics fan-out,
|
||
// dashboard read paths) must never include them.
|
||
deletedAt: null,
|
||
...(type && { type: type as MeasurementType }),
|
||
// v1.15.13 — management-list source filter. Distinct from the
|
||
// `source=rollup` opt-in above (which selects the rollup read tier);
|
||
// `sourceEq` narrows the plain list to one ingest source.
|
||
...(sourceEq && { source: sourceEq }),
|
||
// v1.18.5 — value-range filter (backlog G). Open-ended on either side;
|
||
// the validator has already rejected an inverted `valueMin > valueMax`.
|
||
...(valueMin != null || valueMax != null
|
||
? {
|
||
value: {
|
||
...(valueMin != null && { gte: valueMin }),
|
||
...(valueMax != null && { lte: valueMax }),
|
||
},
|
||
}
|
||
: {}),
|
||
...(from || to
|
||
? {
|
||
measuredAt: {
|
||
...(from && { gte: from }),
|
||
...(to && { lte: to }),
|
||
},
|
||
}
|
||
: {}),
|
||
};
|
||
|
||
// v1.11.5 — sleep is read row-per-STAGE (≈5 rows/night × source-count),
|
||
// not a spot sample and not a cumulative day-sum. A plain list query for
|
||
// SLEEP_DURATION would leak every stage segment as its own "value" — the
|
||
// "many small numbers that stand for themselves" complaint. Route it
|
||
// through the canonical per-night aggregation (the same helper the chart
|
||
// series + dashboard tile use) so a sleep list returns one row per night
|
||
// carrying the night's TIME ASLEEP, with a `dayKey` drill-down to that
|
||
// night's stage segments. Storage is untouched; only the display
|
||
// collapses. Runs ahead of the cumulative branches because sleep is in
|
||
// neither CUMULATIVE_HK_TYPES nor the spot-sample set.
|
||
if (type === "SLEEP_DURATION") {
|
||
return sleepListResponse(user, {
|
||
from,
|
||
to,
|
||
limit,
|
||
offset,
|
||
sortDir,
|
||
dayKey,
|
||
});
|
||
}
|
||
|
||
// v1.4.37 W7c — list-view drill-down to per-sample rows for a single
|
||
// calendar day in the user's IANA timezone. Resolves the day's UTC
|
||
// bounds against `User.timezone`, then runs a plain `findMany` so the
|
||
// expandable list row shows every per-sample chunk the daily collapse
|
||
// hid. Gated to cumulative HK types — the spot-sample types render
|
||
// one-row-per-event already and have nothing to drill into.
|
||
if (dayKey && type && CUMULATIVE_HK_TYPES.has(type as MeasurementType)) {
|
||
const tz =
|
||
user.timezone && user.timezone.length > 0
|
||
? user.timezone
|
||
: DEFAULT_TIMEZONE;
|
||
// Resolve `[dayStart, dayEnd)` for the requested calendar day in
|
||
// the user's local zone. v1.4.37 W10 — `localDayWindow` honours
|
||
// DST: the previous shape (`canonicalDailyTimestamp ± 12 h`)
|
||
// silently leaked or hid one hour of samples on spring-forward
|
||
// (23-h day) and fall-back (25-h day) by always spanning exactly
|
||
// 24 h. Works for every IANA offset including sub-half-hour
|
||
// zones (Asia/Kathmandu UTC+5:45, Pacific/Chatham UTC+12:45).
|
||
const { dayStart, dayEnd } = localDayWindow(dayKey, tz);
|
||
const samples = await prisma.measurement.findMany({
|
||
where: {
|
||
userId: user.id,
|
||
type: type as MeasurementType,
|
||
measuredAt: { gte: dayStart, lt: dayEnd },
|
||
deletedAt: null,
|
||
},
|
||
// Same tiebreaker as the paged list. This one slices in memory rather
|
||
// than with `skip`, so there is no page drift to prevent — but two
|
||
// identical requests would still be free to render a tied block in
|
||
// different orders, and a drill-down that reshuffles on refresh reads
|
||
// as data changing.
|
||
orderBy: [{ measuredAt: sortDir }, { id: sortDir }],
|
||
// v1.4.38 — the 1000-row cap now lives on the validator (refine
|
||
// on `(limit, dayKey)` returning 422 when a caller asks for
|
||
// more). The route reads `limit` straight through because the
|
||
// validator has already clamped it.
|
||
take: limit,
|
||
});
|
||
annotate({
|
||
action: { name: "measurement.list" },
|
||
meta: { total: samples.length, type, dayKey, mode: "drill-down" },
|
||
});
|
||
return apiSuccess({
|
||
measurements: samples,
|
||
meta: { total: samples.length, limit, offset: 0, dayKey },
|
||
});
|
||
}
|
||
|
||
// v1.4.37 W7c — collapsed "one row per day" mode for cumulative
|
||
// types. The list page renders the day's SUM as a single synthesised
|
||
// row with a `sampleCount` chevron that drills back into the
|
||
// per-sample rows via the `dayKey=…` branch above.
|
||
if (
|
||
groupBy === "day" &&
|
||
type &&
|
||
CUMULATIVE_HK_TYPES.has(type as MeasurementType)
|
||
) {
|
||
const tz =
|
||
user.timezone && user.timezone.length > 0
|
||
? user.timezone
|
||
: DEFAULT_TIMEZONE;
|
||
// v1.29.6 — DESC + take so a long history returns the MOST RECENT
|
||
// window of raw samples for bucketing. The previous `orderBy: asc`
|
||
// + `take: limit` sliced the OLDEST page of rows before the
|
||
// day-bucketing loop ran below — on an account with more than
|
||
// `limit` cumulative samples in its history, the buckets were built
|
||
// entirely from year-old rows and the recent days silently never
|
||
// appeared. Mirrors the workouts-list ordering fix (05f80ad37):
|
||
// re-establish "most recent" as the page the caller actually gets.
|
||
const rows = await prisma.measurement.findMany({
|
||
where,
|
||
orderBy: { measuredAt: "desc" },
|
||
// Per-sample row scan within the requested window — bounded by
|
||
// the existing `limit` cap (default 100, max 5000) so a wide
|
||
// multi-year window can still walk the whole range when the
|
||
// chart explicitly asks for it.
|
||
take: limit,
|
||
select: {
|
||
id: true,
|
||
type: true,
|
||
value: true,
|
||
unit: true,
|
||
source: true,
|
||
measuredAt: true,
|
||
notes: true,
|
||
deviceType: true,
|
||
},
|
||
});
|
||
|
||
// v1.29.6 — collapse overlapping sources to the ladder-canonical
|
||
// reading per day BEFORE summing. Without this, a day with both an
|
||
// Apple Health AND a Withings write for the same cumulative metric
|
||
// (e.g. steps) summed BOTH sources into one inflated total — a day
|
||
// the dashboard tile (which reads through the source-collapsed
|
||
// rollup, `collapseRollupRowsBySource` in
|
||
// `src/lib/rollups/measurement-read.ts`) showed correctly. Uses the
|
||
// same `metricKeyForType` ladder lookup the rollup collapse uses, so
|
||
// this list and the tile agree on which source wins. A type with no
|
||
// ladder (e.g. TIME_IN_DAYLIGHT, FALL_COUNT) has nothing to collapse
|
||
// against — every row passes through unchanged, matching the
|
||
// pass-through fallback used at the other `pickCanonicalSourceRows`
|
||
// call sites.
|
||
const metricKey = metricKeyForType(type as MeasurementType);
|
||
const priorityJson = await loadUserSourcePriority(user.id);
|
||
const canonicalRows = metricKey
|
||
? pickCanonicalSourceRows(
|
||
rows.map((r) => ({ ...r, type: r.type as MeasurementType })),
|
||
metricKey,
|
||
priorityJson,
|
||
(d) => dayKeyForUserTz(d, tz),
|
||
).canonicalRows
|
||
: rows;
|
||
|
||
// Group by the user's calendar day, sum values, keep a representative
|
||
// unit/source from the bucket's first row so the rendered row carries
|
||
// a sensible label even when the bucket mixes sources.
|
||
type Bucket = {
|
||
dayKey: string;
|
||
total: number;
|
||
sampleCount: number;
|
||
latest: Date;
|
||
unit: string;
|
||
source: string;
|
||
};
|
||
const buckets = new Map<string, Bucket>();
|
||
for (const row of canonicalRows) {
|
||
const key = dayKeyForUserTz(row.measuredAt, tz);
|
||
const slot = buckets.get(key);
|
||
if (!slot) {
|
||
buckets.set(key, {
|
||
dayKey: key,
|
||
total: row.value,
|
||
sampleCount: 1,
|
||
latest: row.measuredAt,
|
||
unit: row.unit,
|
||
source: row.source,
|
||
});
|
||
} else {
|
||
slot.total += row.value;
|
||
slot.sampleCount += 1;
|
||
if (row.measuredAt > slot.latest) slot.latest = row.measuredAt;
|
||
}
|
||
}
|
||
|
||
// v1.30.3 (QA F6) — the read above is DESC + take(limit), so once a
|
||
// dense account's in-window row count crosses the cap, the OLDEST
|
||
// returned row sits mid-day: every earlier sample on that same
|
||
// calendar day was cut off, and its bucket's SUM understates the real
|
||
// day total with no marker. Flag exactly that one bucket — the day
|
||
// containing the cutoff row (the minimum dayKey among the buckets
|
||
// actually built) — `partial: true` only when the cap was truly hit;
|
||
// a read that came in under the cap covers its whole window and no
|
||
// day is a truncation artifact.
|
||
const cappedRead = rows.length === limit;
|
||
let oldestDayKey: string | null = null;
|
||
if (cappedRead) {
|
||
for (const key of buckets.keys()) {
|
||
if (oldestDayKey === null || key < oldestDayKey) oldestDayKey = key;
|
||
}
|
||
}
|
||
|
||
const measurements = Array.from(buckets.values())
|
||
.map((b) => ({
|
||
// Synthetic id so the list-row key stays stable across pages
|
||
// and the row never collides with a real `Measurement.id`.
|
||
id: `day:${type}:${b.dayKey}`,
|
||
type,
|
||
value: b.total,
|
||
unit: b.unit,
|
||
source: b.source,
|
||
// Anchor the row's timestamp to local noon so the row sorts
|
||
// cleanly between same-day spot samples — matches the
|
||
// `canonicalDailyTimestamp` convention used by the drain.
|
||
measuredAt: canonicalDailyTimestamp(b.dayKey, tz).toISOString(),
|
||
notes: null,
|
||
dayKey: b.dayKey,
|
||
sampleCount: b.sampleCount,
|
||
...(b.dayKey === oldestDayKey ? { partial: true } : {}),
|
||
}))
|
||
.sort((a, b) => {
|
||
const cmp = a.dayKey < b.dayKey ? -1 : a.dayKey > b.dayKey ? 1 : 0;
|
||
return sortDir === "asc" ? cmp : -cmp;
|
||
});
|
||
|
||
annotate({
|
||
action: { name: "measurement.list" },
|
||
meta: {
|
||
total: measurements.length,
|
||
type,
|
||
groupBy: "day",
|
||
rowsScanned: rows.length,
|
||
droppedDuplicates: rows.length - canonicalRows.length,
|
||
// QA F6 — surfaces when the oldest bucket in this page is a
|
||
// partial-day sum (the read hit the cap).
|
||
capped: cappedRead,
|
||
},
|
||
});
|
||
return apiSuccess({
|
||
measurements,
|
||
meta: {
|
||
total: measurements.length,
|
||
limit,
|
||
offset: 0,
|
||
groupBy: "day",
|
||
droppedDuplicates: rows.length - canonicalRows.length,
|
||
},
|
||
});
|
||
}
|
||
|
||
// v1.4.28 FB-D2 — server-side aggregation. Gated on an explicit
|
||
// `aggregate` param so the iOS contract (raw `MeasurementWireDTO`
|
||
// shape on `GET /api/measurements`) is byte-stable for any caller
|
||
// that omits the new query parameter. The chart-data client opts
|
||
// in; iOS does not.
|
||
//
|
||
// R4-CODE-C1 — `take` no longer applies BEFORE bucketising. The
|
||
// aggregation runs in Postgres via `date_trunc`, the bucket cap is
|
||
// applied AFTER, so a 1-year `aggregate=daily` window walks every
|
||
// row in the window and returns up to 365 buckets per type instead
|
||
// of truncating to the first N raw rows.
|
||
//
|
||
// SD-H1 — "All time" range semantics. When the chart's "All" tab
|
||
// is active, the client passes `from` = the user's earliest
|
||
// measurement (or a sentinel like 1970-01-01) plus `to` = now plus
|
||
// `aggregate=monthly` (or `weekly` when full history < 2 years).
|
||
// The route's response is bounded by the `BUCKET_CAP` ceiling per
|
||
// grain (monthly: 24, weekly: 105, daily: 365) so a multi-decade
|
||
// account never paints an unbounded series.
|
||
// `source=rollup` + `aggregate=daily` — the chart's series read. Windows up
|
||
// to the DAY cap fold live into the user's local days (#1026); wider
|
||
// windows step up to the WEEK / MONTH / YEAR rollup tier. `type` is
|
||
// mandatory so the response mirrors the per-type ask the chart makes.
|
||
if (source === "rollup" && aggregate === "daily" && type && from && to) {
|
||
// Shared with the batched series endpoint, so both read the same rows.
|
||
const cap = Math.min(limit, BUCKET_CAP.daily);
|
||
const priorityJson = await loadUserSourcePriority(user.id);
|
||
const measurements = await readDailySeries({
|
||
userId: user.id,
|
||
type: type as MeasurementType,
|
||
from,
|
||
to,
|
||
limit,
|
||
priorityJson,
|
||
});
|
||
if (measurements.length > 0) {
|
||
return apiSuccess({
|
||
measurements,
|
||
meta: {
|
||
total: measurements.length,
|
||
limit: cap,
|
||
offset: 0,
|
||
aggregate: "daily",
|
||
},
|
||
});
|
||
}
|
||
// An empty result means the window genuinely holds no rows. Fall
|
||
// through to the shared live aggregate branch below to keep the
|
||
// response shape identical for the truly-empty case.
|
||
}
|
||
|
||
if (aggregate && aggregate !== "raw" && from && to) {
|
||
const grain: Exclude<AggregateGrain, "raw"> = aggregate;
|
||
const cap = Math.min(limit, BUCKET_CAP[grain]);
|
||
// Buckets are cut at the user's own calendar boundaries (#1026), the
|
||
// same day the raw list and the chart's short range use. Cumulative
|
||
// types SUM per bucket, every other type averages; overlapping sources
|
||
// collapse to the ladder-canonical one first.
|
||
const priorityJson = await loadUserSourcePriority(user.id);
|
||
const measurements = await readLiveBuckets({
|
||
userId: user.id,
|
||
type: (type as MeasurementType | undefined) ?? null,
|
||
from,
|
||
to,
|
||
cap,
|
||
priorityJson,
|
||
grain,
|
||
timeZone: await resolveUserTimezone(user.id),
|
||
});
|
||
annotate({
|
||
action: { name: "measurement.list" },
|
||
meta: { total: measurements.length, type, aggregate: grain },
|
||
});
|
||
return apiSuccess({
|
||
measurements,
|
||
meta: {
|
||
total: measurements.length,
|
||
limit: cap,
|
||
offset: 0,
|
||
aggregate: grain,
|
||
},
|
||
});
|
||
}
|
||
|
||
const [measurements, total] = await Promise.all([
|
||
prisma.measurement.findMany({
|
||
where,
|
||
// `{ id: sortDir }` is the tiebreaker, not decoration. `measuredAt`
|
||
// is not unique here and ties are the normal case. Only the exact
|
||
// tuple `(userId, type, measuredAt, source, sleepStage)` is unique,
|
||
// so a tie survives across `type` (the `stats:<id>:YYYY-MM-DD`
|
||
// daily-rollup convention puts every daily total at one instant),
|
||
// across `source` (two writers landing on the same instant) and
|
||
// across `sleepStage`. Without a unique secondary key Postgres is
|
||
// free to order the tied block differently between the query for page
|
||
// N and the query for page N+1, so a client paging its own history
|
||
// silently gets one row twice and never gets another.
|
||
orderBy: [{ [sortBy]: sortDir }, { id: sortDir }],
|
||
take: limit,
|
||
skip: offset,
|
||
}),
|
||
prisma.measurement.count({ where }),
|
||
]);
|
||
|
||
annotate({ action: { name: "measurement.list" }, meta: { total, type } });
|
||
|
||
return apiSuccess({
|
||
// v1.23 — decrypt `notesEncrypted` onto `notes` and strip the ciphertext
|
||
// before it leaves the server (iOS MeasurementWireDTO contract).
|
||
measurements: measurements.map(shapeMeasurementNotes),
|
||
meta: { total, limit, offset },
|
||
});
|
||
});
|
||
|
||
/**
|
||
* v1.11.5 — per-night collapse for the SLEEP_DURATION list. Sleep is
|
||
* stored one row per STAGE per night (minutes); a plain list leaks every
|
||
* stage segment. This collapses the raw rows into the canonical per-night
|
||
* shape used by the chart series + dashboard tile.
|
||
*
|
||
* Two modes:
|
||
* - `dayKey` set → drill-down: return the night's stage SEGMENTS (the
|
||
* canonical source's per-stage spans) so the list row can reveal the
|
||
* hypnogram's underlying rows, mirroring the cumulative-type drill-down.
|
||
* - no `dayKey` → one synthetic row per night, value = TIME ASLEEP in
|
||
* minutes (the canonical unit), with the per-night stage breakdown,
|
||
* nap count, and awakenings in the row so the UI can caption the night
|
||
* without a second fetch.
|
||
*
|
||
* The read is bounded to the last year (matching the series route's sleep
|
||
* read) so an unbounded list never walks a multi-year per-stage row set.
|
||
*/
|
||
const SLEEP_LIST_MAX_DAYS = 365;
|
||
|
||
async function sleepListResponse(
|
||
user: { id: string },
|
||
opts: {
|
||
from?: Date;
|
||
to?: Date;
|
||
limit: number;
|
||
offset: number;
|
||
sortDir: "asc" | "desc";
|
||
dayKey?: string;
|
||
},
|
||
) {
|
||
const { from, to, limit, offset, sortDir, dayKey } = opts;
|
||
const [tz, priorityJson] = await Promise.all([
|
||
resolveUserTimezone(user.id),
|
||
loadUserSourcePriority(user.id),
|
||
]);
|
||
|
||
// Bound the read window to a year ending at `to` (or now). An explicit
|
||
// `from` narrows it but never widens it: an early `from` used to walk every
|
||
// per-stage row back to that date, which on a multi-year, multi-source
|
||
// account is the unbounded read this cap exists to stop. Older nights are
|
||
// reached by moving `to` back, and `meta.windowFrom` says where a cut
|
||
// window started so a client can tell a cut from an empty history.
|
||
const windowEnd = to ?? new Date();
|
||
const floor = new Date(
|
||
windowEnd.getTime() - SLEEP_LIST_MAX_DAYS * 86_400_000,
|
||
);
|
||
const since = from && from > floor ? from : floor;
|
||
const windowCut = from !== undefined && from < floor;
|
||
const rows = await prisma.measurement.findMany({
|
||
where: {
|
||
userId: user.id,
|
||
type: "SLEEP_DURATION",
|
||
deletedAt: null,
|
||
measuredAt: {
|
||
gte: since,
|
||
...(to ? { lte: to } : {}),
|
||
},
|
||
},
|
||
orderBy: { measuredAt: "asc" },
|
||
select: {
|
||
id: true,
|
||
value: true,
|
||
measuredAt: true,
|
||
sleepStage: true,
|
||
source: true,
|
||
// Writer-level collapse: two HealthKit apps behind one source (watch
|
||
// stages vs phone in-bed) must not blend into one night.
|
||
deviceType: true,
|
||
},
|
||
});
|
||
|
||
// Drill-down — return the requested night's canonical stage segments.
|
||
if (dayKey) {
|
||
const sessions = reconstructSleepSessions(rows, tz, priorityJson).filter(
|
||
(s) => s.night === dayKey,
|
||
);
|
||
const { main, naps } = pickMainNightAndNaps(sessions);
|
||
const dayUnit = getUnitForType("SLEEP_DURATION");
|
||
// One row per stage segment of the main night + each nap, so the
|
||
// drill-down reveals exactly the rows that summed into the headline.
|
||
const segments = [main, ...naps]
|
||
.filter((s): s is NonNullable<typeof s> => s != null)
|
||
.flatMap((s) => s.segments);
|
||
const measurements = segments
|
||
.sort((a, b) =>
|
||
sortDir === "asc"
|
||
? a.start.getTime() - b.start.getTime()
|
||
: b.start.getTime() - a.start.getTime(),
|
||
)
|
||
.map((seg, i) => ({
|
||
id: `sleep-seg:${dayKey}:${i}`,
|
||
type: "SLEEP_DURATION",
|
||
value: Math.round(seg.minutes),
|
||
unit: dayUnit,
|
||
source: main?.source ?? naps[0]?.source ?? "MANUAL",
|
||
measuredAt: seg.end.toISOString(),
|
||
sleepStage: seg.stage,
|
||
notes: null,
|
||
}));
|
||
annotate({
|
||
action: { name: "measurement.list" },
|
||
meta: {
|
||
total: measurements.length,
|
||
type: "SLEEP_DURATION",
|
||
dayKey,
|
||
mode: "sleep-drill-down",
|
||
},
|
||
});
|
||
return apiSuccess({
|
||
measurements,
|
||
meta: { total: measurements.length, limit, offset: 0, dayKey },
|
||
});
|
||
}
|
||
|
||
// Per-night collapse — one synthetic row per wake day. The headline
|
||
// value is the MAIN night's TIME ASLEEP (the longest session that day),
|
||
// per the nap convention: naps are surfaced as a count, never folded
|
||
// into the main night's headline. Reconstruct sessions and pick the
|
||
// main + naps per wake day.
|
||
const sessions = reconstructSleepSessions(rows, tz, priorityJson);
|
||
const byDay = new Map<string, typeof sessions>();
|
||
for (const s of sessions) {
|
||
const list = byDay.get(s.night) ?? [];
|
||
list.push(s);
|
||
byDay.set(s.night, list);
|
||
}
|
||
|
||
const dayUnit = getUnitForType("SLEEP_DURATION");
|
||
const nightRows: Array<{
|
||
id: string;
|
||
type: string;
|
||
value: number;
|
||
unit: string;
|
||
source: string;
|
||
measuredAt: string;
|
||
notes: null;
|
||
dayKey: string;
|
||
sampleCount: number;
|
||
sleepStages: Record<string, number>;
|
||
inBedMinutes: number | null;
|
||
awakeMinutes: number | null;
|
||
napCount: number;
|
||
napAsleepMinutes: number;
|
||
awakenings: number;
|
||
}> = [];
|
||
for (const [day, daySessions] of byDay) {
|
||
const { main, naps } = pickMainNightAndNaps(daySessions);
|
||
if (!main) continue;
|
||
nightRows.push({
|
||
// Synthetic id mirrors the series route's `sleep:<night>` shape.
|
||
id: `sleep:${day}`,
|
||
type: "SLEEP_DURATION",
|
||
value: Math.round(main.asleepMinutes),
|
||
unit: dayUnit,
|
||
source: main.source ?? "MANUAL",
|
||
measuredAt: main.end.toISOString(),
|
||
notes: null,
|
||
dayKey: day,
|
||
// Drives the drill-down chevron — number of stage segments behind
|
||
// the night, matching the cumulative-type `sampleCount` contract.
|
||
sampleCount: main.segments.length,
|
||
sleepStages: main.stages as Record<string, number>,
|
||
inBedMinutes: main.inBedMinutes,
|
||
awakeMinutes: main.awakeMinutes,
|
||
napCount: naps.length,
|
||
napAsleepMinutes: naps.reduce((sum, n) => sum + n.asleepMinutes, 0),
|
||
awakenings: main.awakenings,
|
||
});
|
||
}
|
||
|
||
// Honour offset + limit against the full night set, and report the true
|
||
// pre-slice night count as `meta.total` — matching the non-sleep list
|
||
// branch's pagination contract so a client can page through nights.
|
||
const totalNights = nightRows.length;
|
||
const measurements = nightRows
|
||
.sort((a, b) => {
|
||
const cmp = a.dayKey < b.dayKey ? -1 : a.dayKey > b.dayKey ? 1 : 0;
|
||
return sortDir === "asc" ? cmp : -cmp;
|
||
})
|
||
.slice(offset, offset + limit);
|
||
|
||
annotate({
|
||
action: { name: "measurement.list" },
|
||
meta: {
|
||
total: totalNights,
|
||
type: "SLEEP_DURATION",
|
||
mode: "sleep-per-night",
|
||
rowsScanned: rows.length,
|
||
},
|
||
});
|
||
return apiSuccess({
|
||
measurements,
|
||
meta: {
|
||
total: totalNights,
|
||
limit,
|
||
offset,
|
||
groupBy: "night",
|
||
...(windowCut ? { windowFrom: since.toISOString() } : {}),
|
||
},
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Does this body attribute a source to any reading — any source at all?
|
||
*
|
||
* Read off the RAW body rather than the parsed result, and that is load-bearing
|
||
* twice over. It has to give the same answer for the object arm and the array
|
||
* arm, which parse against different schemas further down; and
|
||
* `createMeasurementSchema.source` carries `.default("MANUAL")`, so after
|
||
* parsing "said MANUAL" and "said nothing" are the same value and the
|
||
* distinction this predicate exists to draw is already gone.
|
||
*
|
||
* v1.38.x — widened from "names APPLE_HEALTH" to "names anything". Under a
|
||
* scoped credential the row's source is now resolved by the server, so an
|
||
* explicit `MANUAL` is an assertion the route would have to override — which
|
||
* is the silent relabelling the refusal below refuses to do.
|
||
*
|
||
* Anything that is not a recognisable measurement shape answers false and
|
||
* falls through to the validation error it has coming: this function decides
|
||
* attribution, not validity.
|
||
*/
|
||
function namesAnySource(body: unknown): boolean {
|
||
const names = (entry: unknown): boolean =>
|
||
typeof entry === "object" &&
|
||
entry !== null &&
|
||
(entry as { source?: unknown }).source !== undefined;
|
||
return Array.isArray(body) ? body.some(names) : names(body);
|
||
}
|
||
|
||
export const POST = apiHandler(withIdempotency<[NextRequest]>(postMeasurement));
|
||
|
||
async function postMeasurement(request: NextRequest) {
|
||
// v1.36.x — a delegated write. `user` is the record the reading belongs to,
|
||
// which every `userId: user.id` below already meant; the caller is somebody
|
||
// who may or may not be that person. Nothing else in this handler reads the
|
||
// caller: the safety-floor check, the reminder satisfaction, the rollup
|
||
// recompute and the cache eviction are all statements about the record.
|
||
//
|
||
// v1.38.x — it also declares the third-party ingest scope, so a narrow
|
||
// `measurements:write` token reaches this arm. The two declarations do not
|
||
// interact: a scoped credential is refused the moment the request names
|
||
// another record, before any grant is read, so "delegable" and "reachable by
|
||
// a bridge token" stay separate properties of the same route.
|
||
const auth = await requireRecordAuth("write", "measurements", {
|
||
scope: MEASUREMENTS_WRITE_SCOPE,
|
||
});
|
||
const { user } = auth;
|
||
const scoped = isScopedCredential(auth);
|
||
|
||
// Shared per-account write ceiling — see `checkRecordWriteRateLimit`. The
|
||
// batch siblings have always been capped; the per-record creates a looping
|
||
// client hits were not.
|
||
const writeRl = await checkRecordWriteRateLimit(auth.actor.id);
|
||
if (!writeRl.allowed) {
|
||
return apiError("Too many writes, try again later", 429, {
|
||
errorCode: "record_write.rate_limited",
|
||
});
|
||
}
|
||
|
||
const { data: body, error: jsonError } = await safeJson(request, {
|
||
maxBytes: 64 * 1024,
|
||
});
|
||
|
||
if (jsonError) return jsonError;
|
||
|
||
// A scoped credential may attribute nothing: the source of its rows is
|
||
// `EXTERNAL`, decided here from the credential rather than taken from the
|
||
// body. Checked before the validation branches so the object and array arms
|
||
// share one answer, and refused rather than rewritten for the reason the
|
||
// batch route gives: relabelling a client's explicit assertion hands it rows
|
||
// it did not ask for and no way to notice.
|
||
//
|
||
// v1.38.x — this used to refuse only an explicit `APPLE_HEALTH`, because the
|
||
// forced value was `MANUAL` and so a body naming `MANUAL` was never actually
|
||
// overridden. Now that bridge rows carry their own provenance, honouring
|
||
// such a body would defeat the point (a bridge that kept sending `MANUAL`
|
||
// would stay indistinguishable from a typed reading) and overriding it
|
||
// silently is what the paragraph above forbids. Status and `errorCode` are
|
||
// unchanged, so client error handling is untouched; the documented Home
|
||
// Assistant payload sends no `source` at all.
|
||
if (scoped && namesAnySource(body)) {
|
||
annotate({ action: { name: "measurements.create.source-not-permitted" } });
|
||
return apiError(
|
||
"This credential resolves the source itself; omit the field",
|
||
422,
|
||
{ errorCode: "measurement.create.source_not_permitted" },
|
||
);
|
||
}
|
||
|
||
// 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, scoped);
|
||
}
|
||
|
||
// Batch mode (array of measurements, e.g. combined BP + Pulse)
|
||
if (Array.isArray(body)) {
|
||
const parsed = createBatchMeasurementSchema.safeParse({
|
||
measurements: body,
|
||
});
|
||
if (!parsed.success) {
|
||
// v1.4.43 W6 — iOS batch ingest (BP + Pulse, combined BG) needs
|
||
// every issue echoed back so a one-shot retry can fix the full
|
||
// payload rather than walk it field-by-field.
|
||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||
annotate({
|
||
action: { name: "measurements.create.batch.validation-failed" },
|
||
meta: { issue_count: issues.length },
|
||
});
|
||
// v1.4.49 — strip `message` from the audit-ledger row; batch
|
||
// rows carry free-text `notes` per measurement.
|
||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||
stripValuesFromMessage: true,
|
||
});
|
||
// v1.36.x — through `auditLog()` rather than a bare `prisma.auditLog
|
||
// .create`, because that helper is the only thing that stamps
|
||
// `actorUserId`. Filed under the resolved record either way; without the
|
||
// stamp a delegate's malformed batch would read as the owner's own.
|
||
void auditLog("measurements.create.batch.validation-failed", {
|
||
userId: user.id,
|
||
details: { issues: auditIssues },
|
||
}).catch(() => {
|
||
/* swallow — 422 response is the contract */
|
||
});
|
||
return returnAllZodIssues(parsed.error, 422, {
|
||
errorCode: "measurement.create.invalid",
|
||
});
|
||
}
|
||
|
||
// The whole batch is one transaction, so a duplicate anywhere in it rolls
|
||
// the rest back — and the caller has to be told which condition it hit.
|
||
// Untouched, that duplicate escaped as an unhandled error and the batch
|
||
// answered 500, which is indistinguishable from a server fault and is what
|
||
// every client retries against.
|
||
let results;
|
||
try {
|
||
results = await prisma.$transaction(
|
||
parsed.data.measurements.map((m) =>
|
||
prisma.measurement.create({
|
||
data: {
|
||
userId: user.id,
|
||
type: m.type as MeasurementType,
|
||
value: m.value,
|
||
unit: getUnitForType(m.type),
|
||
// v1.38.x — the array arm too, not just the single create
|
||
// below. This is the combined BP + Pulse path a bridge uses for
|
||
// a two-value reading; missing it here would leave exactly those
|
||
// rows attributed MANUAL and invisible to the badge and the
|
||
// source filter. `scoped` already implies `m.source` is
|
||
// undefined — the refusal above returned otherwise — so this is
|
||
// the resolution, not an override.
|
||
source: (scoped
|
||
? EXTERNAL_SOURCE
|
||
: (m.source ?? "MANUAL")) as MeasurementSource,
|
||
measuredAt: m.measuredAt,
|
||
// v1.23 — encrypt the note at rest; the legacy plaintext column is
|
||
// written null for new rows.
|
||
notes: null,
|
||
notesEncrypted: encryptNote(m.notes ?? null),
|
||
glucoseContext:
|
||
(m.glucoseContext as GlucoseContext | undefined) ?? null,
|
||
// v1.4.25 W10 reconcile (code-review M4): mirror the
|
||
// single-entry path so the multi-entry batch persists
|
||
// `deviceType` instead of silently dropping it.
|
||
deviceType: m.deviceType ?? null,
|
||
},
|
||
}),
|
||
),
|
||
);
|
||
} catch (err) {
|
||
if (isP2002(err)) {
|
||
return apiError("A measurement with this data already exists", 409, {
|
||
errorCode: "measurement.duplicate_timestamp",
|
||
});
|
||
}
|
||
throw err;
|
||
}
|
||
|
||
const shapedResults = results.map(shapeMeasurementNotes);
|
||
|
||
await auditLog("measurement.create.batch", {
|
||
userId: user.id,
|
||
ipAddress: getClientIp(request),
|
||
details: {
|
||
count: results.length,
|
||
types: parsed.data.measurements.map((m) => m.type),
|
||
},
|
||
});
|
||
|
||
annotate({
|
||
action: { name: "measurement.create.batch" },
|
||
meta: {
|
||
count: results.length,
|
||
types: parsed.data.measurements.map((m) => m.type),
|
||
},
|
||
});
|
||
|
||
// v1.4.34 IW-G — flush every cache that reflects this user's
|
||
// measurement set so the next read paints the new rows. Interactive
|
||
// multi-entry create (combined BP + pulse form) — hard-evict so the
|
||
// SWR readers don't serve the pre-write body back to the user.
|
||
invalidateUserMeasurements(user.id, { evict: true });
|
||
|
||
// v1.18.1 — eventful Vorsorge satisfaction. Resolve the user's
|
||
// reminders against the just-landed readings now. Fire-and-forget.
|
||
fireAndForget(enqueueReminderSatisfy(user.id), {
|
||
action: "reminder.satisfy.enqueue",
|
||
});
|
||
|
||
// v1.31.0 — feed the shared arrival seams only the rows returned by the
|
||
// create-only transaction. Both callbacks are best-effort: arrival or
|
||
// morning-refresh infrastructure can never turn a committed write into an
|
||
// error response.
|
||
void emitInsertedMeasurementArrivals(user.id, results, "manual").catch(
|
||
() => {},
|
||
);
|
||
const sleepMeasuredAts = results
|
||
.filter((row) => row.type === "SLEEP_DURATION")
|
||
.map((row) => row.measuredAt);
|
||
if (sleepMeasuredAts.length > 0) {
|
||
void maybeEnqueueMorningRefresh(user.id, sleepMeasuredAts).catch(
|
||
() => {},
|
||
);
|
||
}
|
||
|
||
// v1.18.6 — absolute clinical safety-floor check on the just-written
|
||
// readings (confirm-gated, module-gated, never diagnoses). The combined
|
||
// BP form lands both arms here, so the BP floor sees a whole reading.
|
||
// The transient per-entry `symptomsPresent` flag (ORed across the batch)
|
||
// lifts a confirmed breach to the symptom-coupled emergency copy.
|
||
// Fire-and-forget — a notification failure never fails the write.
|
||
fireAndForget(
|
||
runSafetyFloorCheck({
|
||
userId: user.id,
|
||
written: results.map((r) => ({
|
||
type: r.type,
|
||
value: r.value,
|
||
measuredAt: r.measuredAt,
|
||
glucoseContext: r.glucoseContext,
|
||
})),
|
||
symptomsPresent: parsed.data.measurements.some(
|
||
(m) => m.symptomsPresent === true,
|
||
),
|
||
timezone: user.timezone ?? undefined,
|
||
}),
|
||
{ action: "measurement.safety_floor.check" },
|
||
);
|
||
|
||
// v1.37.19 (C2-F1) — the shared post-mutation tail: rollup recompute
|
||
// (collapsed per (type, day)) + status-insight re-warm, both
|
||
// best-effort. One helper so a future write surface cannot remember
|
||
// one leg and forget the other.
|
||
await afterMeasurementMutation(
|
||
user.id,
|
||
results.map((r) => ({ type: r.type, measuredAt: r.measuredAt })),
|
||
);
|
||
|
||
return apiSuccess(shapedResults, 201);
|
||
}
|
||
|
||
// Single mode (existing behavior)
|
||
const parsed = createMeasurementSchema.safeParse(body);
|
||
if (!parsed.success) {
|
||
// v1.4.43 W6 — single-row POST is the iOS fallback path; one
|
||
// round-trip per stale-field bug was the v1.4.41 iOS-contract
|
||
// pain point. Multi-issue envelope + audit breadcrumb.
|
||
const issues = sanitiseZodIssues(parsed.error.issues);
|
||
annotate({
|
||
action: { name: "measurements.create.validation-failed" },
|
||
meta: { issue_count: issues.length },
|
||
});
|
||
// v1.4.49 — strip `message` from the audit-ledger row; single
|
||
// measurement carries free-text `notes`.
|
||
const auditIssues = sanitiseZodIssues(parsed.error.issues, {
|
||
stripValuesFromMessage: true,
|
||
});
|
||
// v1.36.x — see the batch arm above: only `auditLog()` stamps the actor.
|
||
void auditLog("measurements.create.validation-failed", {
|
||
userId: user.id,
|
||
details: { issues: auditIssues },
|
||
}).catch(() => {
|
||
/* swallow — 422 response is the contract */
|
||
});
|
||
return returnAllZodIssues(parsed.error, 422, {
|
||
errorCode: "measurement.create.invalid",
|
||
});
|
||
}
|
||
|
||
const {
|
||
type,
|
||
value,
|
||
measuredAt,
|
||
notes,
|
||
source,
|
||
glucoseContext,
|
||
deviceType,
|
||
symptomsPresent,
|
||
} = parsed.data;
|
||
|
||
// Handle unique constraint violation
|
||
let measurement;
|
||
try {
|
||
measurement = await prisma.measurement.create({
|
||
data: {
|
||
userId: user.id,
|
||
type: type as MeasurementType,
|
||
value,
|
||
unit: getUnitForType(type),
|
||
// v1.38.x — server-resolved under a scoped credential. `source` is
|
||
// necessarily the schema default here (the refusal above returned on
|
||
// any body that named one), so nothing a caller asserted is being
|
||
// discarded.
|
||
source: (scoped
|
||
? EXTERNAL_SOURCE
|
||
: (source ?? "MANUAL")) as MeasurementSource,
|
||
measuredAt,
|
||
// v1.23 — encrypt the note at rest; legacy plaintext column nulled.
|
||
notes: null,
|
||
notesEncrypted: encryptNote(notes ?? null),
|
||
glucoseContext: (glucoseContext as GlucoseContext | undefined) ?? null,
|
||
// v1.4.25 W10 reconcile (code-review M4): the deviceType column
|
||
// already accepts client metadata from the batch route. Mirror
|
||
// the behaviour here so single-entry POST persists the tag
|
||
// instead of silently dropping it.
|
||
deviceType: deviceType ?? null,
|
||
},
|
||
});
|
||
} catch (err) {
|
||
// `isP2002` rather than `instanceof Prisma.PrismaClientKnownRequestError`.
|
||
// The helper's own docblock says why it exists: the structural check
|
||
// survives client-bundling quirks, and the `instanceof` form does not —
|
||
// when it misses, a duplicate escapes as an unhandled error and the caller
|
||
// gets a 500 for a condition the route has always meant to answer with a
|
||
// 409. A retry loop that meets a 500 keeps retrying.
|
||
if (isP2002(err)) {
|
||
return apiError("A measurement with this data already exists", 409, {
|
||
errorCode: "measurement.duplicate_timestamp",
|
||
});
|
||
}
|
||
throw err;
|
||
}
|
||
|
||
await auditLog("measurement.create", {
|
||
userId: user.id,
|
||
ipAddress: getClientIp(request),
|
||
details: { type, measurementId: measurement.id },
|
||
});
|
||
|
||
annotate({
|
||
action: {
|
||
name: "measurement.create",
|
||
entity_type: "measurement",
|
||
entity_id: measurement.id,
|
||
},
|
||
});
|
||
|
||
// v1.4.34 IW-G — flush every cache that reflects this user's
|
||
// measurement set so the next read paints the new row. Interactive
|
||
// single-entry create — hard-evict so the SWR readers don't serve the
|
||
// pre-write body back to the user.
|
||
invalidateUserMeasurements(user.id, { evict: true });
|
||
|
||
// v1.18.1 — eventful Vorsorge satisfaction. Resolve the user's reminders
|
||
// against the just-landed reading now. Fire-and-forget.
|
||
fireAndForget(enqueueReminderSatisfy(user.id), {
|
||
action: "reminder.satisfy.enqueue",
|
||
});
|
||
|
||
// v1.31.0 — `measurement` is the exact successful create result. A P2002
|
||
// duplicate returns above and never reaches either best-effort callback.
|
||
void emitInsertedMeasurementArrivals(user.id, [measurement], "manual").catch(
|
||
() => {},
|
||
);
|
||
if (measurement.type === "SLEEP_DURATION") {
|
||
void maybeEnqueueMorningRefresh(user.id, [measurement.measuredAt]).catch(
|
||
() => {},
|
||
);
|
||
}
|
||
|
||
// v1.18.6 — absolute clinical safety-floor check (confirm-gated,
|
||
// module-gated, never diagnoses). A single-entry POST carries only one arm
|
||
// of a BP reading, so the BP floor only fires through the combined-form /
|
||
// batch path; a lone glucose reading is evaluated here. The transient
|
||
// `symptomsPresent` flag lifts a confirmed breach to the emergency copy.
|
||
// Fire-and-forget — a notification failure never fails the write.
|
||
fireAndForget(
|
||
runSafetyFloorCheck({
|
||
userId: user.id,
|
||
written: [
|
||
{
|
||
type: measurement.type,
|
||
value: measurement.value,
|
||
measuredAt: measurement.measuredAt,
|
||
glucoseContext: measurement.glucoseContext,
|
||
},
|
||
],
|
||
symptomsPresent,
|
||
timezone: user.timezone ?? undefined,
|
||
}),
|
||
{ action: "measurement.safety_floor.check" },
|
||
);
|
||
|
||
// v1.5.0 — refresh the persistent rollup row for the affected
|
||
// (type, day) tuple. Runs inline so the next read of the
|
||
// comprehensive / analytics surface is correct without waiting on
|
||
// pg-boss; WEEK / MONTH / YEAR recomputes are enqueued under the
|
||
// hood. Best-effort — a populator hiccup never fails the user's
|
||
// write.
|
||
await afterMeasurementMutation(user.id, [
|
||
{ type: measurement.type, measuredAt: measurement.measuredAt },
|
||
]);
|
||
|
||
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,
|
||
scoped: boolean,
|
||
): 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);
|
||
// Under a scoped credential the source is resolved from the credential,
|
||
// as on the other arms of this route: the refusal above already returned
|
||
// on any body that named one.
|
||
const source = (
|
||
scoped ? EXTERNAL_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,
|
||
);
|
||
}
|