Complete the intake-event spec and align workout nullability with the DTO

- 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.
This commit is contained in:
Marc-André Bombeck committed 2026-08-14 05:44:36 +02:00
1 parent 4b009c3a45
commit f67fcec1c9
4 files changed
+312 -17

No files matched your search

+210 -12
View File
@@ -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:
@@ -9,6 +9,7 @@ import {
createMedicationSchema,
updateMedicationSchema,
intakeSchema,
listIntakeEventsSchema,
createInventoryItemSchema,
updateInventoryItemSchema,
injectionSiteEnum,
@@ -452,6 +453,42 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...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: {
+57 -1
View File
@@ -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
+8 -4
View File
@@ -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(),