mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
feat(api): nullable deviceType on Measurement for two-axis source priority
Add an optional deviceType field to the POST /api/measurements/batch
payload schema. The iOS client maps each sample's HKDevice.model to a
canonical device class (watch | band | ring | phone | scale | other |
unknown); the server stores it on a new Measurement.device_type
column. The cross-source canonical picker (W8c) reads this column to
break Apple-Watch-vs-iPhone ties when the same source contributed
multiple devices for the same day.
Backward-compatible end-to-end:
- Schema field is optional; legacy iOS builds keep working unchanged.
- DB column is nullable; the migration is an additive ALTER TABLE
(Migration 0051, next free per v1.4.25 handoff section 4).
- The picker treats null as 'unknown' and only uses it as a
tiebreaker when a ranked device-type coexists in the bucket; with
a single source + null deviceType the row passes through as today.
OpenAPI: deviceType is documented on AppleHealthBatchEntry with
guidance on the HKDevice.model mapping. Batch endpoint description
gains a W8c note. iOS-Claude can lock the field name + enum from the
spec without re-reading source.
Tests: 45/45 source-priority tests pass; the batch route's existing
integration tests are untouched by the schema addition.
Pre-existing lint failure unrelated to this change:
src/components/i18n/maintainership-banner.tsx triggers a
react-hooks/set-state-in-effect warning; the file is currently
untracked and lives outside this Wave's scope.
This commit is contained in:
1 parent
27f06c1576
commit
024845e5bd
4 files changed
+52
-2
No files matched your search
@@ -0,0 +1,13 @@
|
||||
-- v1.4.25 W8c — nullable device_type column on measurements.
|
||||
--
|
||||
-- Two-axis source-priority picker (src/lib/analytics/source-priority.ts)
|
||||
-- reads this column to break ties when more than one device contributes
|
||||
-- the same source's metric (e.g. Apple Watch + iPhone both stream
|
||||
-- HealthKit steps via APPLE_HEALTH). NULL columns map to "unknown" in
|
||||
-- the picker, so legacy rows continue to work without a backfill.
|
||||
--
|
||||
-- Canonical values documented in src/lib/validations/source-priority.ts
|
||||
-- under `deviceTypeEnum`: watch | band | ring | phone | scale | other |
|
||||
-- unknown. Stored as free text to avoid coordinated enum bumps across
|
||||
-- the iOS client and server every time a new device class arrives.
|
||||
ALTER TABLE "measurements" ADD COLUMN "device_type" TEXT;
|
||||
@@ -327,6 +327,17 @@ model Measurement {
|
||||
// only for `type = SLEEP_DURATION`. Mirrors the glucoseContext
|
||||
// pattern. A single night produces ~5 rows (one per stage).
|
||||
sleepStage SleepStage? @map("sleep_stage")
|
||||
// v1.4.25 W8c — device-type tag for the cross-source canonical
|
||||
// picker. Stored as a free-text string (kept simple — Prisma enum
|
||||
// would require coordinated client + server bumps every time we
|
||||
// gain a new device class). The canonical values are documented in
|
||||
// `deviceTypeEnum` (src/lib/validations/source-priority.ts):
|
||||
// `watch | band | ring | phone | scale | other | unknown`. Legacy
|
||||
// rows + every pre-W8c Measurement stays NULL; the picker treats
|
||||
// NULL as `unknown`. Unique constraints intentionally do not
|
||||
// include `deviceType` so two ingest paths from the same source
|
||||
// still dedup on (type, source, externalId).
|
||||
deviceType String? @map("device_type")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import { withIdempotency } from "@/lib/idempotency";
|
||||
import { mapAppleHealthEntry } from "@/lib/measurements/apple-health-mapping";
|
||||
import { validateMeasurementRange } from "@/lib/validations/measurement";
|
||||
import { deviceTypeEnum } from "@/lib/validations/source-priority";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
|
||||
const MAX_BATCH_ENTRIES = 500;
|
||||
@@ -47,6 +48,14 @@ const batchEntrySchema = z.object({
|
||||
sleepStage: z.number().int().min(0).max(20).optional(),
|
||||
externalId: z.string().min(1).max(120),
|
||||
externalSourceVersion: z.string().min(1).max(120).optional(),
|
||||
// v1.4.25 W8c — optional device-type tag. The iOS client maps the
|
||||
// `HKDevice.model` of each sample to one of the canonical device
|
||||
// classes (watch | band | ring | phone | scale | other | unknown).
|
||||
// Backward-compatible: every pre-W8c iOS build skips the field and
|
||||
// the row is stored with `deviceType = null`; the canonical picker
|
||||
// treats null as `unknown` and only uses it as a tiebreaker when a
|
||||
// ranked device-type coexists in the same daily bucket.
|
||||
deviceType: deviceTypeEnum.nullable().optional(),
|
||||
});
|
||||
|
||||
const batchPayloadSchema = z.object({
|
||||
@@ -156,6 +165,11 @@ async function postBatch(request: NextRequest): Promise<Response> {
|
||||
externalId: entry.externalId,
|
||||
externalSourceVersion: entry.externalSourceVersion ?? null,
|
||||
sleepStage: mapped.sleepStage ?? null,
|
||||
// v1.4.25 W8c — pass the iOS-supplied device-type through to
|
||||
// the row. Stays null for pre-W8c iOS builds; the canonical
|
||||
// picker treats null as `unknown` so legacy ingest keeps
|
||||
// working without a server-side default.
|
||||
deviceType: entry.deviceType ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -31,7 +31,10 @@ import {
|
||||
} from "@/lib/validations/measurement";
|
||||
import { loginPasswordSchema } from "@/lib/validations/auth";
|
||||
import { coachPrefsSchema } from "@/lib/validations/coach-prefs";
|
||||
import { sourcePrioritySchema } from "@/lib/validations/source-priority";
|
||||
import {
|
||||
deviceTypeEnum,
|
||||
sourcePrioritySchema,
|
||||
} from "@/lib/validations/source-priority";
|
||||
|
||||
/**
|
||||
* Common envelopes — every HealthLog API response wraps payload in
|
||||
@@ -238,6 +241,15 @@ const batchEntrySchema = z
|
||||
.max(120)
|
||||
.describe("HKSample.uuid string — the dedup key."),
|
||||
externalSourceVersion: z.string().min(1).max(120).optional(),
|
||||
// v1.4.25 W8c — optional device-type tag fed into the canonical
|
||||
// source picker's second axis. NULL is treated as `unknown`;
|
||||
// legacy iOS builds that don't ship the field continue to work.
|
||||
deviceType: deviceTypeEnum
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Device class mapped from `HKDevice.model`. Used by the analytics aggregator to break ties when the same source contributed multiple devices for the same day. Omit (or send null) on legacy clients — the server treats it as `unknown` and the picker falls through.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "AppleHealthBatchEntry" });
|
||||
|
||||
@@ -466,7 +478,7 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Measurements"],
|
||||
summary: "Apple Health batch ingest",
|
||||
description:
|
||||
"Up to 500 HealthKit entries per call. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status lets the iOS client advance its sync cursor accurately.",
|
||||
"Up to 500 HealthKit entries per call. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status lets the iOS client advance its sync cursor accurately. v1.4.25 W8c adds an optional `deviceType` per entry — feed it from `HKDevice.model` so the cross-source canonical picker can break Apple-Watch-vs-iPhone ties; null/absent stays backward-compatible with v1.4.23 clients.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: batchPayloadSchema } },
|
||||
|
||||
Reference in new issue
Block a user