From f67fcec1c9f17ded639ec594df933b45e0848c67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 05:44:36 +0200 Subject: [PATCH] Complete the intake-event spec and align workout nullability with the DTO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MedicationIntakeEvent declares the full row both intake responses actually return: autoMissed, attributionSource, injectionSite, doseTaken, inventoryConsumption, externalId, updatedAt, syncVersion, deletedAt were served but undeclared. - GET /api/medications/{id}/intake is published (query schema, paged envelope); the spec only knew the POST while the client-facing history list has consumed the GET since v1.4.37. - WorkoutListEntry's sportType/startedAt/endedAt/durationSec become nullable, matching the DTO — declaring them non-null was a strict-decoder bomb for the Swift client. - OpenAPI regenerated. --- docs/api/openapi.yaml | 222 +++++++++++++++++- src/lib/openapi/routes/medications/paths.ts | 37 +++ src/lib/openapi/routes/medications/schemas.ts | 58 ++++- src/lib/openapi/routes/workouts.ts | 12 +- 4 files changed, 312 insertions(+), 17 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index cc399cdda..53af43e79 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -3934,6 +3934,79 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + get: + tags: + - Medications + summary: List a medication's intake events + description: "Paged intake history for one medication (tombstoned rows excluded). `status` filters by action state: + `all` (default, byte-stable pre-v1.4.37 contract), `taken`, `skipped`, or `completed` (taken OR skipped — hides + the ambiguous never-confirmed rows). Sorting by `takenAt` pins NULLs last so skipped/planned rows do not float + above real timestamps." + parameters: + - in: path + name: id + schema: + type: string + required: true + - in: query + name: limit + schema: + default: 25 + type: integer + minimum: 1 + maximum: 500 + - in: query + name: offset + schema: + default: 0 + type: integer + minimum: 0 + maximum: 9007199254740991 + - in: query + name: sortBy + schema: + default: scheduledFor + type: string + enum: + - scheduledFor + - takenAt + - source + - createdAt + - in: query + name: sortDir + schema: + default: desc + type: string + enum: + - asc + - desc + - in: query + name: status + schema: + default: all + type: string + enum: + - all + - taken + - skipped + - completed + responses: + "200": + description: Intake event page. + content: + application/json: + schema: + $ref: "#/components/schemas/ListMedicationIntakeEventsResponse" + "401": *a1 + "403": *a6 + "404": + description: Medication not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 /api/medications/{id}/inventory: get: tags: @@ -22856,19 +22929,27 @@ components: id: type: string sportType: - type: string + anyOf: + - type: string + - type: "null" startedAt: - type: string - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" endedAt: - type: string - format: date-time - pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" durationSec: - type: integer - minimum: 0 - maximum: 9007199254740991 + anyOf: + - type: integer + minimum: 0 + maximum: 9007199254740991 + - type: "null" distanceM: anyOf: - type: number @@ -24098,6 +24179,17 @@ components: - type: "null" skipped: type: boolean + autoMissed: + type: boolean + description: True when the nightly cron closed the slot as missed (no user action). Auto-missed rows count against + adherence but never consume inventory. + attributionSource: + type: string + enum: + - AUTO + - USER_PIN + description: "How the row bound to its schedule slot: AUTO = the write path's nearest-slot resolution; USER_PIN = the + user explicitly pinned the slot (the dedup converge keeps the pinned row)." source: type: string enum: @@ -24114,6 +24206,56 @@ components: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + injectionSite: + anyOf: + - type: string + enum: + - ABDOMEN_LEFT + - ABDOMEN_RIGHT + - ABDOMEN_UPPER_LEFT + - ABDOMEN_UPPER_RIGHT + - THIGH_LEFT + - THIGH_RIGHT + - UPPER_ARM_LEFT + - UPPER_ARM_RIGHT + - type: "null" + description: Recorded injection site for a site-tracked medication (GLP-1 rotation surface). NULL when the medication + does not track sites or none was recorded. + doseTaken: + anyOf: + - type: string + - type: "null" + description: Free-text dose actually taken when it differed from the scheduled dose (titration weeks, split doses). NULL + = the scheduled dose. + inventoryConsumption: + anyOf: + - {} + - type: "null" + description: JSON ledger of the container decrements this intake caused (`[{itemId, units}]`), written by the + consumption hook. NULL when nothing was consumed (skipped / no tracked inventory). + externalId: + anyOf: + - type: string + - type: "null" + description: v1.28 — client-supplied stable id for externally-mirrored intakes (Apple Health sync); the dedup key for + re-synced rows. NULL for native rows. + syncVersion: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + description: Monotonic per-row version for incremental sync readers; bumps on every mutation. + deletedAt: + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + description: Soft-delete tombstone. Non-null rows are excluded from every list/aggregate read; sync readers use it to + propagate deletions. required: - id - userId @@ -24121,12 +24263,22 @@ components: - scheduledFor - takenAt - skipped + - autoMissed + - attributionSource - source - idempotencyKey - createdAt + - updatedAt + - injectionSite + - doseTaken + - inventoryConsumption + - externalId + - syncVersion + - deletedAt additionalProperties: false - description: Single dose log row. `takenAt` is non-null for confirmed intakes; `skipped:true` represents a - deliberately-missed dose (no inventory consumption). + description: Single dose log row — the FULL row shape both the intake POST (201/200) and the intake list GET return. + `takenAt` is non-null for confirmed intakes; `skipped:true` represents a deliberately-missed dose (no inventory + consumption). CreateMedicationIntakeResponse: type: object properties: @@ -24144,6 +24296,52 @@ components: - data - error additionalProperties: false + ListMedicationIntakeEventsResponse: + type: object + properties: + data: + type: object + properties: + events: + type: array + items: + $ref: "#/components/schemas/MedicationIntakeEvent" + meta: + type: object + properties: + total: + type: integer + minimum: 0 + maximum: 9007199254740991 + limit: + type: integer + exclusiveMinimum: 0 + maximum: 9007199254740991 + offset: + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - total + - limit + - offset + additionalProperties: false + required: + - events + - meta + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false ListMedicationInventoryResponse: type: object properties: diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index 58881a273..c33f8316b 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -9,6 +9,7 @@ import { createMedicationSchema, updateMedicationSchema, intakeSchema, + listIntakeEventsSchema, createInventoryItemSchema, updateInventoryItemSchema, injectionSiteEnum, @@ -452,6 +453,42 @@ export const medicationPaths: NonNullable = { ...stdResponses, }, }, + get: { + tags: ["Medications"], + summary: "List a medication's intake events", + description: + "Paged intake history for one medication (tombstoned rows excluded). `status` filters by action state: `all` (default, byte-stable pre-v1.4.37 contract), `taken`, `skipped`, or `completed` (taken OR skipped — hides the ambiguous never-confirmed rows). Sorting by `takenAt` pins NULLs last so skipped/planned rows do not float above real timestamps.", + requestParams: { + path: z.object({ id: z.string() }), + query: listIntakeEventsSchema, + }, + responses: { + ...recordRefusal(), + "200": { + description: "Intake event page.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + events: z.array(medicationIntakeEventResource), + meta: z.object({ + total: z.number().int().nonnegative(), + limit: z.number().int().positive(), + offset: z.number().int().nonnegative(), + }), + }), + "ListMedicationIntakeEventsResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, }, "/api/medications/{id}/inventory": { get: { diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index a5cb26b1d..cb54ebddf 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -427,14 +427,70 @@ export const medicationIntakeEventResource = z scheduledFor: z.iso.datetime({ offset: true }), takenAt: z.iso.datetime({ offset: true }).nullable(), skipped: z.boolean(), + autoMissed: z + .boolean() + .describe( + "True when the nightly cron closed the slot as missed (no user action). Auto-missed rows count against adherence but never consume inventory.", + ), + attributionSource: z + .enum(["AUTO", "USER_PIN"]) + .describe( + "How the row bound to its schedule slot: AUTO = the write path's nearest-slot resolution; USER_PIN = the user explicitly pinned the slot (the dedup converge keeps the pinned row).", + ), source: z.enum(["WEB", "API", "REMINDER", "IMPORT", "APPLE_HEALTH"]), idempotencyKey: z.string().nullable(), createdAt: z.iso.datetime({ offset: true }), + updatedAt: z.iso.datetime({ offset: true }), + injectionSite: z + .enum([ + "ABDOMEN_LEFT", + "ABDOMEN_RIGHT", + "ABDOMEN_UPPER_LEFT", + "ABDOMEN_UPPER_RIGHT", + "THIGH_LEFT", + "THIGH_RIGHT", + "UPPER_ARM_LEFT", + "UPPER_ARM_RIGHT", + ]) + .nullable() + .describe( + "Recorded injection site for a site-tracked medication (GLP-1 rotation surface). NULL when the medication does not track sites or none was recorded.", + ), + doseTaken: z + .string() + .nullable() + .describe( + "Free-text dose actually taken when it differed from the scheduled dose (titration weeks, split doses). NULL = the scheduled dose.", + ), + inventoryConsumption: z + .unknown() + .nullable() + .describe( + "JSON ledger of the container decrements this intake caused (`[{itemId, units}]`), written by the consumption hook. NULL when nothing was consumed (skipped / no tracked inventory).", + ), + externalId: z + .string() + .nullable() + .describe( + "v1.28 — client-supplied stable id for externally-mirrored intakes (Apple Health sync); the dedup key for re-synced rows. NULL for native rows.", + ), + syncVersion: z + .number() + .int() + .describe( + "Monotonic per-row version for incremental sync readers; bumps on every mutation.", + ), + deletedAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "Soft-delete tombstone. Non-null rows are excluded from every list/aggregate read; sync readers use it to propagate deletions.", + ), }) .meta({ id: "MedicationIntakeEvent", description: - "Single dose log row. `takenAt` is non-null for confirmed intakes; `skipped:true` represents a deliberately-missed dose (no inventory consumption).", + "Single dose log row — the FULL row shape both the intake POST (201/200) and the intake list GET return. `takenAt` is non-null for confirmed intakes; `skipped:true` represents a deliberately-missed dose (no inventory consumption).", }); export const medicationCadenceTimelinePoint = z diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index 42b312b27..dd5cf5477 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -86,10 +86,14 @@ const workoutBatchResponse = z const workoutListEntry = z .object({ id: z.string(), - sportType: z.string(), - startedAt: z.iso.datetime({ offset: true }), - endedAt: z.iso.datetime({ offset: true }), - durationSec: z.number().int().nonnegative(), + // v1.37.19 — these four are nullable to match the DTO (list-read.ts + // passes them through from nullable columns): an externally-synced + // session can arrive without a sport type or timing. Declaring them + // non-null was a strict-decoder bomb for the Swift client. + sportType: z.string().nullable(), + startedAt: z.iso.datetime({ offset: true }).nullable(), + endedAt: z.iso.datetime({ offset: true }).nullable(), + durationSec: z.number().int().nonnegative().nullable(), distanceM: z.number().nullable(), activeEnergyKcal: z.number().nullable(), avgHr: z.number().int().nullable(),