diff --git a/prisma/migrations/0051_measurement_device_type/migration.sql b/prisma/migrations/0051_measurement_device_type/migration.sql new file mode 100644 index 000000000..dc0454d3b --- /dev/null +++ b/prisma/migrations/0051_measurement_device_type/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ab0769b4b..a5e25b337 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index c57a758a8..7c9274fa3 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -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 { 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, }, }); } diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index a10448ae5..cc11ea9d0 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -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 = { 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 } },