mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge pull request #501 from MBombeck/release/v1.28.44
release/v1.28.44 — loose ends: broken link, API contract, module guards
This commit is contained in:
17 files changed
+1433
-16
No files matched your search
@@ -2,6 +2,17 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.28.44] — 2026-07-16 — Loose ends: a broken link, the API contract, module guards
|
||||
|
||||
- The "connect a device" link on the sleep insights empty state pointed at a
|
||||
settings page that doesn't exist and returned a 404; it now opens the
|
||||
integrations settings.
|
||||
- Six endpoints the iOS app already uses were missing from the published API
|
||||
contract (`docs/api/openapi.yaml`); they are now documented, so the contract
|
||||
is complete again.
|
||||
- A few module pages didn't redirect away when their module was turned off, the
|
||||
way the others do; they now behave consistently.
|
||||
|
||||
## [1.28.43] — 2026-07-16 — Visual consistency polish
|
||||
|
||||
A pass over small visual inconsistencies the design audit surfaced:
|
||||
|
||||
+794
-1
@@ -1,7 +1,7 @@
|
||||
openapi: 3.1.0
|
||||
info:
|
||||
title: HealthLog API
|
||||
version: 1.28.43
|
||||
version: 1.28.44
|
||||
description: >-
|
||||
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
|
||||
|
||||
@@ -827,6 +827,25 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/measurement-categories:
|
||||
get:
|
||||
tags:
|
||||
- Meta
|
||||
summary: Measurement categorisation overlay
|
||||
description: "Returns the UI-side measurement categorisation as an HTTP contract: the ordered category list (id + i18n
|
||||
label key + order) plus the MeasurementType → category assignments. iOS reads this on cold start to drive the
|
||||
HealthKit permission picker (one consent screen per category) and caches it client-side (`Cache-Control: public,
|
||||
max-age=600`). Auth via cookie or Bearer (not admin); no PII."
|
||||
responses:
|
||||
"200":
|
||||
description: Categorisation overlay.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/MeasurementCategoriesEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/meta/capabilities:
|
||||
get:
|
||||
tags:
|
||||
@@ -1451,6 +1470,31 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/auth/check-user:
|
||||
post:
|
||||
tags:
|
||||
- Auth
|
||||
summary: Resolve the next sign-in step for a typed identifier
|
||||
description: Given an email or username, returns which onboarding branch the iOS client should render plus the
|
||||
`hasPasskey` / `hasPassword` booleans so it can offer a Passkey affordance alongside a password field without a
|
||||
second round-trip. Anonymous surface; per-IP rate-limited (30 requests / 15 min). The response is the same
|
||||
whether or not the identifier matched, and the identifier is never echoed back.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CheckUserRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Discovery result.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CheckUserEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/auth/login:
|
||||
post:
|
||||
tags:
|
||||
@@ -2887,6 +2931,34 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/medications/intake/bulk:
|
||||
post:
|
||||
tags:
|
||||
- Medications
|
||||
summary: Bulk medication-intake backfill (iOS SyncMode)
|
||||
description: Up to 500 intake entries per call, mirroring the mood-entries bulk envelope so the iOS sync engine reuses
|
||||
one retry/cursor path. Idempotent via the `Idempotency-Key` header plus per-entry `idempotencyKey` /
|
||||
`externalId`. Per-entry status (`inserted` / `updated` / `duplicate` / `skipped`) lets the client advance its
|
||||
cursor. Rate-limited 60/min/user. An `APPLE_HEALTH` entry must carry `externalId` and target a medication
|
||||
mirrored from Apple Health, else the whole batch 422s (`meta.errorCode =
|
||||
medications.intake.bulk.apple_health_not_mirrored`); an over-size batch 422s
|
||||
(`medications.intake.bulk.too_large`); a malformed body 422s (`medications.intake.bulk.invalid`).
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BulkMedicationIntakeRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Batch processed (always 200 on a well-formed body).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BulkMedicationIntakeResponseEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/medications:
|
||||
get:
|
||||
tags:
|
||||
@@ -6024,6 +6096,27 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/insights/cards:
|
||||
get:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Insight cards (iOS adapter)
|
||||
description: "v1.4.31 — the native-client adapter over the same alert rule engine the web comprehensive surface
|
||||
consumes: measurements, BP-in-target, weight trend, pulse, and cadence-aware medication compliance are fed
|
||||
through `generateAlerts()` and each resulting `HealthAlert` is re-shaped to the iOS Insight card model.
|
||||
Deterministic — no LLM call on this path. Module-gated on `insights` and the operator `insightStatus` assistant
|
||||
surface. Auth via cookie or Bearer."
|
||||
responses:
|
||||
"200":
|
||||
description: The list of insight cards (possibly empty).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/InsightsCardsEnvelope"
|
||||
"401": *a1
|
||||
"403": *a5
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/insights/pregenerate:
|
||||
post:
|
||||
tags:
|
||||
@@ -6669,6 +6762,32 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/mood-entries/bulk:
|
||||
post:
|
||||
tags:
|
||||
- Mood
|
||||
summary: Bulk mood backfill (iOS SyncMode)
|
||||
description: "Drains the iOS local mood log in one shot: up to 500 entries per call with per-entry UPSERT semantics
|
||||
keyed by `externalId`, so an adopt-on-pair backfill or a retried batch is idempotent. Idempotent via the
|
||||
`Idempotency-Key` header too. Per-entry status (`inserted` / `duplicate` / `skipped`) advances the client
|
||||
cursor. Rate-limited 60/min/user. An over-size batch 422s (`meta.errorCode = mood.bulk.too_large`); a malformed
|
||||
body 422s (`mood.bulk.invalid`)."
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BulkMoodRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Batch processed (always 200 on a well-formed body).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/BulkMoodResponseEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/mood/tags:
|
||||
get:
|
||||
tags:
|
||||
@@ -10477,6 +10596,46 @@ paths:
|
||||
"403": *a17
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/integrations/healthkit:
|
||||
get:
|
||||
tags:
|
||||
- Integrations
|
||||
summary: Read the HealthKit integration config
|
||||
description: Returns the resolved per-metric HealthKit config — the default metric set merged with the user's stored
|
||||
overrides — plus the last HealthKit sync instant. Auth via cookie or Bearer.
|
||||
responses:
|
||||
"200":
|
||||
description: Resolved HealthKit config.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HealthKitConfigEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
patch:
|
||||
tags:
|
||||
- Integrations
|
||||
summary: Update the HealthKit integration config
|
||||
description: Merges the supplied entries into the stored config by `id` (unknown ids are ignored) and returns the
|
||||
resolved config (defaults merged) so the client always sees a complete metric list. `lastSyncedAt` is null on
|
||||
the echo. Auth via cookie or Bearer.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HealthKitConfigPatchRequest"
|
||||
responses:
|
||||
"200":
|
||||
description: Updated + resolved HealthKit config.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HealthKitConfigPatchEnvelope"
|
||||
"401": *a1
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
components:
|
||||
schemas:
|
||||
EncryptedExportRequest:
|
||||
@@ -10490,6 +10649,17 @@ components:
|
||||
server-side recovery.
|
||||
required:
|
||||
- passphrase
|
||||
CheckUserRequest:
|
||||
type: object
|
||||
properties:
|
||||
identifier:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 254
|
||||
description: The typed identifier — either an email or a username. Queried verbatim (no case-folding), never echoed back.
|
||||
required:
|
||||
- identifier
|
||||
description: "Discovery lookup for the iOS onboarding flow: given a typed email or username, resolve the next sign-in step."
|
||||
MfaVerifyRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -10917,6 +11087,77 @@ components:
|
||||
- bundleId
|
||||
description: Native device registration. Re-registering an APNs token belonging to another user returns 409
|
||||
(cross-user-hijack guard).
|
||||
BulkMedicationIntakeRequest:
|
||||
type: object
|
||||
properties:
|
||||
entries:
|
||||
minItems: 1
|
||||
maxItems: 500
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/BulkMedicationIntakeEntry"
|
||||
required:
|
||||
- entries
|
||||
description: iOS SyncMode bulk intake backfill. 1–500 entries per call; idempotent via `Idempotency-Key` and per-entry
|
||||
`idempotencyKey` / `externalId`.
|
||||
BulkMedicationIntakeEntry:
|
||||
type: object
|
||||
properties:
|
||||
medicationId:
|
||||
type: string
|
||||
minLength: 1
|
||||
scheduledFor:
|
||||
description: ISO instant; defaults to `takenAt` then now() when omitted.
|
||||
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)))$
|
||||
takenAt:
|
||||
description: ISO instant of the take; omit + `skipped:false` = pending.
|
||||
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)))$
|
||||
skipped:
|
||||
description: Default false.
|
||||
type: boolean
|
||||
idempotencyKey:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 128
|
||||
injectionSite:
|
||||
description: Per-entry injection site; a disallowed site marks THIS entry skipped without failing the batch.
|
||||
type: string
|
||||
enum:
|
||||
- ABDOMEN_LEFT
|
||||
- ABDOMEN_RIGHT
|
||||
- ABDOMEN_UPPER_LEFT
|
||||
- ABDOMEN_UPPER_RIGHT
|
||||
- THIGH_LEFT
|
||||
- THIGH_RIGHT
|
||||
- UPPER_ARM_LEFT
|
||||
- UPPER_ARM_RIGHT
|
||||
forceSlotInstant:
|
||||
description: Pin a taken entry onto a named real scheduled slot; ignored on non-taken entries.
|
||||
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)))$
|
||||
doseTaken:
|
||||
description: Per-entry dose override; persisted only on a taken entry.
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 50
|
||||
source:
|
||||
description: v1.28 — Apple Health dose-event import. Must be supplied together with `externalId`, and may only target a
|
||||
medication mirrored from Apple Health (else the whole batch 422s).
|
||||
type: string
|
||||
const: APPLE_HEALTH
|
||||
externalId:
|
||||
description: The HealthKit dose-event UUID. Drives the idempotent re-sync dedup (first-write-wins per Apple dose).
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 128
|
||||
required:
|
||||
- medicationId
|
||||
description: One bulk medication-intake entry. `source` + `externalId` are both-or-neither.
|
||||
CreateMedicationRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -12027,6 +12268,86 @@ components:
|
||||
properties: {}
|
||||
description: No body fields. The user is taken from the session / Bearer and the locale from the session; the warm
|
||||
covers every assessment for that user.
|
||||
BulkMoodRequest:
|
||||
type: object
|
||||
properties:
|
||||
entries:
|
||||
minItems: 1
|
||||
maxItems: 500
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/BulkMoodEntry"
|
||||
required:
|
||||
- entries
|
||||
description: iOS SyncMode bulk mood backfill. 1–500 entries per call; per-entry UPSERT keyed by `externalId` so re-runs
|
||||
are idempotent.
|
||||
BulkMoodEntry:
|
||||
type: object
|
||||
properties:
|
||||
mood:
|
||||
type: string
|
||||
enum:
|
||||
- SUPER_GUT
|
||||
- GUT
|
||||
- OKAY
|
||||
- SCHLECHT
|
||||
- LAUSIG
|
||||
tags:
|
||||
maxItems: 20
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
maxLength: 50
|
||||
tagKeys:
|
||||
description: Structured-tag keys from the catalog; unknown keys dropped.
|
||||
maxItems: 30
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
maxLength: 60
|
||||
ratedFactors:
|
||||
description: Rated mood factors; an out-of-scale rating marks THIS entry skipped, never the batch.
|
||||
maxItems: 30
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
maxLength: 60
|
||||
rating:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 5
|
||||
required:
|
||||
- key
|
||||
- rating
|
||||
note:
|
||||
type: string
|
||||
maxLength: 500
|
||||
moodLoggedAt:
|
||||
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)))$
|
||||
description: ISO instant the entry was logged.
|
||||
source:
|
||||
description: Defaults to MANUAL.
|
||||
type: string
|
||||
enum:
|
||||
- MANUAL
|
||||
- MOODLOG
|
||||
- WEB
|
||||
- TELEGRAM
|
||||
- DAYLIO
|
||||
externalId:
|
||||
description: iOS-side source-stable id for idempotent dedup on the `(userId, source, externalId)` key.
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 120
|
||||
required:
|
||||
- mood
|
||||
- moodLoggedAt
|
||||
description: One bulk mood entry (UPSERT keyed by `externalId`).
|
||||
MeasurementReminderCreate:
|
||||
type: object
|
||||
properties:
|
||||
@@ -12338,6 +12659,40 @@ components:
|
||||
- caffeine
|
||||
description: Closed nutrient catalog code — 24 HealthKit Dietary vitamin/mineral types plus water and caffeine. Energy,
|
||||
macros and sodium/potassium are out of scope.
|
||||
HealthKitConfigPatchRequest:
|
||||
type: object
|
||||
properties:
|
||||
entries:
|
||||
maxItems: 50
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
kind:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
direction:
|
||||
type: string
|
||||
enum:
|
||||
- bidirectional
|
||||
- readOnly
|
||||
- writeOnly
|
||||
- disabled
|
||||
description: Per-metric sync direction.
|
||||
enabled:
|
||||
type: boolean
|
||||
required:
|
||||
- id
|
||||
- direction
|
||||
required:
|
||||
- entries
|
||||
description: Merge-by-`id` update of the HealthKit metric config. Unknown ids are silently ignored; omitted fields fall
|
||||
back to the stored (or default) value for that entry. Up to 50 entries per call.
|
||||
MedicationTreatmentClass:
|
||||
type: string
|
||||
enum:
|
||||
@@ -13533,6 +13888,66 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
MeasurementCategoriesEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/MeasurementCategoriesResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
MeasurementCategoriesResponse:
|
||||
type: object
|
||||
properties:
|
||||
version:
|
||||
type: number
|
||||
const: 1
|
||||
description: Additive schema marker; assignments never reshuffle.
|
||||
categories:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Category id (e.g. `vitals`).
|
||||
labelKey:
|
||||
type: string
|
||||
description: i18n key for the category label (`categories.<id>`).
|
||||
order:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Stable display order.
|
||||
required:
|
||||
- id
|
||||
- labelKey
|
||||
- order
|
||||
additionalProperties: false
|
||||
description: The ordered category list for the picker.
|
||||
assignments:
|
||||
type: object
|
||||
propertyNames:
|
||||
type: string
|
||||
additionalProperties:
|
||||
type: string
|
||||
description: MeasurementType → category id map.
|
||||
required:
|
||||
- version
|
||||
- categories
|
||||
- assignments
|
||||
additionalProperties: false
|
||||
description: "The measurement categorisation overlay: the ordered category list plus the MeasurementType → category
|
||||
assignments. Derived server-side from the canonical map; cached `public, max-age=600`."
|
||||
CapabilitiesEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -13922,6 +14337,49 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CheckUserEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/CheckUserResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CheckUserResponse:
|
||||
type: object
|
||||
properties:
|
||||
branch:
|
||||
type: string
|
||||
enum:
|
||||
- not_found
|
||||
- passkey_only
|
||||
- email_fallback
|
||||
- exists
|
||||
description: "Next UX step: `not_found` (show sign-up), `passkey_only` (Sign in with Passkey), `email_fallback`
|
||||
(password field, with a Passkey affordance when applicable), `exists` (account with no usable credential —
|
||||
recovery path)."
|
||||
hasPasskey:
|
||||
type: boolean
|
||||
description: The account has at least one registered passkey.
|
||||
hasPassword:
|
||||
type: boolean
|
||||
description: The account has a password hash.
|
||||
required:
|
||||
- branch
|
||||
- hasPasskey
|
||||
- hasPassword
|
||||
additionalProperties: false
|
||||
description: Account-existence + credential shape. The response is identical whether or not the identifier matched
|
||||
(account-existence is the explicit contract iOS needs); the identifier is never echoed.
|
||||
LoginResponse:
|
||||
type: object
|
||||
properties:
|
||||
@@ -16864,6 +17322,94 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
BulkMedicationIntakeResponseEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/BulkMedicationIntakeResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
BulkMedicationIntakeResponse:
|
||||
type: object
|
||||
properties:
|
||||
processed:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
inserted:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
updated:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
duplicates:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
skipped:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
index:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
reason:
|
||||
type: string
|
||||
required:
|
||||
- index
|
||||
- reason
|
||||
additionalProperties: false
|
||||
entries:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/BulkMedicationIntakeEntryResult"
|
||||
required:
|
||||
- processed
|
||||
- inserted
|
||||
- updated
|
||||
- duplicates
|
||||
- skipped
|
||||
- entries
|
||||
additionalProperties: false
|
||||
BulkMedicationIntakeEntryResult:
|
||||
type: object
|
||||
properties:
|
||||
index:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- inserted
|
||||
- updated
|
||||
- duplicate
|
||||
- skipped
|
||||
description: "`inserted`/`updated`/`duplicate` — the row landed (advance the cursor). `skipped` — not stored; see
|
||||
`reason`."
|
||||
reason:
|
||||
type: string
|
||||
id:
|
||||
description: The landed row id (absent on skips).
|
||||
type: string
|
||||
required:
|
||||
- index
|
||||
- status
|
||||
additionalProperties: false
|
||||
ListMedicationsResponse:
|
||||
type: object
|
||||
properties:
|
||||
@@ -23324,6 +23870,89 @@ components:
|
||||
additionalProperties: false
|
||||
description: AI-generated insights bundle. Strict-schema validated server-side; Coach-routed when the insight surface
|
||||
needs day-level grounding.
|
||||
InsightsCardsEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/InsightsCardsResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
InsightsCardsResponse:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/InsightCard"
|
||||
InsightCard:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Stable per-card id (e.g. `alert-1`).
|
||||
title:
|
||||
type: string
|
||||
summary:
|
||||
type: string
|
||||
description: One-line alert message.
|
||||
body:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: Longer narrative; null on the current rule-engine cards.
|
||||
severity:
|
||||
type: string
|
||||
enum:
|
||||
- alert
|
||||
- caution
|
||||
- info
|
||||
- good
|
||||
description: Mapped from the underlying alert level (danger→alert, warning→caution, success→good, else info).
|
||||
recommendations:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
label:
|
||||
type: string
|
||||
actionURL:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
required:
|
||||
- id
|
||||
- label
|
||||
- actionURL
|
||||
additionalProperties: false
|
||||
description: Suggested follow-ups; empty on the current rule-engine cards.
|
||||
generatedAt:
|
||||
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)))$
|
||||
provider:
|
||||
type: string
|
||||
description: Lower-cased AI provider label for the account (e.g. `claude`).
|
||||
required:
|
||||
- id
|
||||
- title
|
||||
- summary
|
||||
- body
|
||||
- severity
|
||||
- recommendations
|
||||
- generatedAt
|
||||
- provider
|
||||
additionalProperties: false
|
||||
description: One iOS insight card, re-shaped from a server-side HealthAlert. Deterministic rule-engine output — no LLM
|
||||
call on this path.
|
||||
InsightsPregenerateResponseEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -24524,6 +25153,90 @@ components:
|
||||
- generatedAt
|
||||
additionalProperties: false
|
||||
description: Per-analyte change between the two most-recent numeric lab panels. Neutral framing, never a diagnosis.
|
||||
BulkMoodResponseEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/BulkMoodResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
BulkMoodResponse:
|
||||
type: object
|
||||
properties:
|
||||
processed:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
inserted:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
duplicates:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
skipped:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
index:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
reason:
|
||||
type: string
|
||||
required:
|
||||
- index
|
||||
- reason
|
||||
additionalProperties: false
|
||||
entries:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/BulkMoodEntryResult"
|
||||
required:
|
||||
- processed
|
||||
- inserted
|
||||
- duplicates
|
||||
- skipped
|
||||
- entries
|
||||
additionalProperties: false
|
||||
BulkMoodEntryResult:
|
||||
type: object
|
||||
properties:
|
||||
index:
|
||||
type: integer
|
||||
minimum: 0
|
||||
maximum: 9007199254740991
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- inserted
|
||||
- duplicate
|
||||
- skipped
|
||||
description: "`inserted`/`duplicate` — the row landed (advance the cursor). `skipped` — see `reason`."
|
||||
reason:
|
||||
type: string
|
||||
id:
|
||||
description: The upserted row id (absent on skips).
|
||||
type: string
|
||||
externalId:
|
||||
description: Echoed back when the entry carried one, for client mapping.
|
||||
type: string
|
||||
required:
|
||||
- index
|
||||
- status
|
||||
additionalProperties: false
|
||||
MoodTagTreeEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -28692,6 +29405,86 @@ components:
|
||||
additionalProperties: false
|
||||
description: "Per-nutrient window summary in catalog order: latest synced day + total, and the count of days carrying
|
||||
data inside the window. Nutrients without data in the window are omitted."
|
||||
HealthKitConfigEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/HealthKitConfigResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
HealthKitConfigResponse:
|
||||
type: object
|
||||
properties:
|
||||
entries:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/HealthKitEntry"
|
||||
lastSyncedAt:
|
||||
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: When HealthKit last synced for this user; null when never (and always null on the PATCH echo).
|
||||
required:
|
||||
- entries
|
||||
- lastSyncedAt
|
||||
additionalProperties: false
|
||||
description: "The resolved HealthKit integration config: the default metric set merged with the user's stored per-metric
|
||||
overrides."
|
||||
HealthKitEntry:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: Stable metric key (e.g. `bodyMass`).
|
||||
kind:
|
||||
type: string
|
||||
description: HealthKit sample kind (e.g. `bloodPressure`).
|
||||
direction:
|
||||
type: string
|
||||
enum:
|
||||
- bidirectional
|
||||
- readOnly
|
||||
- writeOnly
|
||||
- disabled
|
||||
description: Per-metric sync direction.
|
||||
enabled:
|
||||
type: boolean
|
||||
required:
|
||||
- id
|
||||
- kind
|
||||
- direction
|
||||
- enabled
|
||||
additionalProperties: false
|
||||
description: One resolved HealthKit metric mapping (defaults merged with the user's stored overrides).
|
||||
HealthKitConfigPatchEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/HealthKitConfigResponse"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
MedicationScheduleOutput:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.28.43",
|
||||
"version": "1.28.44",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "PolyForm-Noncommercial-1.0.0",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
+1
-1
@@ -36,7 +36,7 @@ try {
|
||||
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
|
||||
const CACHE_VERSION =
|
||||
(typeof self !== "undefined" && self.__APP_VERSION__) ||
|
||||
/* @sw-version-fallback */ "v1.28.43";
|
||||
/* @sw-version-fallback */ "v1.28.44";
|
||||
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
|
||||
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
|
||||
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
|
||||
|
||||
+13
-3
@@ -49,6 +49,13 @@ export default function LabsPage() {
|
||||
const [scanOpen, setScanOpen] = useState(false);
|
||||
const ocrCapability = useOcrCapability(isAuthenticated);
|
||||
|
||||
// Gated on the resolved `modules.labs` flag from `GET /api/auth/me` (the
|
||||
// per-user toggle AND the operator server-wide kill-switch). Default-on: an
|
||||
// absent key reads as enabled, so a direct URL hit only bounces on an
|
||||
// explicit `false`. Every `/api/labs/*` route also enforces the gate
|
||||
// server-side, so this is a UX redirect, not the security boundary.
|
||||
const enabled = user?.modules?.labs !== false;
|
||||
|
||||
const refreshVisible = useCallback(
|
||||
() => queryClient.invalidateQueries({ type: "active" }),
|
||||
[queryClient],
|
||||
@@ -59,12 +66,15 @@ export default function LabsPage() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
if (isLoading) return;
|
||||
if (!isAuthenticated) {
|
||||
router.push("/auth/login");
|
||||
} else if (!enabled) {
|
||||
router.push("/");
|
||||
}
|
||||
}, [isLoading, isAuthenticated, router]);
|
||||
}, [isLoading, isAuthenticated, enabled, router]);
|
||||
|
||||
if (!mounted || isLoading) {
|
||||
if (!mounted || isLoading || (isAuthenticated && !enabled)) {
|
||||
return (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="text-primary h-8 w-8 animate-spin motion-reduce:animate-none" />
|
||||
|
||||
@@ -153,6 +153,12 @@ function MedicationCardSkeleton() {
|
||||
export default function MedicationsPage() {
|
||||
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
// v1.18.1 (D3) — medications is an opt-out module. When the account has it
|
||||
// turned off the whole page disappears (the nav entry is hidden by the same
|
||||
// gate) and the list query never fires (the API would 403 anyway).
|
||||
// Default-on: an absent key reads as enabled, so the page only hides on an
|
||||
// explicit `false`.
|
||||
const medicationsEnabled = user?.modules?.medications !== false;
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -195,7 +201,7 @@ export default function MedicationsPage() {
|
||||
queryFn: async () => {
|
||||
return apiGet<Medication[]>("/api/medications");
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
enabled: isAuthenticated && medicationsEnabled,
|
||||
// v1.16.12 (#316) — refetch on every mount, even inside the global
|
||||
// staleTime window. App Router unmounts this page on navigation away
|
||||
// and remounts it on return; without this, a return within 5 min
|
||||
@@ -277,6 +283,13 @@ export default function MedicationsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Module off ⇒ the surface disappears entirely. The nav entry is hidden by
|
||||
// the same gate, so a direct hit renders nothing rather than an orphaned
|
||||
// shell.
|
||||
if (isAuthenticated && !medicationsEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
+14
-4
@@ -19,7 +19,7 @@ import { useEffect } from "react";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
export default function MoodPage() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const { user, isAuthenticated, isLoading } = useAuth();
|
||||
const mounted = useMounted();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -30,6 +30,13 @@ export default function MoodPage() {
|
||||
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
const { t } = useTranslations();
|
||||
|
||||
// Gated on the resolved `modules.mood` flag from `GET /api/auth/me` (the
|
||||
// per-user toggle AND the operator server-wide kill-switch). Default-on: an
|
||||
// absent key reads as enabled, so a direct URL hit only bounces on an
|
||||
// explicit `false`. Every `/api/mood-entries/*` route also enforces the gate
|
||||
// server-side, so this is a UX redirect, not the security boundary.
|
||||
const enabled = user?.modules?.mood !== false;
|
||||
|
||||
// v1.16.4 — PWA pull-to-refresh: a top-anchored touch pull refetches
|
||||
// whatever this page currently has mounted (`type: "active"` scopes the
|
||||
// invalidation to visible queries). Suspended while the add-sheet is
|
||||
@@ -44,17 +51,20 @@ export default function MoodPage() {
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
if (isLoading) return;
|
||||
if (!isAuthenticated) {
|
||||
router.push("/auth/login");
|
||||
} else if (!enabled) {
|
||||
router.push("/");
|
||||
}
|
||||
}, [isLoading, isAuthenticated, router]);
|
||||
}, [isLoading, isAuthenticated, enabled, router]);
|
||||
|
||||
// `!mounted` keeps the hydration render identical to the SSR HTML: the
|
||||
// auth query is fired by the early-hydrating shell and can settle before
|
||||
// this page boundary hydrates, so `isLoading` alone flipped the branch
|
||||
// and React logged hydration error #418 — same fix as the measurements
|
||||
// page; see `useMounted`.
|
||||
if (!mounted || isLoading) {
|
||||
if (!mounted || isLoading || (isAuthenticated && !enabled)) {
|
||||
return <PageAuthGate label={t("common.loading")} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* The sleep-overview empty-state CTA must point at a REAL settings slug.
|
||||
* It previously linked to `/settings/devices`, which is not in
|
||||
* `SETTINGS_SECTION_SLUGS`, so the dynamic `[section]` route (dynamicParams
|
||||
* = false) calls `notFound()` → a hard 404 dead-end. This pins the href to
|
||||
* the `integrations` slug (where every device OAuth callback lands) and
|
||||
* guards against a regression to any non-existent slug.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isSettingsSectionSlug } from "@/components/settings/section-slugs";
|
||||
|
||||
describe("sleep-overview empty-state CTA href", () => {
|
||||
it("links to /settings/integrations (a real settings slug)", () => {
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), "src/components/insights/sleep-overview.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
const match = source.match(/href="\/settings\/([a-z-]+)"/);
|
||||
expect(
|
||||
match,
|
||||
"expected a /settings/<slug> href in the sleep overview",
|
||||
).not.toBeNull();
|
||||
const slug = match![1];
|
||||
expect(slug).toBe("integrations");
|
||||
expect(isSettingsSectionSlug(slug)).toBe(true);
|
||||
});
|
||||
|
||||
it("never links to the non-existent /settings/devices slug", () => {
|
||||
const source = readFileSync(
|
||||
join(process.cwd(), "src/components/insights/sleep-overview.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
expect(source).not.toContain("/settings/devices");
|
||||
});
|
||||
});
|
||||
@@ -66,9 +66,9 @@ function SleepStageStackedBar(
|
||||
*
|
||||
* Empty state: no SLEEP_DURATION rows yet → render the "Apple Health
|
||||
* sync is coming in v1.5" message with a deep-link to Settings →
|
||||
* Devices so the user can prepare. We do NOT render any of the three
|
||||
* blocks in that case — half-rendered empty cards would be worse than
|
||||
* a single clean CTA.
|
||||
* Integrations so the user can connect a sleep source. We do NOT render
|
||||
* any of the three blocks in that case — half-rendered empty cards would
|
||||
* be worse than a single clean CTA.
|
||||
*/
|
||||
|
||||
interface SleepAnalyticsSummary {
|
||||
@@ -111,7 +111,7 @@ export function SleepOverview() {
|
||||
description={t("insights.sleep.empty.description")}
|
||||
action={
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link href="/settings/devices">
|
||||
<Link href="/settings/integrations">
|
||||
{t("insights.sleep.empty.action")}
|
||||
</Link>
|
||||
</Button>
|
||||
|
||||
@@ -241,7 +241,67 @@ const successFlagResponse = z
|
||||
.object({ success: z.boolean() })
|
||||
.meta({ id: "AuthSuccessFlagResponse" });
|
||||
|
||||
// ── iOS onboarding discovery (check-user) ────────────────────────────
|
||||
|
||||
const checkUserRequest = z
|
||||
.object({
|
||||
identifier: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(254)
|
||||
.describe(
|
||||
"The typed identifier — either an email or a username. Queried verbatim (no case-folding), never echoed back.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "CheckUserRequest",
|
||||
description:
|
||||
"Discovery lookup for the iOS onboarding flow: given a typed email or username, resolve the next sign-in step.",
|
||||
});
|
||||
|
||||
const checkUserResponse = z
|
||||
.object({
|
||||
branch: z
|
||||
.enum(["not_found", "passkey_only", "email_fallback", "exists"])
|
||||
.describe(
|
||||
"Next UX step: `not_found` (show sign-up), `passkey_only` (Sign in with Passkey), `email_fallback` (password field, with a Passkey affordance when applicable), `exists` (account with no usable credential — recovery path).",
|
||||
),
|
||||
hasPasskey: z
|
||||
.boolean()
|
||||
.describe("The account has at least one registered passkey."),
|
||||
hasPassword: z.boolean().describe("The account has a password hash."),
|
||||
})
|
||||
.meta({
|
||||
id: "CheckUserResponse",
|
||||
description:
|
||||
"Account-existence + credential shape. The response is identical whether or not the identifier matched (account-existence is the explicit contract iOS needs); the identifier is never echoed.",
|
||||
});
|
||||
|
||||
export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/auth/check-user": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
summary: "Resolve the next sign-in step for a typed identifier",
|
||||
description:
|
||||
"Given an email or username, returns which onboarding branch the iOS client should render plus the `hasPasskey` / `hasPassword` booleans so it can offer a Passkey affordance alongside a password field without a second round-trip. Anonymous surface; per-IP rate-limited (30 requests / 15 min). The response is the same whether or not the identifier matched, and the identifier is never echoed back.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: checkUserRequest } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Discovery result.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(checkUserResponse, "CheckUserEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/auth/login": {
|
||||
post: {
|
||||
tags: ["Auth"],
|
||||
|
||||
@@ -43,6 +43,7 @@ import { healthRecordPaths } from "./health-record";
|
||||
import { illnessPaths } from "./illness";
|
||||
import { importPaths } from "./import";
|
||||
import { insightsPaths } from "./insights";
|
||||
import { integrationPaths } from "./integrations";
|
||||
import { insightsSignalPaths } from "./insights-signals";
|
||||
import { labsPaths } from "./labs";
|
||||
import { ocrPaths } from "./ocr";
|
||||
@@ -97,6 +98,9 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...onboardingPaths,
|
||||
// v1.28 — nutrient-intake sync (appended; spread order is load-bearing).
|
||||
...nutrientPaths,
|
||||
// v1.28 — HealthKit integration config (appended; spread order is
|
||||
// load-bearing).
|
||||
...integrationPaths,
|
||||
};
|
||||
|
||||
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
|
||||
|
||||
@@ -4,8 +4,14 @@
|
||||
* Schema declarations live in `./schemas`; this module is the path orchestrator.
|
||||
*/
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { consentRequiredResponse, dataEnvelope, stdResponses } from "../shared";
|
||||
import {
|
||||
consentRequiredResponse,
|
||||
dataEnvelope,
|
||||
moduleDisabledResponse,
|
||||
stdResponses,
|
||||
} from "../shared";
|
||||
import {
|
||||
insightsCardsResponse,
|
||||
insightsComprehensiveResponse,
|
||||
metricStatusQuery,
|
||||
metricStatusResponse,
|
||||
@@ -73,6 +79,29 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/cards": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Insight cards (iOS adapter)",
|
||||
description:
|
||||
"v1.4.31 — the native-client adapter over the same alert rule engine the web comprehensive surface consumes: measurements, BP-in-target, weight trend, pulse, and cadence-aware medication compliance are fed through `generateAlerts()` and each resulting `HealthAlert` is re-shaped to the iOS Insight card model. Deterministic — no LLM call on this path. Module-gated on `insights` and the operator `insightStatus` assistant surface. Auth via cookie or Bearer.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The list of insight cards (possibly empty).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
insightsCardsResponse,
|
||||
"InsightsCardsEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...moduleDisabledResponse,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/pregenerate": {
|
||||
post: {
|
||||
tags: ["Insights"],
|
||||
|
||||
@@ -957,3 +957,49 @@ export const dashboardSnapshotResponse = z
|
||||
description:
|
||||
"Unified above-the-fold dashboard payload. `tiles` always arrives (slim summaries + mood + resolved widget layout); `extras` (BD-in-target + per-context glucose) is null on a rollup-coverage miss so the strip never waits on the slowest read. `briefing` is lifted read-only from the pre-generated insight cache — never generated synchronously — and reports `ready` / `preparing` / `disabled` / `no-provider` via `briefingState` (`no-provider` = stale-or-missing cache with no AI provider configured anywhere, so no warm pass will fill it; stop polling and surface a connect-provider hint). A stale-but-parseable briefing is still delivered with `briefingStale: true`. `layoutCatalogue` (full 27-id widget catalogue) and `metricStates` (latest reading per metric, keyed by iOS `MetricKind` raw value) are additive cold-launch seeds for the native client; both derive in-process from data already fetched, adding no DB round-trip.",
|
||||
});
|
||||
|
||||
// v1.4.31 — the iOS "cards" adapter over the same alert rule engine the
|
||||
// web comprehensive surface consumes. Each card is one `HealthAlert`
|
||||
// re-shaped to the iOS Insight model. Module-gated on `insights` and the
|
||||
// operator `insightStatus` assistant surface.
|
||||
const insightCard = z
|
||||
.object({
|
||||
id: z.string().describe("Stable per-card id (e.g. `alert-1`)."),
|
||||
title: z.string(),
|
||||
summary: z.string().describe("One-line alert message."),
|
||||
body: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe("Longer narrative; null on the current rule-engine cards."),
|
||||
severity: z
|
||||
.enum(["alert", "caution", "info", "good"])
|
||||
.describe(
|
||||
"Mapped from the underlying alert level (danger→alert, warning→caution, success→good, else info).",
|
||||
),
|
||||
recommendations: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
label: z.string(),
|
||||
actionURL: z.string().nullable(),
|
||||
}),
|
||||
)
|
||||
.describe(
|
||||
"Suggested follow-ups; empty on the current rule-engine cards.",
|
||||
),
|
||||
generatedAt: z.iso.datetime({ offset: true }),
|
||||
provider: z
|
||||
.string()
|
||||
.describe(
|
||||
"Lower-cased AI provider label for the account (e.g. `claude`).",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "InsightCard",
|
||||
description:
|
||||
"One iOS insight card, re-shaped from a server-side HealthAlert. Deterministic rule-engine output — no LLM call on this path.",
|
||||
});
|
||||
|
||||
export const insightsCardsResponse = z
|
||||
.array(insightCard)
|
||||
.meta({ id: "InsightsCardsResponse" });
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* OpenAPI route table — third-party integration config (HealthKit).
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`.
|
||||
* Response DTOs are declared here mirroring the route handler under
|
||||
* `src/app/api/integrations/healthkit/route.ts`; the request schema
|
||||
* mirrors the handler's `patchSchema`.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { dataEnvelope, stdResponses } from "./shared";
|
||||
|
||||
// Mirror the route's `directionEnum` — per-metric sync direction.
|
||||
const healthKitDirectionEnum = z
|
||||
.enum(["bidirectional", "readOnly", "writeOnly", "disabled"])
|
||||
.describe("Per-metric sync direction.");
|
||||
|
||||
// Resolved entry (defaults merged): `kind` + `enabled` are always present.
|
||||
const healthKitEntry = z
|
||||
.object({
|
||||
id: z.string().describe("Stable metric key (e.g. `bodyMass`)."),
|
||||
kind: z.string().describe("HealthKit sample kind (e.g. `bloodPressure`)."),
|
||||
direction: healthKitDirectionEnum,
|
||||
enabled: z.boolean(),
|
||||
})
|
||||
.meta({
|
||||
id: "HealthKitEntry",
|
||||
description:
|
||||
"One resolved HealthKit metric mapping (defaults merged with the user's stored overrides).",
|
||||
});
|
||||
|
||||
const healthKitConfigResponse = z
|
||||
.object({
|
||||
entries: z.array(healthKitEntry),
|
||||
lastSyncedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.nullable()
|
||||
.describe(
|
||||
"When HealthKit last synced for this user; null when never (and always null on the PATCH echo).",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "HealthKitConfigResponse",
|
||||
description:
|
||||
"The resolved HealthKit integration config: the default metric set merged with the user's stored per-metric overrides.",
|
||||
});
|
||||
|
||||
// Mirror the route's `patchSchema` — merge-by-id; unknown ids are ignored.
|
||||
const healthKitPatchEntry = z.object({
|
||||
id: z.string().min(1).max(64),
|
||||
kind: z.string().min(1).max(64).optional(),
|
||||
direction: healthKitDirectionEnum,
|
||||
enabled: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const healthKitPatchRequest = z
|
||||
.object({
|
||||
entries: z.array(healthKitPatchEntry).max(50),
|
||||
})
|
||||
.meta({
|
||||
id: "HealthKitConfigPatchRequest",
|
||||
description:
|
||||
"Merge-by-`id` update of the HealthKit metric config. Unknown ids are silently ignored; omitted fields fall back to the stored (or default) value for that entry. Up to 50 entries per call.",
|
||||
});
|
||||
|
||||
export const integrationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/integrations/healthkit": {
|
||||
get: {
|
||||
tags: ["Integrations"],
|
||||
summary: "Read the HealthKit integration config",
|
||||
description:
|
||||
"Returns the resolved per-metric HealthKit config — the default metric set merged with the user's stored overrides — plus the last HealthKit sync instant. Auth via cookie or Bearer.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Resolved HealthKit config.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
healthKitConfigResponse,
|
||||
"HealthKitConfigEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ["Integrations"],
|
||||
summary: "Update the HealthKit integration config",
|
||||
description:
|
||||
"Merges the supplied entries into the stored config by `id` (unknown ids are ignored) and returns the resolved config (defaults merged) so the client always sees a complete metric list. `lastSyncedAt` is null on the echo. Auth via cookie or Bearer.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: healthKitPatchRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Updated + resolved HealthKit config.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
healthKitConfigResponse,
|
||||
"HealthKitConfigPatchEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
intakeSchema,
|
||||
createInventoryItemSchema,
|
||||
updateInventoryItemSchema,
|
||||
injectionSiteEnum,
|
||||
} from "@/lib/validations/medication";
|
||||
import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt";
|
||||
import {
|
||||
@@ -49,7 +50,127 @@ import {
|
||||
medicationListLayoutSchema,
|
||||
} from "./schemas";
|
||||
|
||||
// ── Bulk intake backfill (iOS SyncMode) — mirrors the route's
|
||||
// `bulkPayloadSchema` / `bulkEntrySchema` and the batch-envelope response.
|
||||
const bulkIntakeEntry = z
|
||||
.object({
|
||||
medicationId: z.string().min(1),
|
||||
scheduledFor: z.iso
|
||||
.datetime({ offset: true })
|
||||
.optional()
|
||||
.describe("ISO instant; defaults to `takenAt` then now() when omitted."),
|
||||
takenAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.optional()
|
||||
.describe("ISO instant of the take; omit + `skipped:false` = pending."),
|
||||
skipped: z.boolean().optional().describe("Default false."),
|
||||
idempotencyKey: z.string().min(1).max(128).optional(),
|
||||
injectionSite: injectionSiteEnum
|
||||
.optional()
|
||||
.describe(
|
||||
"Per-entry injection site; a disallowed site marks THIS entry skipped without failing the batch.",
|
||||
),
|
||||
forceSlotInstant: z.iso
|
||||
.datetime({ offset: true })
|
||||
.optional()
|
||||
.describe(
|
||||
"Pin a taken entry onto a named real scheduled slot; ignored on non-taken entries.",
|
||||
),
|
||||
doseTaken: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(50)
|
||||
.optional()
|
||||
.describe("Per-entry dose override; persisted only on a taken entry."),
|
||||
source: z
|
||||
.literal("APPLE_HEALTH")
|
||||
.optional()
|
||||
.describe(
|
||||
"v1.28 — Apple Health dose-event import. Must be supplied together with `externalId`, and may only target a medication mirrored from Apple Health (else the whole batch 422s).",
|
||||
),
|
||||
externalId: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(128)
|
||||
.optional()
|
||||
.describe(
|
||||
"The HealthKit dose-event UUID. Drives the idempotent re-sync dedup (first-write-wins per Apple dose).",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "BulkMedicationIntakeEntry",
|
||||
description:
|
||||
"One bulk medication-intake entry. `source` + `externalId` are both-or-neither.",
|
||||
});
|
||||
|
||||
const bulkIntakePayload = z
|
||||
.object({
|
||||
entries: z.array(bulkIntakeEntry).min(1).max(500),
|
||||
})
|
||||
.meta({
|
||||
id: "BulkMedicationIntakeRequest",
|
||||
description:
|
||||
"iOS SyncMode bulk intake backfill. 1–500 entries per call; idempotent via `Idempotency-Key` and per-entry `idempotencyKey` / `externalId`.",
|
||||
});
|
||||
|
||||
const bulkIntakeEntryResult = z
|
||||
.object({
|
||||
index: z.number().int().nonnegative(),
|
||||
status: z
|
||||
.enum(["inserted", "updated", "duplicate", "skipped"])
|
||||
.describe(
|
||||
"`inserted`/`updated`/`duplicate` — the row landed (advance the cursor). `skipped` — not stored; see `reason`.",
|
||||
),
|
||||
reason: z.string().optional(),
|
||||
id: z.string().optional().describe("The landed row id (absent on skips)."),
|
||||
})
|
||||
.meta({ id: "BulkMedicationIntakeEntryResult" });
|
||||
|
||||
const bulkIntakeResponse = z
|
||||
.object({
|
||||
processed: z.number().int().nonnegative(),
|
||||
inserted: z.number().int().nonnegative(),
|
||||
updated: z.number().int().nonnegative(),
|
||||
duplicates: z.number().int().nonnegative(),
|
||||
skipped: z.array(
|
||||
z.object({
|
||||
index: z.number().int().nonnegative(),
|
||||
reason: z.string(),
|
||||
}),
|
||||
),
|
||||
entries: z.array(bulkIntakeEntryResult),
|
||||
})
|
||||
.meta({ id: "BulkMedicationIntakeResponse" });
|
||||
|
||||
export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/medications/intake/bulk": {
|
||||
post: {
|
||||
tags: ["Medications"],
|
||||
summary: "Bulk medication-intake backfill (iOS SyncMode)",
|
||||
description:
|
||||
"Up to 500 intake entries per call, mirroring the mood-entries bulk envelope so the iOS sync engine reuses one retry/cursor path. Idempotent via the `Idempotency-Key` header plus per-entry `idempotencyKey` / `externalId`. Per-entry status (`inserted` / `updated` / `duplicate` / `skipped`) lets the client advance its cursor. Rate-limited 60/min/user. An `APPLE_HEALTH` entry must carry `externalId` and target a medication mirrored from Apple Health, else the whole batch 422s (`meta.errorCode = medications.intake.bulk.apple_health_not_mirrored`); an over-size batch 422s (`medications.intake.bulk.too_large`); a malformed body 422s (`medications.intake.bulk.invalid`).",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: bulkIntakePayload } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Batch processed (always 200 on a well-formed body).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
bulkIntakeResponse,
|
||||
"BulkMedicationIntakeResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications": {
|
||||
get: {
|
||||
tags: ["Medications"],
|
||||
|
||||
@@ -116,7 +116,59 @@ const capabilitiesResponse = z
|
||||
"Live id vocabularies + contract version. Every list is derived server-side from the canonical registry it documents, so it cannot drift from the values the routes actually accept/emit.",
|
||||
});
|
||||
|
||||
// v1.4.41 — the measurement categorisation overlay iOS reads on cold start
|
||||
// to drive the HealthKit permission picker (one consent screen per
|
||||
// category). Every value is sourced server-side from the canonical
|
||||
// `MEASUREMENT_CATEGORIES` map, so the wire shape here is the contract.
|
||||
const measurementCategoriesResponse = z
|
||||
.object({
|
||||
version: z
|
||||
.literal(1)
|
||||
.describe("Additive schema marker; assignments never reshuffle."),
|
||||
categories: z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.string().describe("Category id (e.g. `vitals`)."),
|
||||
labelKey: z
|
||||
.string()
|
||||
.describe("i18n key for the category label (`categories.<id>`)."),
|
||||
order: z.number().int().describe("Stable display order."),
|
||||
}),
|
||||
)
|
||||
.describe("The ordered category list for the picker."),
|
||||
assignments: z
|
||||
.record(z.string(), z.string())
|
||||
.describe("MeasurementType → category id map."),
|
||||
})
|
||||
.meta({
|
||||
id: "MeasurementCategoriesResponse",
|
||||
description:
|
||||
"The measurement categorisation overlay: the ordered category list plus the MeasurementType → category assignments. Derived server-side from the canonical map; cached `public, max-age=600`.",
|
||||
});
|
||||
|
||||
export const metaPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/measurement-categories": {
|
||||
get: {
|
||||
tags: ["Meta"],
|
||||
summary: "Measurement categorisation overlay",
|
||||
description:
|
||||
"Returns the UI-side measurement categorisation as an HTTP contract: the ordered category list (id + i18n label key + order) plus the MeasurementType → category assignments. iOS reads this on cold start to drive the HealthKit permission picker (one consent screen per category) and caches it client-side (`Cache-Control: public, max-age=600`). Auth via cookie or Bearer (not admin); no PII.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Categorisation overlay.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
measurementCategoriesResponse,
|
||||
"MeasurementCategoriesEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/meta/capabilities": {
|
||||
get: {
|
||||
tags: ["Meta"],
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
hideCatalogueTagSchema,
|
||||
} from "@/lib/mood/custom-tags";
|
||||
import { moodTagLayoutSchema } from "@/lib/mood/tag-layout";
|
||||
import { moodLevelEnum, moodSourceEnum } from "@/lib/validations/moodlog";
|
||||
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
|
||||
|
||||
// ── Request schemas — annotated for spec emission ────────────────────
|
||||
@@ -167,7 +168,120 @@ const notFoundResponse = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
// ── Bulk mood backfill (iOS SyncMode) — mirrors the route's
|
||||
// `bulkPayloadSchema` / `bulkEntrySchema` and the batch-envelope response.
|
||||
const bulkMoodEntry = z
|
||||
.object({
|
||||
mood: moodLevelEnum,
|
||||
tags: z.array(z.string().max(50)).max(20).optional(),
|
||||
tagKeys: z
|
||||
.array(z.string().max(60))
|
||||
.max(30)
|
||||
.optional()
|
||||
.describe("Structured-tag keys from the catalog; unknown keys dropped."),
|
||||
ratedFactors: z
|
||||
.array(
|
||||
z.object({
|
||||
key: z.string().max(60),
|
||||
rating: z.number().int().min(1).max(5),
|
||||
}),
|
||||
)
|
||||
.max(30)
|
||||
.optional()
|
||||
.describe(
|
||||
"Rated mood factors; an out-of-scale rating marks THIS entry skipped, never the batch.",
|
||||
),
|
||||
note: z.string().max(500).optional(),
|
||||
moodLoggedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.describe("ISO instant the entry was logged."),
|
||||
source: moodSourceEnum.optional().describe("Defaults to MANUAL."),
|
||||
externalId: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(120)
|
||||
.optional()
|
||||
.describe(
|
||||
"iOS-side source-stable id for idempotent dedup on the `(userId, source, externalId)` key.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "BulkMoodEntry",
|
||||
description: "One bulk mood entry (UPSERT keyed by `externalId`).",
|
||||
});
|
||||
|
||||
const bulkMoodPayload = z
|
||||
.object({
|
||||
entries: z.array(bulkMoodEntry).min(1).max(500),
|
||||
})
|
||||
.meta({
|
||||
id: "BulkMoodRequest",
|
||||
description:
|
||||
"iOS SyncMode bulk mood backfill. 1–500 entries per call; per-entry UPSERT keyed by `externalId` so re-runs are idempotent.",
|
||||
});
|
||||
|
||||
const bulkMoodEntryResult = z
|
||||
.object({
|
||||
index: z.number().int().nonnegative(),
|
||||
status: z
|
||||
.enum(["inserted", "duplicate", "skipped"])
|
||||
.describe(
|
||||
"`inserted`/`duplicate` — the row landed (advance the cursor). `skipped` — see `reason`.",
|
||||
),
|
||||
reason: z.string().optional(),
|
||||
id: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("The upserted row id (absent on skips)."),
|
||||
externalId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Echoed back when the entry carried one, for client mapping."),
|
||||
})
|
||||
.meta({ id: "BulkMoodEntryResult" });
|
||||
|
||||
const bulkMoodResponse = z
|
||||
.object({
|
||||
processed: z.number().int().nonnegative(),
|
||||
inserted: z.number().int().nonnegative(),
|
||||
duplicates: z.number().int().nonnegative(),
|
||||
skipped: z.array(
|
||||
z.object({
|
||||
index: z.number().int().nonnegative(),
|
||||
reason: z.string(),
|
||||
}),
|
||||
),
|
||||
entries: z.array(bulkMoodEntryResult),
|
||||
})
|
||||
.meta({ id: "BulkMoodResponse" });
|
||||
|
||||
export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/mood-entries/bulk": {
|
||||
post: {
|
||||
tags: ["Mood"],
|
||||
summary: "Bulk mood backfill (iOS SyncMode)",
|
||||
description:
|
||||
"Drains the iOS local mood log in one shot: up to 500 entries per call with per-entry UPSERT semantics keyed by `externalId`, so an adopt-on-pair backfill or a retried batch is idempotent. Idempotent via the `Idempotency-Key` header too. Per-entry status (`inserted` / `duplicate` / `skipped`) advances the client cursor. Rate-limited 60/min/user. An over-size batch 422s (`meta.errorCode = mood.bulk.too_large`); a malformed body 422s (`mood.bulk.invalid`).",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: bulkMoodPayload } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Batch processed (always 200 on a well-formed body).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
bulkMoodResponse,
|
||||
"BulkMoodResponseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/mood/tags": {
|
||||
get: {
|
||||
tags: ["Mood"],
|
||||
|
||||
Reference in new issue
Block a user