From cd0fbddd1dc37a3ef1d5d8ad5d65551325fca6f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 16 Jul 2026 04:58:44 +0200 Subject: [PATCH] =?UTF-8?q?chore(release):=20v1.28.44=20=E2=80=94=20loose?= =?UTF-8?q?=20ends:=20broken=20link,=20API=20contract,=20module=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From the campaign's dead-code + code-health audits (grep-verified; the custom-metrics query-key finding was verified-false and excluded): - sleep-overview empty-state CTA linked to /settings/devices, not a valid settings slug (dynamicParams=false → 404). Repointed to /settings/integrations (where every device OAuth callback already lands). - six live, iOS-consumed routes were absent from docs/api/openapi.yaml — auth/check-user, insights/cards, integrations/healthkit (GET+PATCH), measurement-categories, medications/intake/bulk, mood-entries/bulk — registered in the Zod OpenAPI registry mirroring each handler's real shape. - mood/labs/medications module pages lacked the redirect-when-off self-guard their siblings have; added, mirroring cycle/achievements exactly. No migration. Verifier REJECTED the custom-metrics bare-key finding (they are invalidateQueries prefix filters, not colliding useQuery keys). --- CHANGELOG.md | 11 + docs/api/openapi.yaml | 795 +++++++++++++++++- package.json | 2 +- public/sw.js | 2 +- src/app/labs/page.tsx | 16 +- src/app/medications/page.tsx | 15 +- src/app/mood/page.tsx | 18 +- .../sleep-overview-empty-href.test.ts | 39 + src/components/insights/sleep-overview.tsx | 8 +- src/lib/openapi/routes/auth.ts | 60 ++ src/lib/openapi/routes/index.ts | 4 + src/lib/openapi/routes/insights/paths.ts | 31 +- src/lib/openapi/routes/insights/schemas.ts | 46 + src/lib/openapi/routes/integrations.ts | 115 +++ src/lib/openapi/routes/medications/paths.ts | 121 +++ src/lib/openapi/routes/meta.ts | 52 ++ src/lib/openapi/routes/mood.ts | 114 +++ 17 files changed, 1433 insertions(+), 16 deletions(-) create mode 100644 src/components/insights/__tests__/sleep-overview-empty-href.test.ts create mode 100644 src/lib/openapi/routes/integrations.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 34cc6ad81..bf579d64c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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: diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 01dce3035..69e11adc9 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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.`). + 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: diff --git a/package.json b/package.json index 8b1614fdc..c2d767791 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/sw.js b/public/sw.js index b76eb843d..cc5416e87 100644 --- a/public/sw.js +++ b/public/sw.js @@ -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/*` diff --git a/src/app/labs/page.tsx b/src/app/labs/page.tsx index f79dd9363..78545a18d 100644 --- a/src/app/labs/page.tsx +++ b/src/app/labs/page.tsx @@ -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 (
diff --git a/src/app/medications/page.tsx b/src/app/medications/page.tsx index bc8a5b0e2..6838d8f7a 100644 --- a/src/app/medications/page.tsx +++ b/src/app/medications/page.tsx @@ -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("/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 (
diff --git a/src/app/mood/page.tsx b/src/app/mood/page.tsx index 54b0d893f..2d8780b15 100644 --- a/src/app/mood/page.tsx +++ b/src/app/mood/page.tsx @@ -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(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 ; } diff --git a/src/components/insights/__tests__/sleep-overview-empty-href.test.ts b/src/components/insights/__tests__/sleep-overview-empty-href.test.ts new file mode 100644 index 000000000..6a336abaa --- /dev/null +++ b/src/components/insights/__tests__/sleep-overview-empty-href.test.ts @@ -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/ 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"); + }); +}); diff --git a/src/components/insights/sleep-overview.tsx b/src/components/insights/sleep-overview.tsx index 2bbd01c75..58117e271 100644 --- a/src/components/insights/sleep-overview.tsx +++ b/src/components/insights/sleep-overview.tsx @@ -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={ diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 5d1743c50..3b21da953 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -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 = { + "/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"], diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 91acb62b3..fee926293 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -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 = { ...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 = { diff --git a/src/lib/openapi/routes/insights/paths.ts b/src/lib/openapi/routes/insights/paths.ts index 3cda196b1..b25d3e044 100644 --- a/src/lib/openapi/routes/insights/paths.ts +++ b/src/lib/openapi/routes/insights/paths.ts @@ -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 = { }, }, }, + "/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"], diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index 4eb23fe14..8451451ad 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -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" }); diff --git a/src/lib/openapi/routes/integrations.ts b/src/lib/openapi/routes/integrations.ts new file mode 100644 index 000000000..df9273617 --- /dev/null +++ b/src/lib/openapi/routes/integrations.ts @@ -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 = { + "/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, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index dd0e226b1..ea92a0eed 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -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 = { + "/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"], diff --git a/src/lib/openapi/routes/meta.ts b/src/lib/openapi/routes/meta.ts index 5f515b019..3fd9ef2fe 100644 --- a/src/lib/openapi/routes/meta.ts +++ b/src/lib/openapi/routes/meta.ts @@ -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.`)."), + 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 = { + "/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"], diff --git a/src/lib/openapi/routes/mood.ts b/src/lib/openapi/routes/mood.ts index 1a5832221..df36341bb 100644 --- a/src/lib/openapi/routes/mood.ts +++ b/src/lib/openapi/routes/mood.ts @@ -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 = { + "/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"],