mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
4b009c3a45
commit
f67fcec1c9
4 files changed
+312
-17
No files matched your search
+210
-12
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user