From c49fe731e41d3f10eee08c95e35682c1123d674b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 14 May 2026 16:56:06 +0200 Subject: [PATCH] fix(measurements): accept deviceType on single-entry POST (mirrors batch route) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Measurement.deviceType` exists on the row, was added by the W8c batch ingest path, but the single-entry `createMeasurementSchema` omitted it — every single-row POST silently dropped the device tag even when the client supplied one. Manual rows tagged with watch / scale / ring metadata lost their attribution at the schema boundary and showed up as unsourced. Declare `deviceType` on the schema (optional, nullable, 1–32 chars), thread it through both single-entry and array-body POST handlers, and add three RTL cases (present, null, omitted) so the parsed shape stays observable. --- src/app/api/measurements/route.ts | 11 ++++- .../validations/__tests__/measurement.test.ts | 42 +++++++++++++++++++ src/lib/validations/measurement.ts | 8 ++++ 3 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 33af9e880..6e756e561 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -96,6 +96,10 @@ async function postMeasurement(request: NextRequest) { notes: 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, }, }), ), @@ -127,7 +131,7 @@ async function postMeasurement(request: NextRequest) { return apiError(parsed.error.issues[0].message, 422); } - const { type, value, measuredAt, notes, source, glucoseContext } = + const { type, value, measuredAt, notes, source, glucoseContext, deviceType } = parsed.data; // Handle unique constraint violation @@ -143,6 +147,11 @@ async function postMeasurement(request: NextRequest) { measuredAt, notes: 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) { diff --git a/src/lib/validations/__tests__/measurement.test.ts b/src/lib/validations/__tests__/measurement.test.ts index 54e2d919b..26a781667 100644 --- a/src/lib/validations/__tests__/measurement.test.ts +++ b/src/lib/validations/__tests__/measurement.test.ts @@ -130,5 +130,47 @@ describe("measurement validation", () => { }); expect(parsed.success).toBe(false); }); + + // v1.4.25 W10 reconcile (code-review M4): `deviceType` was a + // batch-only field; the single-entry schema silently dropped it. + // Now it parses through so the column is populated whether the + // client posts one row or many. + it("accepts an optional deviceType tag and surfaces it on the parsed value", () => { + const parsed = createMeasurementSchema.safeParse({ + ...validBase, + type: "WEIGHT", + value: 82, + deviceType: "scale", + }); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.deviceType).toBe("scale"); + } + }); + + it("accepts a null deviceType so a client can explicitly clear the column", () => { + const parsed = createMeasurementSchema.safeParse({ + ...validBase, + type: "WEIGHT", + value: 82, + deviceType: null, + }); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.deviceType).toBeNull(); + } + }); + + it("treats a missing deviceType as undefined (back-compat with pre-W10 clients)", () => { + const parsed = createMeasurementSchema.safeParse({ + ...validBase, + type: "WEIGHT", + value: 82, + }); + expect(parsed.success).toBe(true); + if (parsed.success) { + expect(parsed.data.deviceType).toBeUndefined(); + } + }); }); }); diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index e51f30b6c..7f7359d8e 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -205,6 +205,14 @@ export const createMeasurementSchema = z // Only applies when type === BLOOD_GLUCOSE. Mirrored by a CHECK // constraint in Postgres (see migration 0021). glucoseContext: glucoseContextEnum.optional(), + // v1.4.25 W10 reconcile (code-review M4): the single-entry POST + // dropped `deviceType` silently because the column existed on + // `Measurement` and was accepted by the batch route, but never + // declared in this schema. iOS clients that POST one row at a + // time (and dashboards that backfill manual rows with device + // metadata) now persist the tag instead of seeing it disappear. + // Accepts `null` so a client can explicitly clear the column. + deviceType: z.string().min(1).max(32).nullable().optional(), }) .refine((data) => validateMeasurementRange(data.type, data.value) === null, { message: "Value out of plausible range",