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:
Marc-André Bombeck committed 2026-05-14 15:34:18 +02:00
1 parent 27f06c1576
commit 024845e5bd
4 files changed
+52 -2

No files matched your search

+14
View File
@@ -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,
},
});
}