From 2e2e596ccab3627296f058d8d22f2390736c4719 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 01:06:53 +0200 Subject: [PATCH 1/6] refactor(openapi): split medications route registry into schemas + paths behind a barrel --- src/lib/openapi/routes/medications/index.ts | 7 + src/lib/openapi/routes/medications/paths.ts | 676 ++++++++++++++++ src/lib/openapi/routes/medications/schemas.ts | 725 ++++++++++++++++++ 3 files changed, 1408 insertions(+) create mode 100644 src/lib/openapi/routes/medications/index.ts create mode 100644 src/lib/openapi/routes/medications/paths.ts create mode 100644 src/lib/openapi/routes/medications/schemas.ts diff --git a/src/lib/openapi/routes/medications/index.ts b/src/lib/openapi/routes/medications/index.ts new file mode 100644 index 000000000..5149e6dd5 --- /dev/null +++ b/src/lib/openapi/routes/medications/index.ts @@ -0,0 +1,7 @@ +/** + * OpenAPI route table — medications. Barrel preserving the original module path. + * + * Part of the OpenAPI route table; aggregated in `../index.ts`. + */ +export { medicationResource } from "./schemas"; +export { medicationPaths } from "./paths"; diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts new file mode 100644 index 000000000..3432882ef --- /dev/null +++ b/src/lib/openapi/routes/medications/paths.ts @@ -0,0 +1,676 @@ +/** + * OpenAPI path table — medications CRUD, intake, cadence, compliance, AI extraction. + * + * Schema declarations live in `./schemas`; this module is the path orchestrator. + */ +import { z } from "zod/v4"; +import type { ZodOpenApiObject } from "zod-openapi"; +import { + createMedicationSchema, + updateMedicationSchema, + intakeSchema, + createInventoryItemSchema, + updateInventoryItemSchema, +} from "@/lib/validations/medication"; +import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt"; +import { + scheduleRevisionCreateSchema, + scheduleRevisionUpdateSchema, +} from "@/lib/validations/schedule-revision"; +import { dataEnvelope, errorEnvelope, stdResponses } from "../shared"; +import { + medicationListEntry, + medicationDetailEntry, + medicationInventoryItemResource, + medicationIntakeEventResource, + medicationCadenceResponse, + medicationComplianceResponse, + medicationComplianceSummaryEntry, + scheduleRevisionResource, + scheduleRevisionListResponse, + medicationExtractRequest, + medicationListLayoutSchema, +} from "./schemas"; + +export const medicationPaths: NonNullable = { + "/api/medications": { + get: { + tags: ["Medications"], + summary: "List medications for the calling user", + description: + "Returns every medication owned by the caller (active + paused), ordered by `createdAt DESC`. Each row carries its nested `schedules`, the joined clinical `category`, the latest non-skipped `lastTakenAt`, and the count of today's actioned intake events (`todayEventCount`). The response is cached server-side for 60 s per user; writes flush the cache.", + responses: { + "200": { + description: "Medication list.", + content: { + "application/json": { + schema: dataEnvelope( + z.array(medicationListEntry), + "ListMedicationsResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + post: { + tags: ["Medications"], + summary: "Create a medication with at least one schedule", + description: + "Validates the body against `CreateMedicationRequest`, applies the v1.5 cross-field invariants (one-shot consistency, recurring default `FREQ=DAILY`, `timesOfDay` dual-write), and creates the medication + its schedules in a single Prisma write. Audits as `medication.create`.", + requestBody: { + required: true, + content: { "application/json": { schema: createMedicationSchema } }, + }, + responses: { + "201": { + description: "Created medication with its schedules.", + content: { + "application/json": { + schema: dataEnvelope( + medicationDetailEntry, + "CreateMedicationResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/layout": { + get: { + tags: ["Medications"], + summary: "Read the calling user's medications list presentation", + description: + "Returns the per-user /medications presentation (card/table view + manual order). Falls back to the defaults (cards, empty order) when the user has not customised it. Mirrors the insights-layout contract.", + responses: { + "200": { + description: "The resolved presentation (custom or default).", + content: { + "application/json": { + schema: dataEnvelope( + medicationListLayoutSchema, + "MedicationListLayoutResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Medications"], + summary: "Update the calling user's medications list presentation", + description: + "Field-scoped update: `view` and `order` are each optional, and whichever the body omits is preserved from the stored blob — a view toggle can never wipe the manual order and vice versa. The normalised presentation is returned. Invalid bodies return the multi-issue 422 envelope.", + requestBody: { + required: true, + content: { + "application/json": { schema: medicationListLayoutSchema }, + }, + }, + responses: { + "200": { + description: + "Presentation saved; the normalised blob is echoed back.", + content: { + "application/json": { + schema: dataEnvelope( + medicationListLayoutSchema, + "MedicationListLayoutSaved", + ), + }, + }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Medications"], + summary: "Reset the calling user's medications list presentation", + description: + "Clears the persisted presentation and returns the defaults (cards, empty order). Idempotent.", + responses: { + "200": { + description: "Presentation reset; the defaults are returned.", + content: { + "application/json": { + schema: dataEnvelope( + medicationListLayoutSchema, + "MedicationListLayoutReset", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/{id}": { + get: { + tags: ["Medications"], + summary: "Fetch a single medication", + description: + "Returns the medication + its schedules + the joined `category`. Cross-user rows surface as 404 (existence channel sealed).", + requestParams: { + path: z.object({ id: z.string() }), + }, + responses: { + "200": { + description: "Medication detail.", + content: { + "application/json": { + schema: dataEnvelope( + medicationDetailEntry, + "GetMedicationResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Medications"], + summary: "Replace a medication (partial fields)", + description: + "Every field on the body is optional; omitted fields are left untouched. Supplying `schedules` REPLACES the medication's full schedule list (the route deletes existing rows before re-creating). Flipping `active` to false stamps `pausedAt`; flipping back to true clears it. v1.5 invariants on the `schedules` array match `POST /api/medications`. Audits as `medication.update`.", + requestParams: { + path: z.object({ id: z.string() }), + }, + requestBody: { + required: true, + content: { "application/json": { schema: updateMedicationSchema } }, + }, + responses: { + "200": { + description: "Updated medication.", + content: { + "application/json": { + schema: dataEnvelope( + medicationDetailEntry, + "UpdateMedicationResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Medications"], + summary: "Delete a medication", + description: + "Cascades to the medication's schedules, intake events, dose changes, inventory rows, and side-effect logs. Revokes every API token scoped to `medication::ingest`. Audits as `medication.delete`.", + requestParams: { + path: z.object({ id: z.string() }), + }, + responses: { + "200": { + description: "Deletion succeeded.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ deleted: z.boolean() }), + "DeleteMedicationResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/{id}/intake": { + post: { + tags: ["Medications"], + summary: "Log an intake event for a medication", + description: + "Records a taken or skipped dose. Idempotent via the `Idempotency-Key` header AND the optional `idempotencyKey` body field (the route walks both paths); a re-post inside the 60 s server-side dedup window returns the original event. Non-skipped intakes auto-decrement pen inventory (best-effort), refresh the per-day compliance rollup, and — for `oneShot:true` medications — flip `active` to false.", + requestParams: { + path: z.object({ id: z.string() }), + }, + requestBody: { + required: true, + content: { "application/json": { schema: intakeSchema } }, + }, + responses: { + "201": { + description: "Intake event created.", + content: { + "application/json": { + schema: dataEnvelope( + medicationIntakeEventResource, + "CreateMedicationIntakeResponse", + ), + }, + }, + }, + "200": { + description: + "Idempotent replay — the original event is returned without creating a new row.", + content: { + "application/json": { + schema: dataEnvelope( + medicationIntakeEventResource, + "ReplayMedicationIntakeResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/{id}/inventory": { + get: { + tags: ["Medications"], + summary: "List a medication's supply containers", + description: + "Returns every inventory item (all states) for the medication, ordered by state, then `expiresAt`, then `createdAt`. Items count UNITS; divide by the medication's `unitsPerDose` for dose-level figures.", + requestParams: { + path: z.object({ id: z.string() }), + }, + responses: { + "200": { + description: "Inventory item list.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + items: z.array(medicationInventoryItemResource), + meta: z.object({ total: z.number().int().nonnegative() }), + }), + "ListMedicationInventoryResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + post: { + tags: ["Medications"], + summary: "Register a new supply container", + description: + "Creates an ACTIVE inventory item with `unitsRemaining = unitsTotal`. The request's `unitsTotal` field carries UNITS (1–1000). Rate-limited 30/min/user. Audits as `medication.inventory.create`.", + requestParams: { + path: z.object({ id: z.string() }), + }, + requestBody: { + required: true, + content: { + "application/json": { schema: createInventoryItemSchema }, + }, + }, + responses: { + "201": { + description: "Created inventory item.", + content: { + "application/json": { + schema: dataEnvelope( + medicationInventoryItemResource, + "CreateMedicationInventoryItemResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/{id}/inventory/{itemId}": { + patch: { + tags: ["Medications"], + summary: "Mutate a supply container", + description: + "Per-item operations: manual first-use (`markAsFirstUseAt`), used-up override (`markAsUsedUp`), printed-expiry correction, absolute remaining-unit correction (`unitsRemaining`, clamped to the item's capacity), notes. The canonical state machine re-derives the state after every mutation. Audits as `medication.inventory.update`.", + requestParams: { + path: z.object({ id: z.string(), itemId: z.string() }), + }, + requestBody: { + required: true, + content: { + "application/json": { schema: updateInventoryItemSchema }, + }, + }, + responses: { + "200": { + description: "Updated inventory item.", + content: { + "application/json": { + schema: dataEnvelope( + medicationInventoryItemResource, + "UpdateMedicationInventoryItemResponse", + ), + }, + }, + }, + "404": { + description: + "Inventory item not found (or owned by another user / medication).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Medications"], + summary: "Delete a supply container", + description: + "Hard-deletes the inventory item. The audit log captures the before-state (`medication.inventory.delete`) so a row can be reconstructed if needed. Consumption stamps on intake events that reference the item stay in place; a later restore skips the missing container.", + requestParams: { + path: z.object({ id: z.string(), itemId: z.string() }), + }, + responses: { + "200": { + description: "Deletion succeeded.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ id: z.string(), deleted: z.boolean() }), + "DeleteMedicationInventoryItemResponse", + ), + }, + }, + }, + "404": { + description: + "Inventory item not found (or owned by another user / medication).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/extract": { + post: { + tags: ["Medications"], + summary: + "Extract scheduling fields from a free-text medication description", + description: + "Runs the user's free-text description through the Coach provider chain and returns a citation-guarded partial payload the wizard merges onto whatever the user already typed. `name` and `dose` are dropped when not substring-matched in the original text so the wizard cannot land a hallucinated brand or dose. `cadenceKind` / `doseUnit` / `weekdays` are closed enums; numeric fields are clamped. Rate-limited 10 requests / 5 minutes / user, gated against the daily Coach token budget.", + requestBody: { + required: true, + content: { + "application/json": { schema: medicationExtractRequest }, + }, + }, + responses: { + "200": { + description: "Citation-guarded partial extraction.", + content: { + "application/json": { + schema: dataEnvelope( + medicationExtractionSchema, + "MedicationExtractResponse", + ), + }, + }, + }, + "502": { + description: + "Upstream provider returned an empty, unparseable, or off-schema reply.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "503": { + description: + "No AI provider configured for the calling user (or operator).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/{id}/cadence": { + get: { + tags: ["Medications"], + summary: "Cadence + compliance read for a medication", + description: + "Returns the expected-vs-actual dose timeline for the requested window plus the four compliance chip values that drive the detail-page section. Pure computation — no writes. Day boundaries are resolved in the user's IANA timezone so a Tokyo user and a Berlin user see the same chips for the same medication. The `days` query parameter caps at 180.", + requestParams: { + path: z.object({ id: z.string() }), + query: z.object({ + days: z.coerce + .number() + .int() + .min(1) + .max(180) + .optional() + .describe("Window size in days (default 30, max 180)."), + }), + }, + responses: { + "200": { + description: "Cadence response.", + content: { + "application/json": { + schema: dataEnvelope( + medicationCadenceResponse, + "GetMedicationCadenceResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/compliance": { + get: { + tags: ["Medications"], + summary: "Batched adherence read for every medication of the caller", + description: + "Returns one compact adherence row per medication the caller owns (active + paused), ordered by `createdAt DESC` — the single round trip the medication cards consume instead of fanning out one `/api/medications/{id}/compliance` request per card. Each row carries the 7-/30-day summaries and the cadence-scaled display block; the per-day grid stays on the per-medication endpoint. Pure computation — no writes. Served through the same per-medication server cache as the per-id read, so the two endpoints warm each other.", + responses: { + "200": { + description: "One adherence row per medication.", + content: { + "application/json": { + schema: dataEnvelope( + z.array(medicationComplianceSummaryEntry), + "ListMedicationComplianceResponse", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/{id}/compliance": { + get: { + tags: ["Medications"], + summary: "Adherence read for a medication", + description: + "Returns the 7- and 30-day adherence summaries, the per-day compliance grid for the history glyph track, and the two-row display block. Pure computation — no writes. Day boundaries are resolved in the user's IANA timezone, and the expected-dose denominator is cadence-aware (RRULE / rolling / one-shot / PRN / cyclic) and clamped to the medication's `createdAt`. Read `compliance30` for the headline 30-day taken-vs-expected percentage; build the per-day glyph track from `dailyCompliance` (draw a cell only where `due === true`).", + requestParams: { + path: z.object({ id: z.string() }), + }, + responses: { + "200": { + description: "Compliance response.", + content: { + "application/json": { + schema: dataEnvelope( + medicationComplianceResponse, + "GetMedicationComplianceResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/{id}/schedule-revisions": { + get: { + tags: ["Medications"], + summary: "List a medication's archived schedule eras", + description: + "Returns every archived schedule era (newest first) plus `currentSince`, the instant the live plan took over. The dose-history ledger and compliance tallies already mint past days against these eras; this read powers the Zeitplan-tab history timeline.", + requestParams: { + path: z.object({ id: z.string() }), + }, + responses: { + "200": { + description: "Era list.", + content: { + "application/json": { + schema: dataEnvelope( + scheduleRevisionListResponse, + "ListScheduleRevisionsResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + post: { + tags: ["Medications"], + summary: "Append a manual schedule era (pre-tracking history)", + description: + "Records that the medication dosed at the given daily times during `[validFrom, validUntil)` — history from before the schedule was edited in the app. The era must end at or before the start of the live plan and must not overlap an existing era; violations return 422. The snapshot is shaped exactly like a write-path archive (`FREQ=DAILY`, window pulled to the min/max of the times), so every historical surface reads it transparently. Audits as `medication.schedule_revision.created`.", + requestParams: { + path: z.object({ id: z.string() }), + }, + requestBody: { + required: true, + content: { + "application/json": { schema: scheduleRevisionCreateSchema }, + }, + }, + responses: { + "201": { + description: "Manual era created.", + content: { + "application/json": { + schema: dataEnvelope( + scheduleRevisionResource, + "CreateScheduleRevisionResponse", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/{id}/schedule-revisions/{revisionId}": { + patch: { + tags: ["Medications"], + summary: "Correct a recorded schedule era", + description: + "Replaces an era's bounds and daily times. A `MANUAL` era updates in place; an `ARCHIVED` era stays as the immutable audit record and the correction is minted as a superseding `MANUAL` revision that takes its place in every historical surface (the response carries the correction's id). Validation mirrors the sibling POST: the era must end at or before the start of the live plan and must not overlap another active era; violations return 422. An era that has already been corrected refuses with 409. Audits as `medication.schedule_revision.updated`.", + requestParams: { + path: z.object({ id: z.string(), revisionId: z.string() }), + }, + requestBody: { + required: true, + content: { + "application/json": { schema: scheduleRevisionUpdateSchema }, + }, + }, + responses: { + "200": { + description: "Era corrected.", + content: { + "application/json": { + schema: dataEnvelope( + scheduleRevisionResource, + "UpdateScheduleRevisionResponse", + ), + }, + }, + }, + "404": { + description: + "Medication or revision not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "409": { + description: + "The revision has already been superseded by a correction.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Medications"], + summary: "Delete a manually added schedule era", + description: + "Removes a `MANUAL` era — one appended through the sibling POST, or a correction minted by PATCH (deleting a correction restores the archived original it superseded). Write-path archives (`source: ARCHIVED`) are immutable history and refuse with 409. Audits as `medication.schedule_revision.deleted`.", + requestParams: { + path: z.object({ id: z.string(), revisionId: z.string() }), + }, + responses: { + "200": { + description: "Deletion succeeded.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ deleted: z.boolean() }), + "DeleteScheduleRevisionResponse", + ), + }, + }, + }, + "404": { + description: + "Medication or revision not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "409": { + description: + "The revision is a write-path archive (`ARCHIVED`) and cannot be deleted.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts new file mode 100644 index 000000000..b726f95fd --- /dev/null +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -0,0 +1,725 @@ +/** + * OpenAPI route table — medications CRUD, intake, cadence, compliance, AI extraction. + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. + * Schemas come from `src/lib/validations/*` where shared with the + * runtime request parsing, so the wire contract stays single-source. + */ +import { z } from "zod/v4"; +import { + MEDICATION_CATEGORY_VALUES, + MEDICATION_CONTAINER_TYPE_VALUES, + MEDICATION_TREATMENT_CLASS_VALUES, +} from "@/lib/validations/medication"; +import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt"; +import { + MEDICATION_LIST_VIEWS, + MEDICATION_ORDER_ID_MAX_LENGTH, + MEDICATION_ORDER_MAX_ENTRIES, +} from "@/lib/medication-list-layout"; + +// ── Medications (v1.5 scheduling) ──────────────────────────────────── +// +// The Medication + MedicationSchedule resource shapes documented below +// follow the wire envelope the seven routes registered at the bottom of +// this file emit. `windowStart` / `windowEnd` / `daysOfWeek` / +// `intervalWeeks` are the legacy primitives kept for backwards +// compatibility through the v1.5.x line; `timesOfDay`, `rrule`, +// `rollingIntervalDays`, and `reminderGraceMinutes` are the v1.5 +// first-class primitives the wizard + iOS cadence picker write. The +// XOR between `rrule` and `rollingIntervalDays` is documented at the +// schema description AND enforced by the route + a DB CHECK constraint +// so iOS code-gen surfaces the mutual exclusion. + +export const medicationCategoryEnum = z.enum(MEDICATION_CATEGORY_VALUES).meta({ + id: "MedicationCategory", + description: + "Clinical taxonomy stored in the `medication_categories` side-table. Orthogonal to `MedicationTreatmentClass`.", +}); + +export const medicationTreatmentClassEnum = z + .enum(MEDICATION_TREATMENT_CLASS_VALUES) + .meta({ + id: "MedicationTreatmentClass", + description: + "Prisma-level treatment-class discriminator. `GLP1` unlocks the GLP-1 specialist surfaces (injection-site rotation, titration history, pen inventory, GLP-1-aware Coach).", + }); + +export const medicationScheduleResource = z + .object({ + id: z.string(), + medicationId: z.string(), + windowStart: z + .string() + .describe( + "Legacy single-time-of-intake (HH:mm, user local). Preserved for backwards compatibility; the new `timesOfDay` array supersedes it.", + ), + windowEnd: z + .string() + .describe( + "Legacy reminder-window upper bound (HH:mm). Used to derive the late-classification grace span when `reminderGraceMinutes` is null.", + ), + label: z.string().nullable(), + dose: z + .string() + .nullable() + .describe( + "Per-schedule dose override. NULL means the schedule inherits `Medication.dose`.", + ), + daysOfWeek: z + .string() + .nullable() + .describe( + "Legacy persisted recurrence encoding (`null` | `1,3,5` | `i2;1,3,5`). v1.5 readers consult `rrule` first; the field is kept for pre-v1.5 rows. v1.6.0 drops the column.", + ), + timesOfDay: z + .array(z.string()) + .describe( + "v1.5 first-class points-in-time the dose is taken (HH:mm, user local). Backfilled to `[windowStart]` for every pre-v1.5 row.", + ), + reminderGraceMinutes: z + .number() + .int() + .nullable() + .describe( + "Reminder grace window in minutes. NULL falls back to the legacy `windowEnd - windowStart` span.", + ), + rrule: z + .string() + .nullable() + .describe( + "RFC 5545 RRULE string (subset). Used for calendar-anchored cadences. **Mutually exclusive with `rollingIntervalDays`** — exactly one of the two is non-null on any v1.5+ schedule (or both are null on legacy rows that haven't been touched since the migration).", + ), + rollingIntervalDays: z + .number() + .int() + .nullable() + .describe( + "Flexible-rolling interval in days, counted forward from the latest `MedicationIntakeEvent.takenAt`. **Mutually exclusive with `rrule`.**", + ), + scheduleType: z + .enum(["SCHEDULED", "PRN", "CYCLIC"]) + .describe( + "v1.7.0 schedule-type discriminator. SCHEDULED = rrule / rolling / legacy cadence. PRN = as-needed (never projected, reminded, or counted in compliance expected; still loggable via the intake route). CYCLIC = N weeks on / M weeks off, gating whichever inner cadence the rrule / legacy fields describe.", + ), + cyclicOnWeeks: z + .number() + .int() + .nullable() + .describe( + 'v1.7.0 cyclic "on" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise.', + ), + cyclicOffWeeks: z + .number() + .int() + .nullable() + .describe( + 'v1.7.0 cyclic "off" weeks. Only meaningful when `scheduleType` is CYCLIC; null otherwise.', + ), + }) + .meta({ + id: "MedicationSchedule", + description: + "Schedule entry attached to a medication. v1.5 promotes `timesOfDay` to first-class and introduces `rrule` (calendar-anchored cadences) and `rollingIntervalDays` (flexible-rolling cadences). The two recurrence primitives are mutually exclusive — enforced by the Zod refine on writes, the route layer, and a DB CHECK constraint (`medication_schedules_rrule_xor_rolling`). v1.7.0 adds `scheduleType` (SCHEDULED / PRN / CYCLIC) and the cyclic on/off-week fields.", + }); + +export const medicationResource = z + .object({ + id: z.string(), + name: z.string(), + dose: z.string(), + treatmentClass: medicationTreatmentClassEnum, + dosesPerUnit: z + .number() + .int() + .nullable() + .describe( + "Doses per pen / vial for inventory tracking. NULL = inventory tracking off.", + ), + unitsPerDose: z + .number() + .describe( + "v1.16.10 — inventory units one dose consumes (e.g. 2 tablets of 2 mg for a 4 mg dose). v1.16.12 — may be a split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾); thirds carry as ≈0.3333 / 0.6667. Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.", + ), + reorderLeadDays: z + .number() + .int() + .nullable() + .describe( + "v1.17.0 — optional per-medication reorder lead time in days (0–60). The low-stock alert widens its trigger by this lead plus one dose-interval so a refill arrives before the last dose. null = inherit the user-level notificationPrefs.medication.reorderLeadDays default (10).", + ), + active: z.boolean(), + notificationsEnabled: z.boolean(), + liveActivityEnabled: z + .boolean() + .describe( + "v1.7.0 iOS Live Activity opt-in for this medication's reminders. Default false. The iOS client owns the ActivityKit lifecycle; the server only stores + echoes the flag.", + ), + criticalAlarmEnabled: z + .boolean() + .describe( + "v1.7.0 iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute switch / Focus; the server stores the preference only.", + ), + atcCode: z + .string() + .nullable() + .describe( + "v1.9.0 optional WHO ATC classification code (active-substance class, e.g. `A10BX10`). User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under `http://www.whocc.no/atc`. NULL = no code captured.", + ), + rxNormCode: z + .string() + .nullable() + .describe( + "v1.9.0 optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary FHIR coding under `http://www.nlm.nih.gov/research/umls/rxnorm`, alongside any ATC code. NULL = no code captured.", + ), + pausedAt: z.iso.datetime({ offset: true }).nullable(), + snoozedUntil: z.iso.datetime({ offset: true }).nullable(), + nextDueAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "v1.7.0 server-computed next due instant across all the medication's schedules (earliest `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved slot's anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule era), this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next future slot. The list GET is cached 60 s, so a 60 s staleness is accepted.", + ), + nextDueOverdue: z + .boolean() + .describe( + "v1.16.4 — true when `nextDueAt` is an OPEN overdue slot: its anchor has passed, `now` is still inside the slot's catch-up band, and no taken / skipped / auto-missed row resolves it. False for a regular future next-due (and when `nextDueAt` is null). Read-only — computed, not stored.", + ), + startsOn: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "v1.5 course start (ISO date). Anchors RRULE BYDAY / BYMONTHDAY patterns and the rolling-interval countdown's first window. NULL means active from creation.", + ), + endsOn: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "v1.5 course end (ISO date). NULL means chronic. Equals `startsOn` when `oneShot` is true.", + ), + oneShot: z + .boolean() + .describe( + "v1.5 single-administration flag. When true the medication has at most one schedule (no `rrule` / `rollingIntervalDays`), and `active` auto-flips to false once the dose is logged.", + ), + asNeeded: z + .boolean() + .describe( + "v1.16.11 as-needed (PRN) flag. When true the medication carries ZERO schedules (the write routes 422 on any schedule entry alongside the flag): it is never due (`nextDueAt` stays null), never reminded, and excluded from every compliance rate/streak — but intakes still log as ad-hoc rows, inventory still consumes per `unitsPerDose`, and the history renders. Stays active indefinitely. Mutually exclusive with `oneShot`.", + ), + createdAt: z.iso.datetime({ offset: true }), + updatedAt: z.iso.datetime({ offset: true }), + schedules: z.array(medicationScheduleResource), + }) + .meta({ + id: "Medication", + description: + "Server-shaped medication row returned by GET / POST / PUT endpoints. Carries the v1.5 course-window fields (`startsOn`, `endsOn`, `oneShot`) at the medication level and the per-schedule cadence fields on the nested `schedules` array.", + }); + +export const medicationListEntry = medicationResource + .extend({ + category: medicationCategoryEnum, + lastTakenAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "Latest non-skipped `MedicationIntakeEvent.takenAt` for the medication. Drives the rolling-cadence countdown surface.", + ), + todayEventCount: z + .number() + .int() + .nonnegative() + .describe( + "Number of ACTIONED intake events for today (user-local day window): rows with a recorded `takenAt` or an explicit skip. Pending projector-minted rows do not count — the card overdue-pill suppression compares this against the passed-dose count, and a pending mint must not read as covered.", + ), + stockUnitsRemaining: z + .number() + .int() + .nullable() + .describe( + "v1.16.10 — usable inventory units left across the medication's containers (sum of `unitsRemaining` over ACTIVE / IN_USE items with units left). NULL = inventory tracking off (no items ever registered); 0 = tracking on, supply ran out. Read-only — aggregated, not stored.", + ), + stockDosesRemaining: z + .number() + .int() + .nullable() + .describe( + "v1.16.10 — dose-derived stock: `floor(stockUnitsRemaining / unitsPerDose)`, where `unitsPerDose` may be a fraction (½ tablet ⇒ twice the doses). Stays a whole-dose count. NULL when inventory tracking is off. Drives the table view's Bestand column. Read-only — aggregated, not stored.", + ), + }) + .meta({ + id: "MedicationListEntry", + description: + "List-row variant of the medication resource enriched with the joined `category`, `lastTakenAt`, `todayEventCount`, and the v1.16.10 aggregated stock fields (`stockUnitsRemaining`, `stockDosesRemaining`) the dashboard + iOS client consume. The base medication fields (`id`, `name`, `dose`, `treatmentClass`, `dosesPerUnit`, `active`, `notificationsEnabled`, `pausedAt`, `snoozedUntil`, `startsOn`, `endsOn`, `oneShot`, `createdAt`, `updatedAt`, `schedules`) are inlined; see the `Medication` component for their semantics.", + }); + +export const medicationDetailEntry = medicationResource + .extend({ + category: medicationCategoryEnum, + }) + .meta({ + id: "MedicationDetail", + description: + "Detail variant of the medication resource enriched with the joined `category`. The base medication fields are inlined; see the `Medication` component for their semantics.", + }); + +// v1.16.10 — per-container inventory entity (pen / blister pack / +// bottle). Counts UNITS; the medication's `unitsPerDose` maps units to +// doses. The intake consumption hook decrements `unitsRemaining` per +// taken dose and stamps the intake event with what it consumed. +export const medicationInventoryItemResource = z + .object({ + id: z.string(), + userId: z.string(), + medicationId: z.string(), + state: z + .enum(["ACTIVE", "IN_USE", "EXPIRED", "USED_UP"]) + .describe( + "Container lifecycle state. ACTIVE = unopened; IN_USE = opened (30-day in-use clock running); EXPIRED = printed expiry or in-use window lapsed with units left; USED_UP = drained (terminal).", + ), + containerType: z + .enum(MEDICATION_CONTAINER_TYPE_VALUES) + .describe( + "Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level classification; defaults to OTHER.", + ), + unitsTotal: z + .number() + .nullable() + .describe( + 'Units the container shipped with (tablets / ampoules / puffs; 1–1000). v1.16.12 — fractional, so a split-pill remainder reads cleanly. Dose-derived readouts divide by the medication\'s `unitsPerDose`. v1.18.3 (iOS#31) — NULL when the unit count is unknown (a corrupt or legacy row); the client renders "unknown", never a fabricated 0.', + ), + unitsRemaining: z + .number() + .nullable() + .describe( + 'Units left in the container. v1.16.12 — fractional (a ½-tablet dose leaves 29.5 of 30). Decremented by the intake consumption hook (FEFO with spillover across containers); refunded when a taken dose is skipped, edited away, or deleted. v1.18.3 (iOS#31) — NULL when the count is unknown (a corrupt or legacy row); the client renders "unknown", never a fabricated 0 it could decrement into negatives.', + ), + firstUseAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "Instant the container was first used. NULL until opened; starts the 30-day in-use clock.", + ), + expiresAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "Persisted MIN(firstUseAt + 30 days, printedExpiry). NULL when neither clock has started.", + ), + printedExpiry: z.iso.datetime({ offset: true }).nullable(), + purchasedAt: z.iso.datetime({ offset: true }).nullable(), + notes: z.string().nullable(), + createdAt: z.iso.datetime({ offset: true }), + updatedAt: z.iso.datetime({ offset: true }), + }) + .meta({ + id: "MedicationInventoryItem", + description: + "One supply container (pen / blister pack / bottle) of a medication. Counts UNITS — the medication's `unitsPerDose` maps units to doses. The intake write paths consume from the open container first, then first-expiry-first-out over unopened stock.", + }); + +export const medicationIntakeEventResource = z + .object({ + id: z.string(), + userId: z.string(), + medicationId: z.string(), + scheduledFor: z.iso.datetime({ offset: true }), + takenAt: z.iso.datetime({ offset: true }).nullable(), + skipped: z.boolean(), + source: z.enum(["WEB", "API", "REMINDER", "IMPORT"]), + idempotencyKey: z.string().nullable(), + createdAt: z.iso.datetime({ offset: true }), + }) + .meta({ + id: "MedicationIntakeEvent", + description: + "Single dose log row. `takenAt` is non-null for confirmed intakes; `skipped:true` represents a deliberately-missed dose (no inventory consumption).", + }); + +export const medicationCadenceTimelinePoint = z + .object({ + day: z.iso.datetime({ offset: true }), + windowStart: z.iso.datetime({ offset: true }), + windowEnd: z.iso.datetime({ offset: true }), + scheduleIndex: z.number().int().nonnegative(), + status: z.string(), + }) + .meta({ + id: "MedicationCadenceTimelinePoint", + description: + "One expected-vs-actual dose slot for the cadence timeline chart. `status` is one of `taken | skipped | missed | pending | future` and drives the chip colour.", + }); + +export const medicationCadenceChips = z + .object({ + adherenceRate: z.number(), + streakDays: z.number().int().nonnegative(), + expectedSlots: z.number().int().nonnegative(), + actualDoses: z.number().int().nonnegative(), + }) + .meta({ + id: "MedicationCadenceChips", + description: + "Four compliance summary values for the medication detail page chip row.", + }); + +export const medicationCadenceResponse = z + .object({ + windowDays: z.number().int().positive(), + anchorIso: z.iso.datetime({ offset: true }), + next: z + .object({ + windowStart: z.iso.datetime({ offset: true }), + windowEnd: z.iso.datetime({ offset: true }), + scheduleIndex: z.number().int().nonnegative(), + }) + .nullable(), + chips: medicationCadenceChips, + timeline: z.array(medicationCadenceTimelinePoint), + }) + .meta({ + id: "MedicationCadenceResponse", + description: + "Cadence + compliance read for a single medication. `next` is the upcoming-dose envelope (null when the course has ended or the rolling clock has no pinning intake yet); `timeline` walks the requested `windowDays` worth of slots in ascending time order.", + }); + +export const complianceResult = z + .object({ + totalExpected: z + .number() + .int() + .nonnegative() + .describe( + "Full denominator over the window: `taken + skipped + missed`. Cadence-aware and clamped to the medication's `createdAt`.", + ), + taken: z.number().int().nonnegative(), + skipped: z + .number() + .int() + .nonnegative() + .describe( + "Doses the user explicitly skipped — excluded from the `rate` denominator.", + ), + missed: z.number().int().nonnegative(), + rate: z + .number() + .int() + .min(0) + .max(100) + .describe( + "Adherence percentage `round(taken / (taken + missed) * 100)` — `skipped` is excluded from the denominator.", + ), + streak: z + .number() + .int() + .nonnegative() + .describe("Consecutive days with every due dose taken."), + }) + .meta({ + id: "ComplianceResult", + description: + "Rolling-window adherence summary. `compliance30` is the authoritative 'last 30 days, taken vs expected' read — clients should display `rate` and use `totalExpected` as the denominator rather than re-deriving it from the daily map.", + }); + +export const dailyComplianceEntry = z + .object({ + expected: z + .number() + .int() + .nonnegative() + .describe( + "Engine-computed due-slot count for the day. Equals `expectedCount`; kept for existing consumers.", + ), + expectedCount: z + .number() + .int() + .nonnegative() + .describe( + "True due-slot count for the day (additive field clients key off so they don't infer due-ness from `expected`).", + ), + due: z + .boolean() + .describe( + "`expectedCount > 0`. Paint a per-day glyph as expected/missed ONLY when `due === true`; off-cadence / pre-creation / PRN days are not misses.", + ), + taken: z.number().int().nonnegative(), + skipped: z.number().int().nonnegative(), + onTime: z + .number() + .int() + .nonnegative() + .describe( + "Doses taken in the on-time band, including the `early` bucket (early counts as compliant).", + ), + late: z.number().int().nonnegative(), + veryLate: z.number().int().nonnegative(), + early: z + .number() + .int() + .nonnegative() + .optional() + .describe( + "Doses taken before the on-time band's grace start; already folded into `onTime`, surfaced separately for consumers that differentiate.", + ), + }) + .meta({ + id: "DailyComplianceEntry", + description: + "Per-day compliance cell with the timing breakdown that drives the history glyph track.", + }); + +export const complianceDisplay = z + .object({ + shortDays: z.number().int().positive(), + longDays: z.number().int().positive(), + expectedShort: z.number().int().nonnegative(), + expectedLong: z.number().int().nonnegative(), + minStableDoses: z.number().int().nonnegative(), + short: z.object({ + rate: z.number().int().min(0).max(100), + taken: z.number().int().nonnegative(), + expected: z + .number() + .int() + .nonnegative() + .describe( + "v1.15.9 rate denominator over the short window (`taken + missed`); user skips are excluded. Render `taken / expected · rate%`.", + ), + missed: z + .number() + .int() + .nonnegative() + .describe( + "v1.15.9 doses counted against the rate over the short window — includes forgotten doses the auto-miss cron flipped.", + ), + streak: z.number().int().nonnegative(), + }), + long: z.object({ + rate: z.number().int().min(0).max(100), + taken: z.number().int().nonnegative(), + expected: z + .number() + .int() + .nonnegative() + .describe( + "v1.15.9 rate denominator over the long window (`taken + missed`).", + ), + missed: z + .number() + .int() + .nonnegative() + .describe( + "v1.15.9 doses counted against the rate over the long window.", + ), + }), + currentCycle: z + .object({ + state: z + .enum(["on_track", "due", "missed", "none"]) + .describe( + "Open-cycle state, decoupled from the percentage rows: `on_track` = next dose not yet due; `due` = due now / in grace; `missed` = past grace with no logged intake (the only red state); `none` = no projected next dose (PRN / paused / ended).", + ), + nextDueAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "The open cycle's due instant. Null when `state` is `none`.", + ), + graceUntil: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "End of the due slot's grace window. Null when `state` is `none`.", + ), + hasClosedCycles: z + .boolean() + .describe( + "False for a brand-new sparse med with zero closed dose cycles — the percentage rows are vacuous and the card should show a neutral 'not enough data yet' state.", + ), + }) + .describe( + "v1.13.x — the current (open) dose cycle, surfaced so a between-doses sparse med renders a neutral 'next dose in N days' line instead of a scary red 0%. The percentage rows above already exclude the open forward cycle from their denominator.", + ), + currentDose: z + .object({ + status: z + .enum([ + "upcoming", + "on_time_window", + "overdue", + "missed", + "taken_on_time", + "taken_late", + "skipped", + ]) + .describe( + "Per-dose state of the open cycle, server-derived from the window model so the card renders the take-window (green) / overdue / heavily-overdue escalation from one authority. `upcoming` when no dose is open (PRN / paused / ended).", + ), + targetAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "Target instant of the open dose. Null when no dose is open.", + ), + }) + .describe( + "v1.15.9 — the open dose's per-dose status + target, so the card highlights the actionable take-window green and escalates an overdue dose without re-deriving the window math client-side.", + ), + }) + .meta({ + id: "ComplianceDisplay", + description: + "The two-row card block whose windows scale with dosing cadence (dense meds keep 7 / 30 days, sparse meds step both windows up). NOT the 30-day denominator — read `compliance30.totalExpected` for that.", + }); + +export const medicationComplianceResponse = z + .object({ + compliance7: complianceResult, + compliance30: complianceResult, + dailyCompliance: z + .record(z.string(), dailyComplianceEntry) + .describe( + "Flat per-day map keyed `YYYY-MM-DD` in the user timezone, one entry per day for up to 90 days back, clamped to the medication's `createdAt` (so a recently-created med has fewer entries). No weekly/monthly collapse — this is the raw daily grid.", + ), + complianceDisplay, + }) + .meta({ + id: "MedicationComplianceResponse", + description: + "Adherence read for a single medication. `compliance30` is the authoritative 30-day taken-vs-expected summary; `dailyCompliance` is the per-day grid for the history glyph track. The graded raw→week→month→year series used elsewhere for AI prompts does NOT apply here — this response is never downsampled.", + }); + +export const medicationComplianceSummaryEntry = z + .object({ + medicationId: z.string(), + compliance7: complianceResult, + compliance30: complianceResult, + complianceDisplay, + }) + .meta({ + id: "MedicationComplianceSummaryEntry", + description: + "Compact per-medication adherence row for the batched card read: the 7-/30-day summaries plus the cadence-scaled display block. The per-day `dailyCompliance` grid is NOT on this shape — read the per-medication `/compliance` endpoint for the history glyph track.", + }); + +// v1.16.5 — schedule-era management (the Zeitplan-tab history timeline). +// Archived eras come from two provenances: the wholesale-replace write +// path (`ARCHIVED`, immutable) and the user-entered pre-tracking flow +// (`MANUAL`, deletable through the `[revisionId]` DELETE). +export const scheduleRevisionEntrySummary = z + .object({ + timesOfDay: z + .array(z.string()) + .describe("Daily dose times (HH:mm, user local) the era ran at."), + label: z.string().nullable(), + dose: z.string().nullable(), + scheduleType: z + .string() + .describe( + "Schedule-type discriminator of the archived row (SCHEDULED / PRN / CYCLIC).", + ), + }) + .meta({ + id: "ScheduleRevisionEntry", + description: + "Display summary of one archived schedule row inside an era. The full snapshot (windows, rrule, doseWindows, …) stays server-side; this projection carries what the timeline renders.", + }); + +export const scheduleRevisionResource = z + .object({ + id: z.string(), + validFrom: z.iso.datetime().describe("Inclusive start instant of the era."), + validUntil: z.iso + .datetime() + .describe( + "Exclusive end instant of the era — the moment the next plan took over.", + ), + source: z + .enum(["ARCHIVED", "MANUAL"]) + .describe( + "Provenance. ARCHIVED = minted by the schedule-replace write path (immutable). MANUAL = user-entered pre-tracking era (deletable).", + ), + entries: z.array(scheduleRevisionEntrySummary), + }) + .meta({ + id: "MedicationScheduleRevision", + description: + "One archived schedule era covering `[validFrom, validUntil)`. The dose-history ledger, compliance tallies, and cadence chips mint past days against the era that was live then.", + }); + +export const scheduleRevisionListResponse = z.object({ + currentSince: z.iso + .datetime() + .describe( + "Instant the LIVE plan took over: the newest revision's `validUntil`, or the medication's `createdAt` when no era has been archived.", + ), + revisions: z + .array(scheduleRevisionResource) + .describe("Archived eras, newest first."), +}); + +// v1.5.0 — natural-language medication extraction route. The wizard's +// optional "Beschreiben" overlay POSTs a free-text description and +// receives a partial structured payload the form merges onto whatever +// the user already typed. Citation-guarded (`name` and `dose` are +// dropped when not substring-matched in the original text) and +// closed-enum-validated. +export const medicationExtractRequest = z + .object({ + text: z + .string() + .min(1) + .max(2000) + .describe( + "Free-text medication description (any locale). Up to 2 000 characters. The model never echoes the text back into another tenant — it is only used to produce the structured fields.", + ), + locale: z + .enum(["en", "de", "es", "fr", "it", "pl"]) + .optional() + .describe("Optional UI locale hint for the model."), + today: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional() + .describe( + 'Optional override of the reference date used to resolve relative phrases ("tomorrow", "next Monday"). Format: `YYYY-MM-DD`. Defaults to the server\'s UTC day.', + ), + }) + .meta({ + id: "MedicationExtractRequest", + description: + "Free-text medication description payload. The route runs the text through the Coach provider chain and returns a partial structured payload the wizard merges. Rate-limited 10 requests / 5 minutes / user; budget-gated against the daily Coach token ceiling.", + }); + +medicationExtractionSchema.meta({ + id: "MedicationExtractionResult", + description: + "Citation-guarded partial extraction of medication scheduling fields. Every field is optional; the wizard merges what is present onto the form state and leaves the rest blank. `name` and `dose` are post-validated against the original free-text and dropped when not substring-matched, so the wizard cannot silently land a hallucinated brand or dose. `cadenceKind` / `doseUnit` / `weekdays` are closed enums; numeric fields are clamped to the wizard's wire bounds.", +}); + +// v1.16.10 — medications list presentation (cards/table view + manual +// order), persisted per user in its own `User` column following the +// dashboard-widgets / insights-layout per-surface convention. +export const medicationListLayoutSchema = z + .object({ + version: z.literal(1), + view: z + .enum(MEDICATION_LIST_VIEWS) + .optional() + .describe( + 'Which presentation /medications renders in. Default "cards". Optional on PUT — when omitted the stored value is preserved (preserve-when-absent, like `heroVisible` on the dashboard layout). Always present on responses.', + ), + order: z + .array(z.string().min(1).max(MEDICATION_ORDER_ID_MAX_LENGTH)) + .max(MEDICATION_ORDER_MAX_ENTRIES) + .optional() + .describe( + "User-defined manual medication order (medication ids, first = top), shared by both views. Display-only — unknown / deleted ids are ignored at render time, never 422. Optional on PUT (preserve-when-absent); always present on responses.", + ), + }) + .meta({ + id: "MedicationListLayout", + description: + "Per-user /medications presentation: the card/table view choice plus the manual medication order shared by both views. Mirrors the dashboard-widgets / insights-layout contract.", + }); From d1be2a9c6b3e8013eb490e3d2415c62cb9bc2917 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 01:06:59 +0200 Subject: [PATCH 2/6] refactor(openapi): split insights route registry into schemas + paths behind a barrel --- src/lib/openapi/routes/insights/index.ts | 6 + src/lib/openapi/routes/insights/paths.ts | 375 +++++++++++ src/lib/openapi/routes/insights/schemas.ts | 707 +++++++++++++++++++++ 3 files changed, 1088 insertions(+) create mode 100644 src/lib/openapi/routes/insights/index.ts create mode 100644 src/lib/openapi/routes/insights/paths.ts create mode 100644 src/lib/openapi/routes/insights/schemas.ts diff --git a/src/lib/openapi/routes/insights/index.ts b/src/lib/openapi/routes/insights/index.ts new file mode 100644 index 000000000..22c8041a7 --- /dev/null +++ b/src/lib/openapi/routes/insights/index.ts @@ -0,0 +1,6 @@ +/** + * OpenAPI route table — insights. Barrel preserving the original module path. + * + * Part of the OpenAPI route table; aggregated in `../index.ts`. + */ +export { insightsPaths } from "./paths"; diff --git a/src/lib/openapi/routes/insights/paths.ts b/src/lib/openapi/routes/insights/paths.ts new file mode 100644 index 000000000..a3826a351 --- /dev/null +++ b/src/lib/openapi/routes/insights/paths.ts @@ -0,0 +1,375 @@ +/** + * OpenAPI path table — dashboard snapshot, comprehensive insights, analytics range, metric status, derived metrics, correlations. + * + * Schema declarations live in `./schemas`; this module is the path orchestrator. + */ +import type { ZodOpenApiObject } from "zod-openapi"; +import { consentRequiredResponse, dataEnvelope, stdResponses } from "../shared"; +import { + insightsComprehensiveResponse, + metricStatusQuery, + metricStatusResponse, + derivedMetricQuery, + derivedMetricResponse, + derivedBatchQuery, + derivedBatchResponse, + correlationDiscoveryResponse, + insightStatusQuery, + insightStatusResponse, + medicationComplianceStatusResponse, + analyticsRangeQuery, + analyticsRangeResponse, + insightsPregenerateRequest, + insightsPregenerateResponse, + dashboardSnapshotResponse, +} from "./schemas"; + +export const insightsPaths: NonNullable = { + "/api/dashboard/snapshot": { + get: { + tags: ["Dashboard"], + summary: "Unified dashboard first-paint snapshot", + description: + "Assembles every above-the-fold tile field in one round-trip from the rollup / mood / widget helpers plus a read-only lift of the pre-generated daily briefing. Two-phase: `tiles` always present, `extras` nullable on a rollup-coverage miss. No LLM is reachable from this path. Cookie or Bearer auth.", + responses: { + "200": { + description: "Dashboard snapshot.", + content: { + "application/json": { + schema: dataEnvelope( + dashboardSnapshotResponse, + "DashboardSnapshotResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/comprehensive": { + get: { + tags: ["Insights"], + summary: "Comprehensive AI insights bundle", + description: + "Full Insights surface — daily briefing, recommendations with rationale, optional weekly report + storyboard annotations. Strict-schema validated server-side. Requires an active ConsentReceipt when the resolved provider chain egresses via the operator's server-managed key (see POST /api/consent/ai).", + responses: { + "200": { + description: "Insights bundle.", + content: { + "application/json": { + schema: dataEnvelope( + insightsComprehensiveResponse, + "InsightsComprehensiveResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + ...consentRequiredResponse, + }, + }, + }, + "/api/insights/pregenerate": { + post: { + tags: ["Insights"], + summary: "Warm all AI assessments for the calling user", + description: + "v1.8.7.1 — enqueue a full warm of every AI assessment for the authenticated user (comprehensive insight + the seven specialised status cards + every data-bearing generic metric assessment) in the active locale, so the read-only status GETs serve cached text instantly. Returns immediately; the generation runs out of band on the worker. Empty metrics and provider-less accounts never trigger an LLM call. Short anti-spam bucket (`insights-warm:`, one warm per 3 minutes) → 429 on a tight loop. Auth via cookie or Bearer; `userId` is taken from the session, never the body.", + requestBody: { + required: false, + content: { + "application/json": { schema: insightsPregenerateRequest }, + }, + }, + responses: { + "200": { + description: + "Warm accepted and enqueued. The work runs on the worker; poll the read-only status routes for the text.", + content: { + "application/json": { + schema: dataEnvelope( + insightsPregenerateResponse, + "InsightsPregenerateResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/analytics/range": { + get: { + tags: ["Analytics"], + summary: "Single-metric period-over-period range delta", + description: + "v1.9.0 — returns the current-window aggregate, the previous comparable window, and the composed delta for ONE metric type over a `7d` / `30d` / `90d` / `1y` range. Single-type by construction (the metric page is single-metric), so the read is one rollup-tier call covering the trailing 2N days sliced into the two halves — no per-type fan-out. Additive route; the `/api/analytics` envelope is unchanged. Auth via cookie or Bearer.", + requestParams: { + query: analyticsRangeQuery, + }, + responses: { + "200": { + description: "Current + previous window aggregates and the delta.", + content: { + "application/json": { + schema: dataEnvelope( + analyticsRangeResponse, + "AnalyticsRangeResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/blood-pressure-status": { + get: { + tags: ["Insights"], + summary: "Blood-pressure assessment", + description: + "Data-driven plain-language assessment of the user's recent blood-pressure readings. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", + requestParams: { + query: insightStatusQuery, + }, + responses: { + "200": { + description: "Assessment envelope (fresh, cached, or preparing).", + content: { + "application/json": { + schema: dataEnvelope( + insightStatusResponse, + "BloodPressureStatusResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/pulse-status": { + get: { + tags: ["Insights"], + summary: "Pulse assessment", + description: + "Data-driven plain-language assessment of the user's recent resting-pulse readings. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", + requestParams: { + query: insightStatusQuery, + }, + responses: { + "200": { + description: "Assessment envelope (fresh, cached, or preparing).", + content: { + "application/json": { + schema: dataEnvelope( + insightStatusResponse, + "PulseStatusResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/weight-status": { + get: { + tags: ["Insights"], + summary: "Weight assessment", + description: + "Data-driven plain-language assessment of the user's recent weight trend. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", + requestParams: { + query: insightStatusQuery, + }, + responses: { + "200": { + description: "Assessment envelope (fresh, cached, or preparing).", + content: { + "application/json": { + schema: dataEnvelope( + insightStatusResponse, + "WeightStatusResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/bmi-status": { + get: { + tags: ["Insights"], + summary: "BMI assessment", + description: + "Data-driven plain-language assessment of the user's body-mass index. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", + requestParams: { + query: insightStatusQuery, + }, + responses: { + "200": { + description: "Assessment envelope (fresh, cached, or preparing).", + content: { + "application/json": { + schema: dataEnvelope( + insightStatusResponse, + "BmiStatusResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/mood-status": { + get: { + tags: ["Insights"], + summary: "Mood assessment", + description: + "Data-driven plain-language assessment of the user's recent mood entries. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", + requestParams: { + query: insightStatusQuery, + }, + responses: { + "200": { + description: "Assessment envelope (fresh, cached, or preparing).", + content: { + "application/json": { + schema: dataEnvelope( + insightStatusResponse, + "MoodStatusResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/medication-compliance-status": { + get: { + tags: ["Insights"], + summary: "Medication-compliance assessment", + description: + "Data-driven plain-language assessment of the user's medication compliance — an overall `summary` plus a per-medication note array. Read-only: a cache miss warms a generation out of band and serves the last-good envelope meanwhile (stale-while-revalidate). Auth via cookie or Bearer.", + requestParams: { + query: insightStatusQuery, + }, + responses: { + "200": { + description: + "Compliance assessment envelope (fresh, cached, or preparing).", + content: { + "application/json": { + schema: dataEnvelope( + medicationComplianceStatusResponse, + "MedicationComplianceStatusResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/metric-status": { + get: { + tags: ["Insights"], + summary: "Generic per-HealthKit-metric assessment", + description: + "v1.8.7.1 — data-driven plain-language assessment for any registered HealthKit metric (resting heart rate, sleep, glucose, body composition, gait, audio exposure, …). One generic route covering ~30 metric pages via archetype prompt templates + per-metric metadata. Read-only: a cache miss warms a generation out of band and serves the last-good text meanwhile (stale-while-revalidate). An unknown `metric` 422s against the closed registry enum. Auth via cookie or Bearer.", + requestParams: { + query: metricStatusQuery, + }, + responses: { + "200": { + description: "Assessment envelope (fresh, cached, or preparing).", + content: { + "application/json": { + schema: dataEnvelope( + metricStatusResponse, + "MetricStatusResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/derived": { + get: { + tags: ["Insights"], + summary: "Derived wellness metric (compute-once)", + description: + "v1.10.0 — the compute-once `Derived` value for any registered derived wellness metric (personal typical-range vitals baseline, cardio-fitness band, vascular-age delta, sleep score, readiness, coincident-deviation flag). One generic route over a closed registry enum; an unknown `metric` 422s. Pure compute over the rollup tier with a per-type live fallback on a coverage miss — no LLM call, no narrative, no cache table. Returns the flat `Derived` union so the native client can decode one stable shape and combine values across metrics. Auth via cookie or Bearer.", + requestParams: { + query: derivedMetricQuery, + }, + responses: { + "200": { + description: "The flat derived-metric value (ok or insufficient).", + content: { + "application/json": { + schema: dataEnvelope( + derivedMetricResponse, + "DerivedMetricResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/derived/batch": { + get: { + tags: ["Insights"], + summary: "Derived wellness metrics (batched compute-once)", + description: + "v1.10.0 — resolve several derived wellness metrics in ONE request. The `metrics` CSV names the metrics (a `metric:type` token sub-targets a VITALS_BASELINE vital); the server fans out under a bounded limiter with the profile loaded once and returns a map keyed by the per-request token. Collapses the Insights cold-mount fan-out of 14+ independent single-metric requests — the pool-starvation class that surfaces as a hang-then-recover. The single-metric route stays for the per-score detail pages. Auth via cookie or Bearer.", + requestParams: { + query: derivedBatchQuery, + }, + responses: { + "200": { + description: "The map of derived-metric values, keyed by token.", + content: { + "application/json": { + schema: dataEnvelope( + derivedBatchResponse, + "DerivedBatchResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/correlations": { + get: { + tags: ["Insights"], + summary: "Correlation discovery (FDR-controlled)", + description: + "v1.10.0 — scans a curated behaviour × outcome matrix (daylight / mood / glucose / BP / steps × sleep / HRV / resting HR / weight), lag-joins each behaviour day to the next day's outcome, runs Pearson with the exact Student-t p-value, and applies Benjamini-Hochberg FDR control across every tested pair. Only statistically-defensible pairs surface, each carrying n, r, p, and the BH-adjusted q. Descriptive, never causal. Gated by the operator `correlations` assistant surface. Auth via cookie or Bearer.", + responses: { + "200": { + description: "The discovered correlations + the tested-pair count.", + content: { + "application/json": { + schema: dataEnvelope( + correlationDiscoveryResponse, + "CorrelationDiscoveryResponseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, +}; diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts new file mode 100644 index 000000000..d5ee83614 --- /dev/null +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -0,0 +1,707 @@ +/** + * OpenAPI route table — dashboard snapshot, comprehensive insights, analytics range, metric status, derived metrics, correlations. + * + * Part of the OpenAPI route table; aggregated in `./index.ts`. + * Schemas come from `src/lib/validations/*` where shared with the + * runtime request parsing, so the wire contract stays single-source. + */ +import { z } from "zod/v4"; +import { measurementTypeEnum } from "@/lib/validations/measurement"; +import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry"; +import { + DERIVED_METRIC_IDS, + VITALS_BASELINE_TYPES, +} from "@/lib/insights/derived/registry"; +import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta"; + +export const insightsComprehensiveResponse = z + .object({ + summary: z.string(), + recommendations: z.array(z.record(z.string(), z.unknown())), + citations: z.array(z.record(z.string(), z.unknown())), + warnings: z.array(z.record(z.string(), z.unknown())), + dailyBriefing: z.record(z.string(), z.unknown()).nullable().optional(), + trendAnnotations: z.record(z.string(), z.unknown()).nullable().optional(), + storyboardAnnotations: z + .array(z.record(z.string(), z.unknown())) + .optional(), + metricSource: z.record(z.string(), z.unknown()).optional(), + revalidating: z + .boolean() + .optional() + .describe( + "True when the body is served from last-good cache (stale-while-revalidate) while a fresh aggregation runs in the background. The client keeps polling on `revalidating` (bounded) so the open page converges on the fresh body.", + ), + }) + .meta({ + id: "InsightsComprehensiveResponse", + description: + "AI-generated insights bundle. Strict-schema validated server-side; Coach-routed when the insight surface needs day-level grounding.", + }); + +// v1.8.7.1 — generic per-HealthKit-metric assessment. The query enum is +// derived from the same registry the route validates against, so the +// spec, the route, and the cache scope cannot drift. The seven +// specialised metrics (weight / blood-pressure / pulse / bmi / mood / +// medication-compliance) keep their own routes and are NOT accepted here. +export const metricStatusQuery = z + .object({ + metric: z + .enum(METRIC_STATUS_IDS as [string, ...string[]]) + .describe( + "HealthKit metric id to assess (e.g. RESTING_HEART_RATE, SLEEP_DURATION). Closed enum: an unknown id 422s. The seven specialised metrics are served by their own routes and are not accepted here.", + ), + locale: z + .enum(["de", "en"]) + .optional() + .describe("Optional UI-locale override; defaults to the session locale."), + }) + .meta({ id: "MetricStatusQuery" }); + +export const metricStatusResponse = z + .object({ + hasProvider: z + .boolean() + .describe( + "False when the user has no usable AI provider — `text` then carries the generic no-key guidance.", + ), + text: z + .string() + .nullable() + .describe( + "The assessment narrative (plain text, rendered as React text children). Null while a first generation is preparing, or when the metric has insufficient data.", + ), + cached: z + .boolean() + .describe("True when `text` is served from cache (incl. last-good)."), + updatedAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe("When the served assessment was generated; null when none."), + preparing: z + .boolean() + .optional() + .describe( + "True when a first assessment is being generated out of band and no prior text exists yet — the client polls until it lands.", + ), + revalidating: z + .boolean() + .optional() + .describe( + "True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The payload is otherwise terminal; the client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the warmed assessment without a remount.", + ), + insufficient: z + .boolean() + .optional() + .describe( + "True when the metric has no readings; no assessment is generated (no LLM call). The card shows its insufficient-data state.", + ), + }) + .meta({ + id: "MetricStatusResponse", + description: + "Generic per-metric assessment envelope. Identical shape to the seven specialised `*-status` cards so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile.", + }); + +// v1.10.0 — generic derived-wellness-metric route. The query enum is +// derived from the same registry the route validates against, so spec + +// route + cache scope cannot drift. `type` sub-targets the single vital +// a baseline metric (VITALS_BASELINE) bands over. +export const derivedMetricQuery = z + .object({ + metric: z + .enum(DERIVED_METRIC_IDS as [string, ...string[]]) + .describe( + "Derived-metric id to compute (e.g. VITALS_BASELINE, FITNESS_AGE, VASCULAR_AGE_DELTA, HRV_BALANCE, BMI, READINESS). Closed enum: an unknown id 422s. Metrics whose compute has not yet landed return an `insufficient` value with reason `not_implemented`.", + ), + type: z + .enum(VITALS_BASELINE_TYPES as [string, ...string[]]) + .optional() + .describe( + "For VITALS_BASELINE only — the single vital to band (defaults to RESTING_HEART_RATE). Ignored by composites. An unsupported value yields an `insufficient` value rather than a 422 so iOS metric combinations stay forgiving.", + ), + }) + .meta({ id: "DerivedMetricQuery" }); + +export const derivedCoverage = z + .object({ + requiredInputs: z + .number() + .int() + .describe("Inputs the metric wants (its full input set)."), + presentInputs: z + .number() + .int() + .describe("Inputs actually present in the user's data."), + historyDays: z + .number() + .int() + .describe( + "Distinct days of history backing the value (the gating floor).", + ), + missing: z + .array(z.string()) + .describe( + "Named inputs still missing — drives the 'track N more' nudge.", + ), + }) + .meta({ id: "DerivedCoverage" }); + +export const derivedConfidence = z + .object({ + score: z + .number() + .describe( + "0..100 confidence; feeds the shared coverage meter unchanged.", + ), + band: z + .enum(["high", "medium", "low", "draft"]) + .describe("Confidence band the meter renders."), + }) + .meta({ id: "DerivedConfidence" }); + +export const derivedProvenance = z + .object({ + inputs: z + .array(z.string()) + .describe("Named inputs that actually backed the value."), + source: z + .enum(["DAY", "WEEK", "MONTH", "YEAR", "live", "none"]) + .describe( + "Granularity the dominant read resolved against. 'live' = a coverage-miss live-SQL fallback; 'none' = no data backed the value.", + ), + windowDays: z + .number() + .int() + .describe("Trailing window the value summarises, in days."), + computedAt: z.iso + .datetime({ offset: true }) + .describe("Compute time (for cache-staleness + the 'as of' chip)."), + }) + .meta({ id: "DerivedProvenance" }); + +// v1.13.2 — per-derived-SCORE assessment text. Additive, non-breaking field +// on the derived response; the iOS field-name contract is LOCKED. +export const derivedAssessment = z + .object({ + text: z + .string() + .describe( + "Short, non-empty explanation of why the score sits where it does, referencing the score's contributors.", + ), + source: z + .string() + .describe( + "'deterministic' for the always-on template text, or 'ai' when warmer provider prose has been cached.", + ), + updatedAt: z.iso + .datetime({ offset: true }) + .describe("When the text was produced / last warmed."), + }) + .meta({ id: "DerivedAssessment" }); + +export const derivedMetricResponse = z + .object({ + metric: z + .enum(DERIVED_METRIC_IDS as [string, ...string[]]) + .describe("Echoes the requested derived-metric id (tags the union)."), + status: z + .enum(["ok", "insufficient"]) + .describe( + "'ok' carries `value` + `confidence`; 'insufficient' carries `reason` and no value, but still carries `coverage` + `provenance` so the surface renders the same gating UI.", + ), + value: z + .record(z.string(), z.unknown()) + .nullable() + .describe( + "Metric-specific value object when status is 'ok' (e.g. { type, center, low, high, spread, sampleDays, k, series } for VITALS_BASELINE, where `series` is the trailing per-day mean values for the inline sparkline); null when 'insufficient'.", + ), + coverage: derivedCoverage, + confidence: derivedConfidence + .nullable() + .describe("Present when status is 'ok'; null when 'insufficient'."), + provenance: derivedProvenance, + reason: z + .string() + .nullable() + .describe( + "Why the value could not be produced; null when status is 'ok'.", + ), + assessment: derivedAssessment + .nullable() + .describe( + "v1.13.2 — short 'why is this score what it is' explanation, keyed to the SAME requested id (only for the per-score ids READINESS, SLEEP_SCORE, RECOVERY_SCORE, STRAIN_SCORE, STRESS_SCORE). Null for any other metric and whenever status !== 'ok'. Always non-empty when present: a deterministic text fills it (so provider-less accounts + the demo always get one) and warmer AI prose overrides it once cached.", + ), + }) + .meta({ + id: "DerivedMetricResponse", + description: + "Flat `Derived` envelope for one derived wellness metric. Pure compute over the rollup tier (no LLM, no narrative). iOS decodes one stable shape and combines values across metrics; coverage/confidence/provenance let it render the same honesty chips.", + }); + +// v1.10.0 — batched derived-metric query. The `metrics` CSV carries one +// or more `metric` / `metric:type` tokens; the route fans out server-side +// under a bounded limiter with the profile loaded once, collapsing the +// dashboard's cold-mount fan-out of N single-metric requests into one. +export const derivedBatchQuery = z + .object({ + metrics: z + .string() + .min(1) + .max(1024) + .describe( + "Comma-separated derived-metric tokens. Each is a `` or `:` (the colon sub-targets a VITALS_BASELINE vital). An unknown id 422s; a `type` outside the MeasurementType enum 422s; at most 24 tokens; duplicates collapse.", + ), + }) + .meta({ id: "DerivedBatchQuery" }); + +export const derivedBatchResponse = z + .object({ + metrics: z + .record(z.string(), derivedMetricResponse) + .describe( + "Map keyed by the per-request token (`` or `:`). Each value is the same flat `Derived` envelope the single-metric route returns, so a client decodes one shape and reads back exactly the tokens it asked for.", + ), + }) + .meta({ + id: "DerivedBatchResponse", + description: + "Batched derived-metric values. One request resolves the whole dashboard grid (the wellness scores + the derived re-frames + one baseline per vital) instead of N concurrent single-metric requests sharing the Prisma pool. Pure compute over the rollup tier — no LLM, no narrative, no cache table.", + }); + +// v1.10.0 — FDR-controlled correlation discovery result. One discovered, +// statistically-defensible behaviour → next-day-outcome pair. +export const discoveredCorrelation = z + .object({ + behaviour: z + .string() + .describe("Behaviour channel (lag source), e.g. TIME_IN_DAYLIGHT, MOOD."), + outcome: z + .string() + .describe( + "Outcome channel (lag target), e.g. SLEEP_DURATION, HEART_RATE_VARIABILITY.", + ), + n: z + .number() + .int() + .describe("Paired-day count after the day+1 lag join (≥ 20)."), + r: z.number().describe("Pearson r over the lag-joined daily series."), + pValue: z.number().describe("Two-sided exact Student-t p-value (< 0.05)."), + qValue: z + .number() + .describe( + "Benjamini-Hochberg FDR-adjusted q-value (≤ the surface threshold).", + ), + interpretation: z + .string() + .describe("Conservative, descriptive interpretation — never causal."), + lagDays: z.number().int().describe("Lag in days applied (1)."), + }) + .meta({ id: "DiscoveredCorrelation" }); + +export const correlationDiscoveryResponse = z + .object({ + discovered: z + .array(discoveredCorrelation) + .describe("Pairs surviving n ≥ 20, p < 0.05, AND the BH-FDR control."), + pairsTested: z + .number() + .int() + .describe("Behaviour × outcome pairs assessed (for the honest footer)."), + fdrQ: z.number().describe("The FDR target the surface used."), + minPairs: z + .number() + .int() + .describe("Minimum paired-day count enforced per pair."), + }) + .meta({ + id: "CorrelationDiscoveryResponse", + description: + "v1.10.0 — FDR-controlled correlation discovery over a curated behaviour × outcome matrix, lagged behaviour → next-day outcome. Only statistically-defensible pairs surface; descriptive, never causal.", + }); + +// The seven specialised `*-status` routes accept an optional locale +// override (the metric is fixed by the route path, unlike the generic +// metric-status route which carries it as a query field). +export const insightStatusQuery = z + .object({ + locale: z + .enum(["de", "en"]) + .optional() + .describe("Optional UI-locale override; defaults to the session locale."), + }) + .meta({ id: "InsightStatusQuery" }); + +// Shared response shape for the five text-bearing specialised status +// routes (blood-pressure, pulse, weight, bmi, mood). Same envelope as +// the generic metric-status card minus the `insufficient` flag, which is +// metric-status-only. Read-only + stale-while-revalidate. +export const insightStatusResponse = z + .object({ + hasProvider: z + .boolean() + .describe( + "False when the user has no usable AI provider — `text` then carries the generic no-key guidance.", + ), + text: z + .string() + .nullable() + .describe( + "The assessment narrative (plain text, rendered as React text children). Null while a first generation is preparing.", + ), + cached: z + .boolean() + .describe("True when `text` is served from cache (incl. last-good)."), + updatedAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe("When the served assessment was generated; null when none."), + preparing: z + .boolean() + .optional() + .describe( + "True when a first assessment is being generated out of band and no prior text exists yet — the client polls until it lands.", + ), + revalidating: z + .boolean() + .optional() + .describe( + "True when `text` is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The client keeps polling on `preparing || revalidating` (bounded) so the open card upgrades to the warmed assessment without a remount.", + ), + }) + .meta({ + id: "InsightStatusResponse", + description: + "Specialised per-metric assessment envelope (blood-pressure, pulse, weight, bmi, mood). Identical shape to the generic metric-status card so the `InsightStatusCard` consumes it unchanged. Read-only + stale-while-revalidate: a cache miss warms a generation out of band and serves the last-good text meanwhile.", + }); + +// The medication-compliance route carries a richer envelope than the +// other six: a `summary` narrative plus a per-medication `text` array, +// instead of a single `text` field. +export const medicationComplianceStatusResponse = z + .object({ + hasProvider: z + .boolean() + .describe( + "False when the user has no usable AI provider — `summary` then carries the generic no-key guidance.", + ), + summary: z + .string() + .nullable() + .describe( + "The overall compliance narrative (plain text). Null while a first generation is preparing.", + ), + medications: z + .array( + z + .object({ + medicationId: z + .string() + .describe("The medication this note belongs to."), + text: z + .string() + .describe("Per-medication compliance note (plain text)."), + }) + .meta({ id: "MedicationComplianceStatusItem" }), + ) + .describe( + "Per-medication compliance notes. Empty while preparing or when no medication qualifies.", + ), + cached: z + .boolean() + .describe( + "True when the envelope is served from cache (incl. last-good).", + ), + updatedAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe("When the served assessment was generated; null when none."), + preparing: z + .boolean() + .optional() + .describe( + "True when a first assessment is being generated out of band and no prior summary exists yet — the client polls until it lands.", + ), + revalidating: z + .boolean() + .optional() + .describe( + "True when the envelope is served from last-good cache (stale-while-revalidate) while a fresh generation is in flight. The client keeps polling on `preparing || revalidating` (bounded).", + ), + }) + .meta({ + id: "MedicationComplianceStatusResponse", + description: + "Medication-compliance assessment envelope. Unlike the other six specialised cards it carries a `summary` plus a per-medication `text` array rather than a single `text` field. Read-only + stale-while-revalidate.", + }); + +export const analyticsRangeQuery = z + .object({ + type: measurementTypeEnum.describe( + "The measurement type to read (single metric — no fan-out). Closed enum: an unknown type 422s.", + ), + range: z + .enum(ANALYTICS_RANGES) + .describe( + "Trailing window: `7d` / `30d` / `90d` / `1y`. The previous comparable window is the equally-sized span immediately before it.", + ), + }) + .meta({ id: "AnalyticsRangeQuery" }); + +export const analyticsWindowAggregate = z + .object({ + count: z.number().int().describe("Reading count composed across buckets."), + min: z.number().nullable().describe("Window minimum; null when empty."), + max: z.number().nullable().describe("Window maximum; null when empty."), + mean: z + .number() + .nullable() + .describe("Count-weighted mean across buckets; null when empty."), + sum: z + .number() + .nullable() + .describe( + "Cumulative total for cumulative metrics (steps, energy, distance); null when no bucket carries a sum.", + ), + }) + .meta({ id: "AnalyticsWindowAggregate" }); + +export const analyticsRangeResponse = z + .object({ + range: z + .enum(ANALYTICS_RANGES) + .describe("The range that was read (echoes the request)."), + windowDays: z + .number() + .int() + .describe("Trailing-window length in days for the chosen range."), + granularity: z + .string() + .describe( + "Rollup granularity the read resolved against (`DAY` / `WEEK` / `MONTH` / `YEAR`, or `none` on a coverage miss).", + ), + current: analyticsWindowAggregate.describe( + "Aggregate over the current window `[now-N, now)`.", + ), + previous: analyticsWindowAggregate.describe( + "Aggregate over the previous comparable window `[now-2N, now-N)`.", + ), + delta: z + .number() + .nullable() + .describe( + "`current.mean - previous.mean`; null when either window has no data (never a misleading 0).", + ), + deltaPct: z + .number() + .nullable() + .describe( + "`delta / previous.mean` as a fraction (0.03 = +3 %); null when the prior window has no / zero mean (no divide-by-zero). The client shows 'no prior-period data' in that case.", + ), + }) + .meta({ + id: "AnalyticsRangeResponse", + description: + "Single-metric period-over-period aggregate. Reads the current and previous comparable windows from the WMY rollup tier and composes a count-weighted-mean delta. `count/min/max/mean/sum` are linearly composable across buckets; SD/slope/r² are intentionally excluded (not composable).", + }); + +export const insightsPregenerateRequest = z.object({}).meta({ + id: "InsightsPregenerateRequest", + 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.", +}); + +export const insightsPregenerateResponse = z + .object({ + queued: z + .boolean() + .describe("True when the full warm was accepted and enqueued."), + locale: z + .enum(["de", "en"]) + .describe("The locale the assessments are being warmed in."), + }) + .meta({ + id: "InsightsPregenerateResponse", + description: + "Acknowledgement that a full assessment warm was enqueued for the calling user. The generation runs out of band; the text lands in the read-only status routes.", + }); + +// v1.7.0 — unified dashboard first-paint snapshot. One GET that +// assembles every above-the-fold tile field in a single round-trip. +// Two-phase shape: `tiles` (fast, always present) + `extras` (thick, +// nullable on a rollup-coverage miss). The nested AI / DataSummary +// blocks are typed loosely (`z.record`) to match the comprehensive +// response style above — the strict shapes live in their own Zod +// modules and the iOS client does not consume this web-only route. +export const dataSummaryRecord = z.record(z.string(), z.unknown()); + +// v1.17.0 — server-authoritative glucose clinical panel. Mirrors +// `GlucoseClinicalMetrics` from `@/lib/analytics/glucose-metrics`: the +// trailing-30-day TIR / GMI / eA1C / CV% headline plus the advanced +// J-index + LBGI/HBGI tier, gated by a `stillLearning` flag so a thin +// spot-data window is never asserted as a clinical AGP. The iOS client +// renders these numbers verbatim and never re-derives them. +export const glucoseClinicalSchema = z + .object({ + stillLearning: z.boolean(), + stillLearningReason: z.string().nullable(), + windowDays: z.number().int(), + actualSpanDays: z.number(), + readingCount: z.number().int(), + meanMgdl: z.number().nullable(), + distribution: z + .object({ + tir: z.number(), + tbrLevel1: z.number(), + tbrLevel2: z.number(), + tarLevel1: z.number(), + tarLevel2: z.number(), + minutesEquivalent: z.object({ + tir: z.number(), + tbrLevel1: z.number(), + tbrLevel2: z.number(), + tarLevel1: z.number(), + tarLevel2: z.number(), + }), + }) + .nullable(), + gmi: z.number().nullable(), + estimatedA1c: z.number().nullable(), + variability: z + .object({ + sd: z.number(), + cv: z.number(), + unstable: z.boolean(), + }) + .nullable(), + advanced: z + .object({ + jIndex: z.number().nullable(), + lbgi: z.number(), + hbgi: z.number(), + }) + .nullable(), + isSpotEstimate: z.boolean(), + }) + .meta({ + id: "GlucoseClinicalMetrics", + description: + "Server-authoritative glucose clinical panel over the trailing 30-day window. Figures from sparse spot data are a SPOT-READING ESTIMATE (a % of readings), not a CGM time-in-range AGP; `isSpotEstimate` is derived from reading density (true below ~hourly, false for a continuous CGM stream such as Nightscout) and `stillLearning` gates assertion when the window has too few readings or too short a span. `distribution` carries the Battelino 2019 TIR/TBR/TAR fractions (level-2 nested in level-1) plus minutes-of-a-day equivalents; `gmi` (Bergenstal 2018) + `estimatedA1c` (Nathan 2008 ADAG) derive from the mean; `variability` is SD + CV% with the Monnier 2017 ≥36% instability flag; `advanced` is the disclosure tier — J-index (Wojcicki 1995) + LBGI/HBGI (Kovatchev hypo/hyper risk). All blocks are null when there are no readings; `advanced.jIndex` is null for a single-reading window.", + }); + +export const dashboardSnapshotResponse = z + .object({ + user: z.object({ + username: z.string(), + timezone: z.string(), + heightCm: z.number().nullable(), + dateOfBirth: z.string().nullable(), + gender: z.enum(["MALE", "FEMALE"]).nullable(), + glucoseUnit: z.string().nullable(), + onboardingTourCompleted: z.boolean(), + greetingHour: z.number().int(), + }), + layout: z.record(z.string(), z.unknown()), + // v1.7.0 — full 27-id widget catalogue (16 server-known + 11 + // iOS-only) so a cold-launch first-paint seeds every tile and the + // layout round-trips in one key. Additive alongside the web + // `layout` block, which stays byte-identical. + layoutCatalogue: z + .array( + z.object({ + id: z.string(), + visible: z.boolean(), + order: z.number().int(), + }), + ) + .describe( + "Full 27-id widget catalogue (server-known + iOS-only) with per-widget visibility + order. iOS-only ids are appended default-invisible. The web dashboard reads `layout`; this block is the cold-launch seed for the native client.", + ), + // v1.7.0 — per-chartable-metric latest reading keyed by iOS + // `MetricKind` raw value (e.g. `oxygenSaturation`, + // `heartRateVariability`, `bodyMassIndex`). Derived in-process from + // the slim summaries slice — no extra DB read. + metricStates: z + .record( + z.string(), + z.object({ + value: z.number(), + measuredAt: z.string(), + unit: z.string(), + }), + ) + .describe( + "Latest reading per chartable metric, keyed by the iOS `MetricKind` raw value (the non-obvious raws: `oxygenSaturation`, `totalBodyWater`, `heartRateVariability`, `bodyMassIndex`, `walkingAsymmetryPercentage`, `walkingDoubleSupportPercentage`, `environmentalAudioExposure`, `headphoneAudioExposure`, `activeEnergyBurned`). Each entry carries `value`, `measuredAt` (ISO8601), and the canonical `unit`. Types the user has never logged are omitted.", + ), + tiles: z.object({ + summaries: dataSummaryRecord, + lastSeenByType: z.record(z.string(), z.unknown()), + mood: z.object({ + summary: dataSummaryRecord.nullable(), + entries: z.array( + z.object({ + date: z.string(), + score: z.number(), + samples: z.number().int(), + }), + ), + }), + }), + extras: z + .object({ + bpInTargetPct: z.number().nullable(), + bpInTargetPct7d: z.number().nullable(), + bpInTargetPct30d: z.number().nullable(), + bpInTargetPctAllTime: z.number().nullable(), + bpInTargetPctPriorMonth: z.number().nullable(), + bpInTargetPctPriorYear: z.number().nullable(), + bpInTargetCount90: z.number().int().nullable(), + bpInTargetSpanDays90: z.number().int().nullable(), + glucoseByContext: dataSummaryRecord, + glucoseClinical: glucoseClinicalSchema, + }) + .nullable(), + // Dashboard hero — today's medication block (fast phase, always + // present). Projection-backed tally + earliest next-due across + // active medications. + medsToday: z + .object({ + activeCount: z.number().int(), + scheduledToday: z.number().int(), + takenToday: z.number().int(), + skippedToday: z.number().int(), + nextDueAt: z.string().nullable(), + nextDueOverdue: z.boolean(), + nextDueMedicationName: z.string().nullable(), + }) + .describe( + "Today's medication block: active-medication count, today-window tally (scheduled / taken / skipped), and the earliest next-due slot across active medications. `nextDueOverdue: true` marks an OPEN overdue slot (anchor passed, still inside its catch-up band). Staleness contract: the snapshot is cache-served, so a `nextDueAt` in the past with `nextDueOverdue: false` means the anchor passed after the snapshot was built — render the plain day summary, never an overdue state.", + ), + // Dashboard hero — health score (warm phase, nullable on a + // rollup-coverage miss). Score + band + delta only; the per-pillar + // component breakdown stays on the analytics route. + healthScore: z + .object({ + score: z.number().int(), + band: z.enum(["green", "yellow", "red"]), + delta: z.number().nullable(), + }) + .nullable() + .describe( + "Personal health score summary (0..100 score, traffic-light band, week-over-week delta). Null on a rollup-coverage miss (it rides the thick phase alongside `extras`) and when no pillar is computable. Component breakdown is deliberately not serialised here.", + ), + briefing: z.record(z.string(), z.unknown()).nullable(), + briefingState: z.enum(["ready", "preparing", "disabled", "no-provider"]), + briefingUpdatedAt: z.string().nullable(), + briefingStale: z + .boolean() + .describe( + "True when `briefing` carries the last good (expired-TTL) briefing while a refresh is pending (`preparing`) or impossible (`no-provider`). Render the stale content with its `briefingUpdatedAt` timestamp instead of a blank tile.", + ), + generatedAt: z.string(), + }) + .meta({ + id: "DashboardSnapshotResponse", + 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.", + }); From e9d90a689fb11418f82544160bda5786a5aba3dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 01:12:35 +0200 Subject: [PATCH 3/6] refactor(validations): split medication schemas into focused modules behind a barrel --- src/lib/validations/medication.ts | 1145 ----------------- src/lib/validations/medication/base.ts | 226 ++++ .../validations/medication/create-update.ts | 338 +++++ src/lib/validations/medication/index.ts | 16 + src/lib/validations/medication/intake.ts | 190 +++ src/lib/validations/medication/inventory.ts | 193 +++ src/lib/validations/medication/schedule.ts | 220 ++++ 7 files changed, 1183 insertions(+), 1145 deletions(-) delete mode 100644 src/lib/validations/medication.ts create mode 100644 src/lib/validations/medication/base.ts create mode 100644 src/lib/validations/medication/create-update.ts create mode 100644 src/lib/validations/medication/index.ts create mode 100644 src/lib/validations/medication/intake.ts create mode 100644 src/lib/validations/medication/inventory.ts create mode 100644 src/lib/validations/medication/schedule.ts diff --git a/src/lib/validations/medication.ts b/src/lib/validations/medication.ts deleted file mode 100644 index 810717fd4..000000000 --- a/src/lib/validations/medication.ts +++ /dev/null @@ -1,1145 +0,0 @@ -import { z } from "zod/v4"; - -import { SCHEDULE_TYPES } from "@/lib/medications/scheduling/recurrence"; -import { INJECTION_SITE_KEYS } from "@/lib/medications/injection-sites"; -import { validateEntryInstant } from "@/lib/validations/entry-instant"; - -/** - * v1.8.5 — the eight injection-site enum values, mirrored from the - * Prisma `InjectionSite` enum via the shared `INJECTION_SITE_KEYS` - * tuple. Reused by the intake + bulk-intake request schemas (the - * `injectionSite` field) and the per-medication allowed-sites editor. - */ -export const INJECTION_SITE_VALUES = INJECTION_SITE_KEYS; -export type InjectionSiteValue = (typeof INJECTION_SITE_VALUES)[number]; - -/** Zod enum over the eight injection sites. */ -export const injectionSiteEnum = z.enum(INJECTION_SITE_VALUES); - -const timeRegex = /^([01]\d|2[0-3]):([0-5]\d)$/; - -/** - * v1.9.0 — drug-classification code formats. - * - * `ATC_CODE_REGEX` — the 7-character WHO ATC code (one letter, two - * digits, two letters, two digits, e.g. `A10BX10`). This is the - * full leaf-level substance class; the shorter anatomical-group - * prefixes (`A`, `A10`, `A10B`) are deliberately NOT accepted — the - * exporter emits a substance-class coding, not a group. - * - * `RXCUI_REGEX` — the RxNorm RxCUI is a bare positive integer string - * (e.g. `2601723`). - * - * Both fields are user/clinician-asserted and never machine-guessed; - * a malformed value is rejected with 422 rather than silently stored. - */ -const ATC_CODE_REGEX = /^[A-Z]\d{2}[A-Z]{2}\d{2}$/; -const RXCUI_REGEX = /^\d+$/; - -/** - * v1.9.0 — reusable nullable/optional code-field validators shared by - * the create + update medication schemas. `null` clears the column; - * `undefined` leaves it untouched on update. - */ -const atcCodeField = z - .string() - .regex(ATC_CODE_REGEX, "Invalid ATC code (expected e.g. A10BX10)") - .nullable() - .optional() - .describe( - "Optional WHO ATC classification code (active-substance class, 7 chars, e.g. `A10BX10`). User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under `http://www.whocc.no/atc`. NULL clears it; absent leaves it untouched.", - ); -const rxNormCodeField = z - .string() - .regex(RXCUI_REGEX, "Invalid RxNorm code (expected a numeric RxCUI)") - // Defence-in-depth: the `^\d+$` regex is otherwise unbounded. Real - // RxCUIs are at most 7 digits today; 20 is a generous ceiling that - // still bounds the column write tightly. (`atcCode` needs no max — - // its regex already fixes the length at 7.) - .max(20, "RxNorm code is too long") - .nullable() - .optional() - .describe( - "Optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary coding emitted under `http://www.nlm.nih.gov/research/umls/rxnorm` alongside any ATC code, never instead of the free-text name. NULL clears it; absent leaves it untouched.", - ); -/** - * Clinical-category values stored in the `medication_categories` side- - * table (TEXT column). v1.5.4 adds `DIABETES` and `ANTIBIOTIC` so the - * wizard's Step 2 taxonomy can write a first-class bucket for those - * two rows instead of collapsing them into `OTHER`. The column is a - * plain TEXT field — no Prisma enum exists to migrate; the Zod values - * list is the only enforcement layer. - */ -export const MEDICATION_CATEGORY_VALUES = [ - "BLOOD_PRESSURE", - "VITAMIN", - "SUPPLEMENT", - "PAIN_RELIEF", - "ALLERGY", - "DIGESTIVE", - "THYROID", - "HORMONE", - "SKIN", - "SLEEP_AID", - "DIABETES", - "ANTIBIOTIC", - "OTHER", -] as const; -export type MedicationCategoryValue = - (typeof MEDICATION_CATEGORY_VALUES)[number]; - -/** - * v1.4.25 W4d — Prisma-level treatment class. Orthogonal to - * `MEDICATION_CATEGORY_VALUES` (the clinical taxonomy that lives in the - * `medication_categories` side-table). GLP1 turns on the GLP-1 - * specialist surfaces — injection-site picker, titration history, pen - * inventory, GLP-1-aware Coach replies. Future treatment classes drop - * into this list (INSULIN, BIOLOGIC, FERTILITY, …). - */ -export const MEDICATION_TREATMENT_CLASS_VALUES = ["GENERIC", "GLP1"] as const; -export type MedicationTreatmentClass = - (typeof MEDICATION_TREATMENT_CLASS_VALUES)[number]; - -/** - * v1.16.12 (#316) — fractional dosing. A dose may consume a sub-unit - * fraction of a tablet (split pills). The UI offers a CURATED set of - * common fractions (¼ ⅓ ½ ⅔ ¾), stored as their decimal value; thirds - * are inexact in decimal (⅓ ≈ 0.3333, ⅔ ≈ 0.6667) — the @db.Decimal(10,4) - * column and the runway floor absorb the sub-0.0001 drift. Whole numbers - * 1..100 (multi-tablet doses, the pre-v1.16.12 contract) stay valid - * alongside the fractions. One source of truth — the UI selector and the - * tests import this set so the allowed values can never drift from the - * validator. - */ -export const UNITS_PER_DOSE_FRACTIONS = [ - 0.25, 0.3333, 0.5, 0.6667, 0.75, -] as const; -export const UNITS_PER_DOSE_MAX_WHOLE = 100; - -export function isSupportedUnitsPerDose(value: number): boolean { - if ((UNITS_PER_DOSE_FRACTIONS as readonly number[]).includes(value)) { - return true; - } - return ( - Number.isInteger(value) && value >= 1 && value <= UNITS_PER_DOSE_MAX_WHOLE - ); -} - -const UNITS_PER_DOSE_MESSAGE = - "unitsPerDose must be a whole number 1–100 or a supported fraction (¼, ⅓, ½, ⅔, ¾)"; - -/** - * v1.6.0 — route of administration. Decoupled from `treatmentClass`: - * the injection-site picker surfaces for any `INJECTION` dose, and a - * one-time injection is `oneShot: true` + `deliveryForm: "INJECTION"`. - * ORAL is the default the migration backfilled onto every existing row. - */ -export const MEDICATION_DELIVERY_FORM_VALUES = [ - "ORAL", - "INJECTION", - "OTHER", -] as const; -export type MedicationDeliveryForm = - (typeof MEDICATION_DELIVERY_FORM_VALUES)[number]; - -/** - * v1.5 — RRULE string shape check. - * - * Not a full RFC 5545 parser (that lives in the `rrule` npm package on - * the server side). The regex catches the subset HealthLog mints from - * the wizard / form: a `FREQ=` line with optional `INTERVAL=`, - * `BYDAY=`, `BYMONTHDAY=`, `BYMONTH=`, `COUNT=`, `UNTIL=` properties - * separated by `;`. Any pathological-looking shape (the `FREQ=SECONDLY` - * DoS surface, `;COUNT=999999999`) is filtered at the server route - * layer with a hard cap on emitted occurrences. - */ -const RRULE_PROPS = - /^FREQ=(?:DAILY|WEEKLY|MONTHLY|YEARLY)(?:;(?:INTERVAL=\d{1,3}|BYDAY=(?:MO|TU|WE|TH|FR|SA|SU)(?:,(?:MO|TU|WE|TH|FR|SA|SU))*|BYMONTHDAY=-?\d{1,2}(?:,-?\d{1,2})*|BYMONTH=\d{1,2}(?:,\d{1,2})*|COUNT=\d{1,4}|UNTIL=\d{8}T\d{6}Z))*$/; - -/** - * v1.15.18 — one explicit per-dose on-time window. `timeOfDay` keys the dose - * the window applies to; `start`/`end` are the HH:mm on-time bounds in the - * user's wall clock. `start <= end` within the day (an overnight window is not - * a configurable on-time band — the late tail owns the cross-midnight tail). - */ -export const doseWindowEntrySchema = z - .object({ - timeOfDay: z - .string() - .regex(timeRegex, "Format: HH:mm") - .describe( - "The dose time this window applies to (matches a `timesOfDay` entry).", - ), - start: z - .string() - .regex(timeRegex, "Format: HH:mm") - .describe("On-time band lower bound (HH:mm, user local)."), - end: z - .string() - .regex(timeRegex, "Format: HH:mm") - .describe( - "On-time band upper bound (HH:mm, user local). Must be >= `start`.", - ), - }) - .refine((w) => hhmmToMinutes(w.start) <= hhmmToMinutes(w.end), { - message: "Window start must be on or before end (same day)", - path: ["end"], - }) - .meta({ - id: "DoseWindowEntry", - description: - "One explicit per-dose on-time intake window. `timeOfDay` matches a schedule dose time; `[start, end]` (HH:mm, user local, `start <= end`) is the on-time band. Outside it the cadence-derived late tail applies, then ad-hoc.", - }); - -/** Minutes-since-midnight for an `HH:mm` literal (already regex-validated). */ -function hhmmToMinutes(hhmm: string): number { - const [h, m] = hhmm.split(":"); - return Number(h) * 60 + Number(m); -} - -/** - * v1.15.19 introduced `takenAt` plausibility bounds on the EDIT path - * (audit P0-4); v1.16.9 lifts them onto every CREATE path too (the - * per-medication intake POST, the bulk endpoint, the external ingest) so - * the retro-add dialog and a hand-rolled API call can't insert a row a - * decade in the past or hours in the future. Future allowance covers - * client clock skew; the 5-year floor matches the GLP-1 dose-change - * validator's window. Slot-distance / medication-start checks stay in - * the routes — the schema cannot see the medication. - */ -export const TAKEN_AT_MAX_AGE_MS = 5 * 365 * 24 * 60 * 60 * 1000; - -/** - * ISO `takenAt` → `Date` with the plausibility bounds applied. - * - * v1.17 W1b — delegates to the shared `validateEntryInstant` helper (the - * same bound now guarding `measuredAt` + `moodLoggedAt`), passing the - * five-year window so a stale backdated dose is rejected well before the - * 1900 floor. The 5-min clock-skew tolerance is the shared default. - */ -export const boundedTakenAtSchema = validateEntryInstant( - z.iso.datetime({ offset: true }).transform((s) => new Date(s)), - { - maxAgeMs: TAKEN_AT_MAX_AGE_MS, - // Preserve the established wording so the iOS contract + tests stay stable. - pastMessage: "takenAt must be within the last 5 years", - }, -); - -export const scheduleSchema = z - .object({ - windowStart: z - .string() - .regex(timeRegex, "Format: HH:mm") - .describe( - "Legacy single-time-of-intake (HH:mm, user local). v1.5 keeps the field for backwards compatibility with pre-wizard iOS clients; the new `timesOfDay` array supersedes it.", - ), - windowEnd: z - .string() - .regex(timeRegex, "Format: HH:mm") - .describe( - "Legacy reminder-window upper bound (HH:mm). Used to derive the late-classification grace span when `reminderGraceMinutes` is null.", - ), - label: z - .string() - .max(50) - .optional() - .describe('Optional human label (e.g. "Morning", "Evening").'), - dose: z - .string() - .max(50) - .optional() - .describe( - "Per-schedule dose override. NULL means the schedule inherits `Medication.dose`.", - ), - daysOfWeek: z - .array(z.number().int().min(0).max(6)) - .optional() - .describe( - "Legacy day-of-week filter (0=Sunday..6=Saturday). v1.5 reads new writes through `rrule` first; this field is preserved for pre-v1.5 rows and is the input the route serialises into the persisted `days_of_week` string.", - ), - intervalWeeks: z - .number() - .int() - .min(1) - .max(4) - .optional() - .describe( - "Legacy multi-week stride (1..4). Bi-weekly + tri-weekly were broken in the pre-v1.5 reminder worker; new writes encode the same intent via `rrule` (e.g. `FREQ=WEEKLY;INTERVAL=2;BYDAY=WE`).", - ), - /** - * v1.5 — first-class times-of-day. One or more HH:mm entries in - * the user's wall-clock; the engine applies them per matched day. - * Empty array falls back to `[windowStart]` for backwards-compat. - */ - timesOfDay: z - .array(z.string().regex(timeRegex, "Format: HH:mm")) - .max(8) - .optional() - .describe( - "v1.5 first-class points-in-time the dose is taken (HH:mm, user local). Up to 8 entries. Absent or empty means the route stamps `[windowStart]` so the new engine always sees a populated array.", - ), - /** - * v1.5 — reminder grace window (minutes). Replaces the implicit - * `windowEnd - windowStart` span for late-classification. NULL - * falls back to the legacy span. Capped at 24 hours. - */ - reminderGraceMinutes: z - .number() - .int() - .min(1) - .max(24 * 60) - .optional() - .describe( - "Reminder grace window in minutes. Caps at 24h. NULL falls back to the legacy `windowEnd - windowStart` span.", - ), - /** - * v1.5 — RFC 5545 RRULE string for calendar-anchored cadences. - * Mutually exclusive with `rollingIntervalDays`. - */ - rrule: z - .string() - .max(200) - .regex(RRULE_PROPS, "Invalid RRULE") - .optional() - .describe( - "RFC 5545 RRULE string (subset). Use for daily / weekly-with-BYDAY / multi-week / monthly / yearly cadences. Mutually exclusive with `rollingIntervalDays`. Examples: `FREQ=DAILY`, `FREQ=WEEKLY;BYDAY=MO,WE,FR`, `FREQ=WEEKLY;INTERVAL=2;BYDAY=WE`, `FREQ=MONTHLY;BYMONTHDAY=1`, `FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1`.", - ), - /** - * v1.5 — flexible-rolling interval in days, counted from the - * latest MedicationIntakeEvent.takenAt. Mutually exclusive with - * `rrule`. Range 1..365 days. - */ - rollingIntervalDays: z - .number() - .int() - .min(1) - .max(365) - .optional() - .describe( - "Flexible-rolling interval in days, counted forward from the latest `MedicationIntakeEvent.takenAt` (the dose re-anchors when logged). Mutually exclusive with `rrule`. Range 1..365.", - ), - /** - * v1.7.0 — schedule-type discriminator. SCHEDULED (default) keeps the - * rrule / rolling / legacy cadence. PRN is as-needed (never projected, - * reminded, or counted in compliance expected; still loggable). - * CYCLIC wraps the inner cadence with an N-on / M-off week phase. - */ - scheduleType: z - .enum(SCHEDULE_TYPES) - .optional() - .describe( - "Schedule type. SCHEDULED (default) = rrule / rolling / legacy cadence. PRN = as-needed (never projected, reminded, or counted in compliance expected; still loggable via the intake route). CYCLIC = N weeks on / M weeks off, gating whichever inner cadence the rrule / legacy fields describe.", - ), - /** v1.7.0 — cyclic "on" weeks. Required when `scheduleType === "CYCLIC"`. */ - cyclicOnWeeks: z - .number() - .int() - .min(1) - .max(52) - .optional() - .describe( - 'Cyclic "on" weeks (1..52). Required when `scheduleType` is CYCLIC; ignored otherwise.', - ), - /** v1.7.0 — cyclic "off" weeks. Required when `scheduleType === "CYCLIC"`. */ - cyclicOffWeeks: z - .number() - .int() - .min(0) - .max(52) - .optional() - .describe( - 'Cyclic "off" weeks (0..52). Required when `scheduleType` is CYCLIC; ignored otherwise.', - ), - /** - * v1.15.18 — per-dose configurable on-time intake window (the maintainer's - * "07:00–09:00" lever). One entry per dose time the user wants an explicit - * range for; a `timeOfDay` with no entry keeps the symmetric ±1h default. - * Each `timeOfDay` MUST match one of the schedule's `timesOfDay` (or the - * legacy `windowStart`), and `start <= end` within the day. Absent → every - * slot uses the default derivation (unchanged behaviour). - */ - doseWindows: z - .array(doseWindowEntrySchema) - .max(8) - .optional() - .describe( - "Per-dose on-time intake windows. Each `{ timeOfDay, start, end }` HH:mm triple sets the explicit on-time band for the matching dose time; a dose time with no entry keeps the symmetric ±1h default. `timeOfDay` must match one of `timesOfDay` (or `windowStart`); `start <= end`. Up to 8 entries. Absent leaves every slot on the default derivation. The late tail stays cadence-derived.", - ), - }) - .refine((s) => !(s.rrule && s.rollingIntervalDays), { - message: "A schedule can be calendar-anchored (rrule) or rolling, not both", - path: ["rrule"], - }) - .refine( - (s) => - s.scheduleType !== "CYCLIC" || - (s.cyclicOnWeeks !== undefined && s.cyclicOffWeeks !== undefined), - { - message: "cyclic schedules require both cyclicOnWeeks and cyclicOffWeeks", - path: ["cyclicOnWeeks"], - }, - ) - .refine( - (s) => - s.scheduleType !== "PRN" || - (s.rrule === undefined && s.rollingIntervalDays === undefined), - { - message: - "PRN schedules cannot carry a cadence (rrule or rollingIntervalDays)", - path: ["scheduleType"], - }, - ) - .refine( - (s) => - s.rollingIntervalDays === undefined || - s.rollingIntervalDays === null || - !s.timesOfDay || - s.timesOfDay.length <= 1, - { - message: "rolling-cadence schedules accept at most one time of day", - path: ["timesOfDay"], - }, - ) - .refine( - (s) => { - // Every per-dose window must name a real dose time. The effective dose - // times are `timesOfDay` when set, else the single legacy `windowStart` - // (mirrors the engine's `effectiveTimesOfDay`). - if (!s.doseWindows || s.doseWindows.length === 0) return true; - const times = new Set( - s.timesOfDay && s.timesOfDay.length > 0 - ? s.timesOfDay - : [s.windowStart], - ); - return s.doseWindows.every((w) => times.has(w.timeOfDay)); - }, - { - message: - "Each doseWindows.timeOfDay must match one of the schedule's timesOfDay", - path: ["doseWindows"], - }, - ) - .refine( - (s) => { - // A dose time may carry at most one explicit window. - if (!s.doseWindows || s.doseWindows.length === 0) return true; - const seen = new Set(); - for (const w of s.doseWindows) { - if (seen.has(w.timeOfDay)) return false; - seen.add(w.timeOfDay); - } - return true; - }, - { - message: "doseWindows must not repeat a timeOfDay", - path: ["doseWindows"], - }, - ) - .meta({ - id: "MedicationScheduleInput", - description: - "Single schedule entry on a medication. v1.5 introduces `timesOfDay`, `rrule`, `rollingIntervalDays`, and `reminderGraceMinutes` as first-class fields; `windowStart`, `windowEnd`, `daysOfWeek`, and `intervalWeeks` are preserved through the v1.5.x line for backwards compatibility. **`rrule` and `rollingIntervalDays` are mutually exclusive** — supplying both fails 422 (`rrule_xor_rolling`). The DB enforces the same invariant via a CHECK constraint.", - }); - -/** - * v1.5 — medication-level course window + one-shot flag. The fields - * are optional on the create path so the existing form (no wizard - * yet) keeps working. The route layer enforces the `oneShot` + at- - * least-one-schedule invariant for the wizard path. - */ -const courseWindowFields = { - /** - * Date the medication course begins. ISO YYYY-MM-DD. NULL = "from - * creation" (the legacy implicit anchor). - */ - startsOn: z.iso - .date() - .transform((s) => new Date(s)) - .nullable() - .optional() - .describe( - "Date the medication course begins (ISO `YYYY-MM-DD`). Anchors RRULE BYDAY / BYMONTHDAY patterns and the rolling-interval countdown's first window. NULL = active from creation (the legacy implicit behaviour). Required when `oneShot` is true.", - ), - /** - * Date the medication course ends. ISO YYYY-MM-DD. NULL = chronic. - * Required to equal `startsOn` when `oneShot` is true (the route - * normalises this; the schema doesn't because they're sister - * fields and the cross-field refine is enforced at the route). - */ - endsOn: z.iso - .date() - .transform((s) => new Date(s)) - .nullable() - .optional() - .describe( - "Date the medication course ends (ISO `YYYY-MM-DD`). NULL = no end date (chronic). When `oneShot` is true the route normalises `endsOn` to equal `startsOn`.", - ), - /** - * Single-administration medication. Auto-deactivates after the - * single intake is logged. - */ - oneShot: z - .boolean() - .optional() - .describe( - "Single-administration medication (e.g. flu shot, post-op single dose). When true the medication has at most one schedule with no `rrule` / `rollingIntervalDays`, and `active` auto-flips to false once the dose is logged (non-skipped).", - ), - /** - * v1.16.11 (#316) — as-needed (PRN) medication. No fixed schedule: - * never due, never reminded, excluded from compliance, active - * indefinitely. Intakes log ad-hoc and consume inventory. Carries - * ZERO schedules — a `schedules` array alongside `asNeeded: true` is - * a 422. Mutually exclusive with `oneShot`. - */ - asNeeded: z - .boolean() - .optional() - .describe( - "As-needed (PRN) medication (pain relief, rescue inhaler). When true the medication carries NO schedules (supplying any schedule entry is a 422): it is never due, never reminded, and excluded from compliance rates/streaks, but intakes still log (ad-hoc), inventory still consumes, and the history renders. Stays active indefinitely. Mutually exclusive with `oneShot`.", - ), -}; - -/** - * v1.16.11 — shared as-needed cross-field refines for the create + - * update bodies: `asNeeded` and `oneShot` are mutually exclusive, and - * an as-needed medication cannot carry schedule entries. - */ -const AS_NEEDED_ONE_SHOT_MESSAGE = - "asNeeded and oneShot are mutually exclusive"; -const AS_NEEDED_SCHEDULES_MESSAGE = - "An as-needed medication cannot carry schedules"; - -export const createMedicationSchema = z - .object({ - name: z.string().min(1).max(100), - dose: z.string().min(1).max(50), - category: z.enum(MEDICATION_CATEGORY_VALUES).optional(), - /** v1.4.25 W4d — treatment-class discriminator (GENERIC | GLP1). */ - treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(), - /** v1.4.25 W4d — doses per pen/vial for inventory tracking. - * v1.16.10 raises the cap to 1000 (large tablet packs). */ - dosesPerUnit: z.number().int().min(1).max(1000).optional(), - /** - * v1.16.10 — inventory units one dose consumes (2 × 2 mg tablets - * for a 4 mg dose). Default 1. Dose-level readouts divide the - * unit counts by this factor. - */ - unitsPerDose: z - .number() - .refine(isSupportedUnitsPerDose, { message: UNITS_PER_DOSE_MESSAGE }) - .optional() - .describe( - "Inventory units consumed per dose. A whole number 1–100 (e.g. 2 tablets of 2 mg for a 4 mg dose) or a supported fraction for a split pill (¼ / ⅓ / ½ / ⅔ / ¾). Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.", - ), - /** - * v1.17.0 — optional per-medication reorder lead time in days. The - * low-stock alert widens its trigger by this lead plus one - * dose-interval so a sparse cadence is warned before its last dose. - * Omitted = inherit the user-level - * `notificationPrefs.medication.reorderLeadDays` default. - */ - reorderLeadDays: z - .number() - .int() - .min(0) - .max(60) - .optional() - .describe( - "Per-medication reorder lead time in days (0–60). The low-stock alert fires the supply early enough to reorder before the last dose. Omitted = inherit the user-level reorderLeadDays default (10).", - ), - /** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */ - deliveryForm: z.enum(MEDICATION_DELIVERY_FORM_VALUES).optional(), - /** - * v1.8.5 — per-medication injection-site tracking opt-in. Default - * false. Only meaningful when `deliveryForm === "INJECTION"`. - */ - trackInjectionSites: z - .boolean() - .optional() - .describe( - "Per-medication injection-site tracking opt-in. Default false. Only meaningful for an INJECTION delivery form; when true the client prompts (skippably) for the site after a taken dose.", - ), - /** - * v1.8.5 — per-medication allowed / preferred injection sites. - * Empty = no per-medication restriction. The effective pickable set - * subtracts the user's global exclusion (deny wins). - */ - allowedInjectionSites: z - .array(injectionSiteEnum) - .max(INJECTION_SITE_VALUES.length) - .optional() - .describe( - "Per-medication allowed / preferred injection sites. Empty array = no per-medication restriction (every site offered). The effective pickable set is this list minus the user's global exclusion.", - ), - notificationsEnabled: z.boolean().optional(), - /** v1.7.0 — iOS Live Activity opt-in for this medication's reminders. */ - liveActivityEnabled: z - .boolean() - .optional() - .describe( - "iOS Live Activity opt-in for this medication's reminders. Default false. The iOS client owns the ActivityKit lifecycle; the server only stores + echoes the flag.", - ), - /** v1.7.0 — iOS 26 AlarmKit critical-reminder opt-in. */ - criticalAlarmEnabled: z - .boolean() - .optional() - .describe( - "iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute switch / Focus; the server stores the preference only and hangs no server-side behaviour off it.", - ), - /** v1.9.0 — optional WHO ATC classification code. */ - atcCode: atcCodeField, - /** v1.9.0 — optional RxNorm RxCUI (secondary, US). */ - rxNormCode: rxNormCodeField, - ...courseWindowFields, - /** - * v1.16.11 — optional at the type level so an `asNeeded: true` - * create can omit it; the refine below keeps the legacy - * at-least-one-schedule contract for every scheduled medication. - */ - schedules: z.array(scheduleSchema).optional(), - }) - .refine((b) => b.oneShot !== true || !!b.startsOn, { - message: "startsOn is required when oneShot is true", - path: ["startsOn"], - }) - .refine( - (b) => - !b.startsOn || !b.endsOn || b.endsOn.getTime() >= b.startsOn.getTime(), - { - message: "endsOn must be on or after startsOn", - path: ["endsOn"], - }, - ) - .refine((b) => !(b.asNeeded === true && b.oneShot === true), { - message: AS_NEEDED_ONE_SHOT_MESSAGE, - path: ["asNeeded"], - }) - .refine( - (b) => b.asNeeded === true || (!!b.schedules && b.schedules.length >= 1), - { - message: "Mindestens ein Zeitfenster", - path: ["schedules"], - }, - ) - .refine( - (b) => b.asNeeded !== true || !b.schedules || b.schedules.length === 0, - { - message: AS_NEEDED_SCHEDULES_MESSAGE, - path: ["schedules"], - }, - ) - .meta({ - id: "CreateMedicationRequest", - description: - 'Create-medication body. The route enforces the v1.5 cross-field invariants on top of the per-schedule `rrule_xor_rolling` Zod refine: a `oneShot:true` medication may carry at most one schedule and that schedule must not declare a recurrence; `endsOn` is normalised to equal `startsOn` for one-shot doses; a recurring schedule with no `rrule`, `rollingIntervalDays`, or legacy `daysOfWeek` defaults to `rrule = "FREQ=DAILY"`; and `timesOfDay` is dual-written from `windowStart` when the caller omits it. v1.16.11 — `asNeeded: true` creates a PRN medication with ZERO schedules (`schedules` must be absent or empty, 422 otherwise); a scheduled medication still requires at least one schedule entry.', - }); - -export const updateMedicationSchema = z - .object({ - name: z.string().min(1).max(100).optional(), - dose: z.string().min(1).max(50).optional(), - category: z.enum(MEDICATION_CATEGORY_VALUES).optional(), - treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(), - dosesPerUnit: z.number().int().min(1).max(1000).nullable().optional(), - /** v1.16.10 — inventory units one dose consumes. Default 1. - * v1.16.12 — whole number 1–100 or a curated fraction (½ / ⅓ / ¼ …). */ - unitsPerDose: z - .number() - .refine(isSupportedUnitsPerDose, { message: UNITS_PER_DOSE_MESSAGE }) - .optional() - .describe( - "Inventory units consumed per dose — a whole number 1–100 or a supported split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾). The intake consumption hook decrements this many units per taken dose; already-stamped intake events keep their recorded consumption.", - ), - /** - * v1.17.0 — per-medication reorder lead time in days (0–60). `null` - * clears the override and reverts to the user-level default. - */ - reorderLeadDays: z - .number() - .int() - .min(0) - .max(60) - .nullable() - .optional() - .describe( - "Per-medication reorder lead time in days (0–60). null clears the override and reverts to the user-level reorderLeadDays default.", - ), - /** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */ - deliveryForm: z.enum(MEDICATION_DELIVERY_FORM_VALUES).optional(), - /** v1.8.5 — per-medication injection-site tracking opt-in. */ - trackInjectionSites: z - .boolean() - .optional() - .describe( - "Per-medication injection-site tracking opt-in. Only meaningful for an INJECTION delivery form. Set false to deactivate tracking.", - ), - /** v1.8.5 — per-medication allowed / preferred injection sites. */ - allowedInjectionSites: z - .array(injectionSiteEnum) - .max(INJECTION_SITE_VALUES.length) - .optional() - .describe( - "Per-medication allowed / preferred injection sites. Empty array clears the restriction. The effective pickable set subtracts the user's global exclusion (deny wins).", - ), - active: z.boolean().optional(), - notificationsEnabled: z.boolean().optional(), - /** v1.7.0 — iOS Live Activity opt-in for this medication's reminders. */ - liveActivityEnabled: z - .boolean() - .optional() - .describe( - "iOS Live Activity opt-in for this medication's reminders. The iOS client owns the ActivityKit lifecycle; the server only stores + echoes the flag.", - ), - /** v1.7.0 — iOS 26 AlarmKit critical-reminder opt-in. */ - criticalAlarmEnabled: z - .boolean() - .optional() - .describe( - "iOS 26 AlarmKit critical-reminder opt-in. Critical alarms bypass the device mute switch / Focus; the server stores the preference only.", - ), - /** v1.9.0 — optional WHO ATC classification code. */ - atcCode: atcCodeField, - /** v1.9.0 — optional RxNorm RxCUI (secondary, US). */ - rxNormCode: rxNormCodeField, - ...courseWindowFields, - schedules: z.array(scheduleSchema).optional(), - /** - * v1.5.5 — top-level primary-schedule grace bridge. The detail - * page settings section saves the reminder-window in minutes for - * the medication's primary schedule without re-sending the full - * `schedules` array. The route normalises this value onto the - * primary schedule's `reminderGraceMinutes` before the Prisma - * update so the persisted shape stays per-schedule. NULL clears - * the override and falls back to the legacy `windowEnd - - * windowStart` span. - */ - reminderGraceMinutes: z - .number() - .int() - .min(0) - .max(24 * 60) - .nullable() - .optional() - .describe( - "Detail-page bridge: primary-schedule reminder-window in minutes. The route maps the value onto the primary schedule's `reminderGraceMinutes` field; ignored when a full `schedules` array is also supplied.", - ), - }) - .refine((b) => b.oneShot !== true || !!b.startsOn, { - message: "startsOn is required when oneShot is true", - path: ["startsOn"], - }) - .refine( - (b) => - !b.startsOn || !b.endsOn || b.endsOn.getTime() >= b.startsOn.getTime(), - { - message: "endsOn must be on or after startsOn", - path: ["endsOn"], - }, - ) - .refine((b) => !(b.asNeeded === true && b.oneShot === true), { - message: AS_NEEDED_ONE_SHOT_MESSAGE, - path: ["asNeeded"], - }) - .refine( - (b) => b.asNeeded !== true || !b.schedules || b.schedules.length === 0, - { - message: AS_NEEDED_SCHEDULES_MESSAGE, - path: ["schedules"], - }, - ) - .refine( - // An empty replace-list is only legal on the as-needed path — for a - // scheduled medication it would wipe every schedule and strand the - // row in a state no surface can reason about. - (b) => b.asNeeded === true || !b.schedules || b.schedules.length >= 1, - { - message: "Mindestens ein Zeitfenster", - path: ["schedules"], - }, - ) - .meta({ - id: "UpdateMedicationRequest", - description: - "Update-medication body. Every field is optional; omitted fields are left untouched. Supplying `schedules` REPLACES the medication's full schedule list (the route deletes existing rows before re-creating). Flipping `active` to false records the current timestamp on `pausedAt`; flipping back to true clears it. v1.5 invariants on the `schedules` array match the create path. v1.16.11 — `asNeeded: true` requires the medication to end schedule-less: supply `schedules: []` to clear an existing plan (any schedule entry is a 422); an empty `schedules` array without `asNeeded: true` is likewise a 422.", - }); - -export const intakeSchema = z - .object({ - medicationId: z - .string() - .min(1) - .describe( - "Server-narrowed from the URL path. The route layer overwrites whatever the body supplies before Zod parsing so a caller cannot log an intake against another medication.", - ), - scheduledFor: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .optional() - .describe( - "Slot the dose belongs to. Defaults to `takenAt` (or `now()` when both are absent) so the compliance pairing logic can pin the dose to a schedule slot.", - ), - takenAt: boundedTakenAtSchema - .optional() - .describe( - "When the dose was actually taken. NULL when `skipped` is true; defaults to `now()` for non-skipped intakes. Must not lie in the future (5-minute clock-skew allowance) nor more than 5 years in the past.", - ), - skipped: z - .boolean() - .optional() - .default(false) - .describe( - "True to log a skipped slot (no consumption, no inventory decrement, one-shot medications stay active).", - ), - idempotencyKey: z - .string() - .max(128) - .optional() - .describe( - "Caller-issued de-dup key. A second POST with the same key returns the original event without creating a new row.", - ), - /** - * v1.8.5 — optional injection-site capture. Only honoured on a - * non-skipped (taken) write for a medication with - * `deliveryForm === "INJECTION"` and `trackInjectionSites === true`. - * The site is validated server-side against the medication's - * effective allowed set (per-medication `allowedInjectionSites` - * minus the user's `globalExcludedInjectionSites` deny-list); a - * disallowed value is rejected with 422. Always optional — the - * client may omit it (the dose still records). - */ - injectionSite: injectionSiteEnum - .optional() - .describe( - "Optional injection site for a taken dose. Honoured only when the medication is an INJECTION with site-tracking enabled; validated against the medication's effective allowed set (per-medication allowed sites minus the user's global exclusion). A disallowed site returns 422. Omit to record the dose without a site.", - ), - /** - * v1.15.18 — late-take "attribute anyway" pin. An off-window take that - * band attribution would otherwise orphan to an ad-hoc row can be pinned - * onto a chosen scheduled slot via the UI's "diesem Slot zuordnen?" nudge. - * The instant MUST be a real slot of this medication on its day (the - * server validates it against the band anchors); an arbitrary instant is - * rejected with 422. Absent → default band attribution. - */ - forceSlotInstant: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .optional() - .describe( - "Late-take override: pin this taken dose onto the named scheduled slot instead of orphaning it to an ad-hoc row. Must be a real scheduled slot of this medication on its day (validated server-side against the dose-window band anchors); an instant that is not a slot returns 422. Absent applies the default window-band attribution.", - ), - /** - * v1.16.4 — per-intake dose override. Free text mirroring - * `Medication.dose` (max 50 chars). Persisted only on a taken - * (non-skipped) write. Absent = the configured medication dose - * applies; read paths fall back to it. - */ - doseTaken: z - .string() - .trim() - .min(1) - .max(50) - .optional() - .describe( - "Dose actually consumed for THIS intake when it deviates from (or documents) the medication's configured dose, e.g. a half tablet or a titration step. Free text, max 50 characters. Omit to record the take under the medication's configured dose.", - ), - }) - .meta({ - id: "MedicationIntakeRequest", - description: - "Per-medication intake log body. Idempotent via `idempotencyKey`; the server also dedupes by a 60-second sliding window when the key is absent. Non-skipped intakes auto-decrement pen inventory (best-effort), refresh the per-day compliance rollup, and — for one-shot medications — flip `active` to false. The optional `injectionSite` is persisted only for an INJECTION medication with site-tracking enabled and is validated against the medication's effective allowed set (422 on a disallowed value).", - }); - -export const externalIntakeSchema = z.object({ - medicationName: z.string().min(1).max(200), - // v1.16.9 — same plausibility bounds as the interactive create paths. - takenAt: boundedTakenAtSchema.optional(), - idempotencyKey: z.string().max(128), -}); - -export const listIntakeEventsSchema = z.object({ - limit: z.coerce.number().int().min(1).max(500).optional().default(25), - offset: z.coerce.number().int().min(0).optional().default(0), - sortBy: z - .enum(["scheduledFor", "takenAt", "source", "createdAt"]) - .optional() - .default("scheduledFor"), - sortDir: z.enum(["asc", "desc"]).optional().default("desc"), - /** - * v1.4.37 W3 — server-side status filter so the medication detail - * page (IntakeHistoryListV2) can hide unconfirmed / planned rows. - * - * - "all" (default): every event, preserves the byte-stable contract - * the iOS Swift client and existing dashboard consumers depend on. - * - "taken": only rows where the dose was confirmed taken - * (`takenAt IS NOT NULL AND skipped = false`). - * - "skipped": only rows the user explicitly skipped (`skipped = true`). - * - "completed": taken OR skipped — anything the user actually - * actioned. Excludes the ambiguous "missed / never confirmed" - * rows (`takenAt IS NULL AND skipped = false`) that the v1 list - * rendered as "verpasst" before the v1 component retired. - */ - status: z - .enum(["all", "taken", "skipped", "completed"]) - .optional() - .default("all"), -}); - -/** - * v1.15.19 — `takenAt` plausibility bounds on the edit path (audit P0-4). - * A date typo on an intake edit could park `takenAt` a month before its - * slot with no pushback anywhere. The schema rejects the physically - * implausible cases: a future instant (small skew allowance for client - * clocks) and anything older than the 5-year window the GLP-1 dose-change - * validator already established (`glp1DoseChangePostSchema`). Slot-distance - * checks stay out of the schema — it cannot see the medication — and live - * in the route (start-date guard) + the edit dialog (non-blocking hint). - */ -export const updateIntakeEventSchema = z - .object({ - takenAt: boundedTakenAtSchema.nullable().optional(), - skipped: z.boolean().optional(), - scheduledFor: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .optional(), - /** - * v1.15.18 — late-take "attribute anyway" pin on the edit path. When the - * edited `takenAt` lands outside every window the UI can offer to pin the - * take onto a chosen slot; the server validates the instant is a real - * scheduled slot (422 otherwise). Absent → the edit re-runs band - * attribution on the new `takenAt`. - * - * v1.15.20 — an explicit `null` UNPINS: the dose re-attributes by window - * band on its (unchanged or edited) `takenAt` and the binding provenance - * resets to AUTO — the "Zuordnung lösen" path. A pin / unpin no longer - * requires `takenAt` or `skipped` to change in the same request. - */ - forceSlotInstant: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .nullable() - .optional() - .describe( - "Late-take override on edit: pin the edited dose onto the named scheduled slot instead of re-attributing by window band. Must be a real scheduled slot of this medication on its day (validated server-side); a non-slot instant returns 422. Explicit null unpins: the dose re-attributes by window band on its takenAt (ad-hoc when no band matches). Absent re-runs the default window-band attribution on the edited `takenAt`.", - ), - }) - .meta({ - id: "UpdateMedicationIntakeEventRequest", - description: - "Edit a single intake event. v1.15.18 re-runs window-band slot attribution whenever `takenAt` or `skipped` change, snapping `scheduledFor` to the matched slot (or the take's own time when it falls in no window). `forceSlotInstant` overrides that to pin the take onto a named real slot (explicit null unpins, re-attributing by band); an explicit `scheduledFor` still wins when supplied directly. `takenAt` must not be in the future (5-minute clock-skew allowance) nor more than 5 years in the past; a `takenAt` before the medication's start date returns 422.", - }); - -/** - * v1.5.5 — bulk-delete request body. The detail-page intake-history - * preview surfaces a multi-select that posts the resulting eventIds - * here. The cap matches `listIntakeEventsSchema.limit` (500) so the - * client never selects more rows than the table can return at once. - * Server-side guarantees scoped-by-medication ownership via - * `assertMedicationOwnership` + a `userId` predicate on the - * `deleteMany`. - */ -export const bulkDeleteIntakeEventsSchema = z.object({ - eventIds: z.array(z.string().min(1).max(64)).min(1).max(500), -}); - -export type BulkDeleteIntakeEventsInput = z.infer< - typeof bulkDeleteIntakeEventsSchema ->; - -/** - * v1.4.25 W19b — inventory (pen / vial) CRUD validators. - * - * The Prisma model carries a 4-state enum - * (ACTIVE | IN_USE | EXPIRED | USED_UP); the API surface only lets - * the user explicitly transition into IN_USE (mark-as-first-use) or - * USED_UP (manual override). EXPIRED is owned by the daily cron and - * the intake hook, so the PATCH schema deliberately omits it. - */ -/** - * v1.16.10 — container kinds, mirrored from the Prisma - * `MedicationContainerType` enum. Display-level classification only. - */ -export const MEDICATION_CONTAINER_TYPE_VALUES = [ - "PEN", - "AMPOULE", - "BLISTER", - "INHALER", - "BOTTLE", - "OTHER", -] as const; -export type MedicationContainerTypeValue = - (typeof MEDICATION_CONTAINER_TYPE_VALUES)[number]; - -export const createInventoryItemSchema = z - .object({ - /** Units the container ships with. v1.16.10 raises the cap from - * 100 to 1000 (large tablet packs) and renames the wire field to - * `unitsTotal` — it counts units, mapped to doses via - * `Medication.unitsPerDose`. */ - unitsTotal: z - .number() - .min(1) - .max(1000) - .describe( - "Units the container ships with (tablets / ampoules / puffs; 1–1000, fractional allowed for split-pill packs). Dose-derived readouts divide by the medication's `unitsPerDose`.", - ), - /** v1.16.10 — container kind. Defaults to OTHER when absent. */ - containerType: z - .enum(MEDICATION_CONTAINER_TYPE_VALUES) - .optional() - .describe( - "Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level only; defaults to OTHER.", - ), - printedExpiry: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .nullable() - .optional(), - purchasedAt: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .nullable() - .optional(), - notes: z.string().max(200).nullable().optional(), - }) - .meta({ - id: "CreateMedicationInventoryItemRequest", - description: - "Register a new supply container (pen / blister pack / bottle). `unitsTotal` counts UNITS (1–1000); the item starts ACTIVE with `unitsRemaining = unitsTotal` and the intake consumption hook decrements it per taken dose.", - }); - -export const updateInventoryItemSchema = z - .object({ - markAsFirstUseAt: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .optional() - .describe( - "Manually start the 30-day in-use clock (the user opened the container without logging an intake). ACTIVE flips to IN_USE; a backdated instant whose window already lapsed lands EXPIRED.", - ), - markAsUsedUp: z - .boolean() - .optional() - .describe( - "Terminal override: zero the remaining units and mark the container USED_UP (physically discarded).", - ), - printedExpiry: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .nullable() - .optional(), - /** - * v1.16.1 — stock correction (the Bestand tab's adjust / withdraw - * flow). Sets the remaining-unit count directly; the route clamps to - * `unitsTotal` and re-runs the canonical state machine (0 ⇒ USED_UP, - * a raise out of 0 re-evaluates against the expiry clocks). - * v1.16.10 raises the cap to 1000 alongside the capacity cap and - * renames the wire field to `unitsRemaining` (it always counted - * units), matching the response side. - */ - unitsRemaining: z - .number() - .min(0) - .max(1000) - .optional() - .describe( - "Absolute remaining-unit correction (0–1000, fractional allowed). Clamped server-side to the item's `unitsTotal`; the canonical state machine re-derives the state (0 ⇒ USED_UP).", - ), - notes: z.string().max(200).nullable().optional(), - }) - .meta({ - id: "UpdateMedicationInventoryItemRequest", - description: - "Per-item inventory mutation: manual first-use, used-up override, printed-expiry correction, absolute remaining-unit correction, notes. Every field is optional and commutative.", - }); - -export type CreateInventoryItemInput = z.infer< - typeof createInventoryItemSchema ->; -export type UpdateInventoryItemInput = z.infer< - typeof updateInventoryItemSchema ->; - -/** - * v1.4.25 W21 Fix-K — `POST /api/medications/[id]/glp1` body validators. - * - * The convenience route accepts either a `doseChange` or an `inventory` - * payload (the route picks one). Both branches were hand-rolled - * `typeof === "number"` checks pre-Fix-K, which let `NaN`, `Infinity`, - * negative doses, and unbounded notes slip through. - * - * Bounds: - * - `doseValue` is finite, non-negative, capped at 100 mg (covers every - * real-world GLP-1 step with headroom). - * - `doseUnit` is a short string (mg / mcg / IE). - * - `note` is capped at 500 characters so the field can't be used as a - * blob smuggler. - * - `effectiveFrom` is constrained to a ±5-year window around now — - * a paper-record back-fill or a planned future step both fit, but - * "1970" / "9999" do not. - * - `delta` is a non-zero finite integer in [−100, 100] — the legacy - * ledger counts pens, and ±100 pens per correction stays plenty - * (deliberately NOT raised with the v1.16.10 per-item unit cap). - * - `reason` is a bounded string (the route logs it; raw blob bad). - */ -const MAX_DOSE_MG = 100; -const MAX_NOTE_CHARS = 500; -const MAX_REASON_CHARS = 200; -const MIN_EFFECTIVE_FROM = new Date("2020-01-01T00:00:00Z"); -const FIVE_YEARS_MS = 5 * 365 * 24 * 60 * 60 * 1000; - -export const glp1DoseChangePostSchema = z.object({ - effectiveFrom: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .refine((d) => d.getTime() >= MIN_EFFECTIVE_FROM.getTime(), { - message: "effectiveFrom must be on or after 2020-01-01", - }) - .refine((d) => d.getTime() <= Date.now() + FIVE_YEARS_MS, { - message: "effectiveFrom must be within 5 years of now", - }), - doseValue: z.number().finite().min(0).max(MAX_DOSE_MG), - doseUnit: z.string().min(1).max(10), - note: z.string().max(MAX_NOTE_CHARS).nullable().optional(), -}); - -/** - * DEPRECATED write path (v1.16.10) — the `inventory.delta` branch feeds - * the legacy `MedicationInventoryEvent` running-sum ledger. The per-item - * endpoints (`POST /api/medications/[id]/inventory`, - * `PATCH /api/medications/[id]/inventory/[itemId]`) replaced it; reads - * fall back to the ledger only while a medication has zero inventory - * items. New callers must register containers instead of posting deltas. - */ -export const glp1InventoryPostSchema = z.object({ - delta: z - .number() - .int() - .finite() - .min(-100) - .max(100) - .refine((n) => n !== 0, { message: "delta must be non-zero" }) - .describe( - "Deprecated since v1.16.10: pen-count delta on the legacy running-sum ledger. Register containers via the inventory endpoints instead; reads use the ledger only while the medication has no inventory items.", - ), - reason: z.string().min(1).max(MAX_REASON_CHARS), -}); - -export const glp1PostBodySchema = z - .object({ - doseChange: glp1DoseChangePostSchema.optional(), - inventory: glp1InventoryPostSchema.optional(), - }) - .refine((b) => Boolean(b.doseChange) !== Boolean(b.inventory), { - message: "Body must carry exactly one of doseChange or inventory", - }); - -export type Glp1DoseChangePostInput = z.infer; -export type Glp1InventoryPostInput = z.infer; -export type Glp1PostBodyInput = z.infer; - -export type CreateMedicationInput = z.infer; -export type IntakeInput = z.infer; -export type ListIntakeEventsInput = z.infer; -export type UpdateIntakeEventInput = z.infer; diff --git a/src/lib/validations/medication/base.ts b/src/lib/validations/medication/base.ts new file mode 100644 index 000000000..7a6709c5c --- /dev/null +++ b/src/lib/validations/medication/base.ts @@ -0,0 +1,226 @@ +import { z } from "zod/v4"; + +import { INJECTION_SITE_KEYS } from "@/lib/medications/injection-sites"; +import { validateEntryInstant } from "@/lib/validations/entry-instant"; + +/** + * v1.8.5 — the eight injection-site enum values, mirrored from the + * Prisma `InjectionSite` enum via the shared `INJECTION_SITE_KEYS` + * tuple. Reused by the intake + bulk-intake request schemas (the + * `injectionSite` field) and the per-medication allowed-sites editor. + */ +export const INJECTION_SITE_VALUES = INJECTION_SITE_KEYS; +export type InjectionSiteValue = (typeof INJECTION_SITE_VALUES)[number]; + +/** Zod enum over the eight injection sites. */ +export const injectionSiteEnum = z.enum(INJECTION_SITE_VALUES); + +export const timeRegex = /^([01]\d|2[0-3]):([0-5]\d)$/; + +/** + * v1.9.0 — drug-classification code formats. + * + * `ATC_CODE_REGEX` — the 7-character WHO ATC code (one letter, two + * digits, two letters, two digits, e.g. `A10BX10`). This is the + * full leaf-level substance class; the shorter anatomical-group + * prefixes (`A`, `A10`, `A10B`) are deliberately NOT accepted — the + * exporter emits a substance-class coding, not a group. + * + * `RXCUI_REGEX` — the RxNorm RxCUI is a bare positive integer string + * (e.g. `2601723`). + * + * Both fields are user/clinician-asserted and never machine-guessed; + * a malformed value is rejected with 422 rather than silently stored. + */ +const ATC_CODE_REGEX = /^[A-Z]\d{2}[A-Z]{2}\d{2}$/; +const RXCUI_REGEX = /^\d+$/; + +/** + * v1.9.0 — reusable nullable/optional code-field validators shared by + * the create + update medication schemas. `null` clears the column; + * `undefined` leaves it untouched on update. + */ +export const atcCodeField = z + .string() + .regex(ATC_CODE_REGEX, "Invalid ATC code (expected e.g. A10BX10)") + .nullable() + .optional() + .describe( + "Optional WHO ATC classification code (active-substance class, 7 chars, e.g. `A10BX10`). User/clinician-asserted; never machine-guessed. Emitted on the FHIR `medicationCodeableConcept` under `http://www.whocc.no/atc`. NULL clears it; absent leaves it untouched.", + ); +export const rxNormCodeField = z + .string() + .regex(RXCUI_REGEX, "Invalid RxNorm code (expected a numeric RxCUI)") + // Defence-in-depth: the `^\d+$` regex is otherwise unbounded. Real + // RxCUIs are at most 7 digits today; 20 is a generous ceiling that + // still bounds the column write tightly. (`atcCode` needs no max — + // its regex already fixes the length at 7.) + .max(20, "RxNorm code is too long") + .nullable() + .optional() + .describe( + "Optional RxNorm RxCUI (numeric, US identifier, e.g. `2601723`). Secondary coding emitted under `http://www.nlm.nih.gov/research/umls/rxnorm` alongside any ATC code, never instead of the free-text name. NULL clears it; absent leaves it untouched.", + ); +/** + * Clinical-category values stored in the `medication_categories` side- + * table (TEXT column). v1.5.4 adds `DIABETES` and `ANTIBIOTIC` so the + * wizard's Step 2 taxonomy can write a first-class bucket for those + * two rows instead of collapsing them into `OTHER`. The column is a + * plain TEXT field — no Prisma enum exists to migrate; the Zod values + * list is the only enforcement layer. + */ +export const MEDICATION_CATEGORY_VALUES = [ + "BLOOD_PRESSURE", + "VITAMIN", + "SUPPLEMENT", + "PAIN_RELIEF", + "ALLERGY", + "DIGESTIVE", + "THYROID", + "HORMONE", + "SKIN", + "SLEEP_AID", + "DIABETES", + "ANTIBIOTIC", + "OTHER", +] as const; +export type MedicationCategoryValue = + (typeof MEDICATION_CATEGORY_VALUES)[number]; + +/** + * v1.4.25 W4d — Prisma-level treatment class. Orthogonal to + * `MEDICATION_CATEGORY_VALUES` (the clinical taxonomy that lives in the + * `medication_categories` side-table). GLP1 turns on the GLP-1 + * specialist surfaces — injection-site picker, titration history, pen + * inventory, GLP-1-aware Coach replies. Future treatment classes drop + * into this list (INSULIN, BIOLOGIC, FERTILITY, …). + */ +export const MEDICATION_TREATMENT_CLASS_VALUES = ["GENERIC", "GLP1"] as const; +export type MedicationTreatmentClass = + (typeof MEDICATION_TREATMENT_CLASS_VALUES)[number]; + +/** + * v1.16.12 (#316) — fractional dosing. A dose may consume a sub-unit + * fraction of a tablet (split pills). The UI offers a CURATED set of + * common fractions (¼ ⅓ ½ ⅔ ¾), stored as their decimal value; thirds + * are inexact in decimal (⅓ ≈ 0.3333, ⅔ ≈ 0.6667) — the @db.Decimal(10,4) + * column and the runway floor absorb the sub-0.0001 drift. Whole numbers + * 1..100 (multi-tablet doses, the pre-v1.16.12 contract) stay valid + * alongside the fractions. One source of truth — the UI selector and the + * tests import this set so the allowed values can never drift from the + * validator. + */ +export const UNITS_PER_DOSE_FRACTIONS = [ + 0.25, 0.3333, 0.5, 0.6667, 0.75, +] as const; +export const UNITS_PER_DOSE_MAX_WHOLE = 100; + +export function isSupportedUnitsPerDose(value: number): boolean { + if ((UNITS_PER_DOSE_FRACTIONS as readonly number[]).includes(value)) { + return true; + } + return ( + Number.isInteger(value) && value >= 1 && value <= UNITS_PER_DOSE_MAX_WHOLE + ); +} + +export const UNITS_PER_DOSE_MESSAGE = + "unitsPerDose must be a whole number 1–100 or a supported fraction (¼, ⅓, ½, ⅔, ¾)"; + +/** + * v1.6.0 — route of administration. Decoupled from `treatmentClass`: + * the injection-site picker surfaces for any `INJECTION` dose, and a + * one-time injection is `oneShot: true` + `deliveryForm: "INJECTION"`. + * ORAL is the default the migration backfilled onto every existing row. + */ +export const MEDICATION_DELIVERY_FORM_VALUES = [ + "ORAL", + "INJECTION", + "OTHER", +] as const; +export type MedicationDeliveryForm = + (typeof MEDICATION_DELIVERY_FORM_VALUES)[number]; + +/** + * v1.5 — RRULE string shape check. + * + * Not a full RFC 5545 parser (that lives in the `rrule` npm package on + * the server side). The regex catches the subset HealthLog mints from + * the wizard / form: a `FREQ=` line with optional `INTERVAL=`, + * `BYDAY=`, `BYMONTHDAY=`, `BYMONTH=`, `COUNT=`, `UNTIL=` properties + * separated by `;`. Any pathological-looking shape (the `FREQ=SECONDLY` + * DoS surface, `;COUNT=999999999`) is filtered at the server route + * layer with a hard cap on emitted occurrences. + */ +export const RRULE_PROPS = + /^FREQ=(?:DAILY|WEEKLY|MONTHLY|YEARLY)(?:;(?:INTERVAL=\d{1,3}|BYDAY=(?:MO|TU|WE|TH|FR|SA|SU)(?:,(?:MO|TU|WE|TH|FR|SA|SU))*|BYMONTHDAY=-?\d{1,2}(?:,-?\d{1,2})*|BYMONTH=\d{1,2}(?:,\d{1,2})*|COUNT=\d{1,4}|UNTIL=\d{8}T\d{6}Z))*$/; + +/** + * v1.15.18 — one explicit per-dose on-time window. `timeOfDay` keys the dose + * the window applies to; `start`/`end` are the HH:mm on-time bounds in the + * user's wall clock. `start <= end` within the day (an overnight window is not + * a configurable on-time band — the late tail owns the cross-midnight tail). + */ +export const doseWindowEntrySchema = z + .object({ + timeOfDay: z + .string() + .regex(timeRegex, "Format: HH:mm") + .describe( + "The dose time this window applies to (matches a `timesOfDay` entry).", + ), + start: z + .string() + .regex(timeRegex, "Format: HH:mm") + .describe("On-time band lower bound (HH:mm, user local)."), + end: z + .string() + .regex(timeRegex, "Format: HH:mm") + .describe( + "On-time band upper bound (HH:mm, user local). Must be >= `start`.", + ), + }) + .refine((w) => hhmmToMinutes(w.start) <= hhmmToMinutes(w.end), { + message: "Window start must be on or before end (same day)", + path: ["end"], + }) + .meta({ + id: "DoseWindowEntry", + description: + "One explicit per-dose on-time intake window. `timeOfDay` matches a schedule dose time; `[start, end]` (HH:mm, user local, `start <= end`) is the on-time band. Outside it the cadence-derived late tail applies, then ad-hoc.", + }); + +/** Minutes-since-midnight for an `HH:mm` literal (already regex-validated). */ +export function hhmmToMinutes(hhmm: string): number { + const [h, m] = hhmm.split(":"); + return Number(h) * 60 + Number(m); +} + +/** + * v1.15.19 introduced `takenAt` plausibility bounds on the EDIT path + * (audit P0-4); v1.16.9 lifts them onto every CREATE path too (the + * per-medication intake POST, the bulk endpoint, the external ingest) so + * the retro-add dialog and a hand-rolled API call can't insert a row a + * decade in the past or hours in the future. Future allowance covers + * client clock skew; the 5-year floor matches the GLP-1 dose-change + * validator's window. Slot-distance / medication-start checks stay in + * the routes — the schema cannot see the medication. + */ +export const TAKEN_AT_MAX_AGE_MS = 5 * 365 * 24 * 60 * 60 * 1000; + +/** + * ISO `takenAt` → `Date` with the plausibility bounds applied. + * + * v1.17 W1b — delegates to the shared `validateEntryInstant` helper (the + * same bound now guarding `measuredAt` + `moodLoggedAt`), passing the + * five-year window so a stale backdated dose is rejected well before the + * 1900 floor. The 5-min clock-skew tolerance is the shared default. + */ +export const boundedTakenAtSchema = validateEntryInstant( + z.iso.datetime({ offset: true }).transform((s) => new Date(s)), + { + maxAgeMs: TAKEN_AT_MAX_AGE_MS, + // Preserve the established wording so the iOS contract + tests stay stable. + pastMessage: "takenAt must be within the last 5 years", + }, +); diff --git a/src/lib/validations/medication/create-update.ts b/src/lib/validations/medication/create-update.ts new file mode 100644 index 000000000..02373b3d4 --- /dev/null +++ b/src/lib/validations/medication/create-update.ts @@ -0,0 +1,338 @@ +import { z } from "zod/v4"; + +import { + MEDICATION_CATEGORY_VALUES, + MEDICATION_DELIVERY_FORM_VALUES, + MEDICATION_TREATMENT_CLASS_VALUES, + UNITS_PER_DOSE_MESSAGE, + atcCodeField, + injectionSiteEnum, + isSupportedUnitsPerDose, + INJECTION_SITE_VALUES, + rxNormCodeField, +} from "./base"; +import { scheduleSchema } from "./schedule"; + +/** + * v1.5 — medication-level course window + one-shot flag. The fields + * are optional on the create path so the existing form (no wizard + * yet) keeps working. The route layer enforces the `oneShot` + at- + * least-one-schedule invariant for the wizard path. + */ +const courseWindowFields = { + /** + * Date the medication course begins. ISO YYYY-MM-DD. NULL = "from + * creation" (the legacy implicit anchor). + */ + startsOn: z.iso + .date() + .transform((s) => new Date(s)) + .nullable() + .optional() + .describe( + "Date the medication course begins (ISO `YYYY-MM-DD`). Anchors RRULE BYDAY / BYMONTHDAY patterns and the rolling-interval countdown's first window. NULL = active from creation (the legacy implicit behaviour). Required when `oneShot` is true.", + ), + /** + * Date the medication course ends. ISO YYYY-MM-DD. NULL = chronic. + * Required to equal `startsOn` when `oneShot` is true (the route + * normalises this; the schema doesn't because they're sister + * fields and the cross-field refine is enforced at the route). + */ + endsOn: z.iso + .date() + .transform((s) => new Date(s)) + .nullable() + .optional() + .describe( + "Date the medication course ends (ISO `YYYY-MM-DD`). NULL = no end date (chronic). When `oneShot` is true the route normalises `endsOn` to equal `startsOn`.", + ), + /** + * Single-administration medication. Auto-deactivates after the + * single intake is logged. + */ + oneShot: z + .boolean() + .optional() + .describe( + "Single-administration medication (e.g. flu shot, post-op single dose). When true the medication has at most one schedule with no `rrule` / `rollingIntervalDays`, and `active` auto-flips to false once the dose is logged (non-skipped).", + ), + /** + * v1.16.11 (#316) — as-needed (PRN) medication. No fixed schedule: + * never due, never reminded, excluded from compliance, active + * indefinitely. Intakes log ad-hoc and consume inventory. Carries + * ZERO schedules — a `schedules` array alongside `asNeeded: true` is + * a 422. Mutually exclusive with `oneShot`. + */ + asNeeded: z + .boolean() + .optional() + .describe( + "As-needed (PRN) medication (pain relief, rescue inhaler). When true the medication carries NO schedules (supplying any schedule entry is a 422): it is never due, never reminded, and excluded from compliance rates/streaks, but intakes still log (ad-hoc), inventory still consumes, and the history renders. Stays active indefinitely. Mutually exclusive with `oneShot`.", + ), +}; + +/** + * v1.16.11 — shared as-needed cross-field refines for the create + + * update bodies: `asNeeded` and `oneShot` are mutually exclusive, and + * an as-needed medication cannot carry schedule entries. + */ +const AS_NEEDED_ONE_SHOT_MESSAGE = + "asNeeded and oneShot are mutually exclusive"; +const AS_NEEDED_SCHEDULES_MESSAGE = + "An as-needed medication cannot carry schedules"; + +export const createMedicationSchema = z + .object({ + name: z.string().min(1).max(100), + dose: z.string().min(1).max(50), + category: z.enum(MEDICATION_CATEGORY_VALUES).optional(), + /** v1.4.25 W4d — treatment-class discriminator (GENERIC | GLP1). */ + treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(), + /** v1.4.25 W4d — doses per pen/vial for inventory tracking. + * v1.16.10 raises the cap to 1000 (large tablet packs). */ + dosesPerUnit: z.number().int().min(1).max(1000).optional(), + /** + * v1.16.10 — inventory units one dose consumes (2 × 2 mg tablets + * for a 4 mg dose). Default 1. Dose-level readouts divide the + * unit counts by this factor. + */ + unitsPerDose: z + .number() + .refine(isSupportedUnitsPerDose, { message: UNITS_PER_DOSE_MESSAGE }) + .optional() + .describe( + "Inventory units consumed per dose. A whole number 1–100 (e.g. 2 tablets of 2 mg for a 4 mg dose) or a supported fraction for a split pill (¼ / ⅓ / ½ / ⅔ / ¾). Default 1. The intake consumption hook decrements this many units per taken dose; dose-derived readouts divide unit counts by it.", + ), + /** + * v1.17.0 — optional per-medication reorder lead time in days. The + * low-stock alert widens its trigger by this lead plus one + * dose-interval so a sparse cadence is warned before its last dose. + * Omitted = inherit the user-level + * `notificationPrefs.medication.reorderLeadDays` default. + */ + reorderLeadDays: z + .number() + .int() + .min(0) + .max(60) + .optional() + .describe( + "Per-medication reorder lead time in days (0–60). The low-stock alert fires the supply early enough to reorder before the last dose. Omitted = inherit the user-level reorderLeadDays default (10).", + ), + /** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */ + deliveryForm: z.enum(MEDICATION_DELIVERY_FORM_VALUES).optional(), + /** + * v1.8.5 — per-medication injection-site tracking opt-in. Default + * false. Only meaningful when `deliveryForm === "INJECTION"`. + */ + trackInjectionSites: z + .boolean() + .optional() + .describe( + "Per-medication injection-site tracking opt-in. Default false. Only meaningful for an INJECTION delivery form; when true the client prompts (skippably) for the site after a taken dose.", + ), + /** + * v1.8.5 — per-medication allowed / preferred injection sites. + * Empty = no per-medication restriction. The effective pickable set + * subtracts the user's global exclusion (deny wins). + */ + allowedInjectionSites: z + .array(injectionSiteEnum) + .max(INJECTION_SITE_VALUES.length) + .optional() + .describe( + "Per-medication allowed / preferred injection sites. Empty array = no per-medication restriction (every site offered). The effective pickable set is this list minus the user's global exclusion.", + ), + notificationsEnabled: z.boolean().optional(), + /** v1.7.0 — iOS Live Activity opt-in for this medication's reminders. */ + liveActivityEnabled: z + .boolean() + .optional() + .describe( + "iOS Live Activity opt-in for this medication's reminders. Default false. The iOS client owns the ActivityKit lifecycle; the server only stores + echoes the flag.", + ), + /** v1.7.0 — iOS 26 AlarmKit critical-reminder opt-in. */ + criticalAlarmEnabled: z + .boolean() + .optional() + .describe( + "iOS 26 AlarmKit critical-reminder opt-in. Default false. Critical alarms bypass the device mute switch / Focus; the server stores the preference only and hangs no server-side behaviour off it.", + ), + /** v1.9.0 — optional WHO ATC classification code. */ + atcCode: atcCodeField, + /** v1.9.0 — optional RxNorm RxCUI (secondary, US). */ + rxNormCode: rxNormCodeField, + ...courseWindowFields, + /** + * v1.16.11 — optional at the type level so an `asNeeded: true` + * create can omit it; the refine below keeps the legacy + * at-least-one-schedule contract for every scheduled medication. + */ + schedules: z.array(scheduleSchema).optional(), + }) + .refine((b) => b.oneShot !== true || !!b.startsOn, { + message: "startsOn is required when oneShot is true", + path: ["startsOn"], + }) + .refine( + (b) => + !b.startsOn || !b.endsOn || b.endsOn.getTime() >= b.startsOn.getTime(), + { + message: "endsOn must be on or after startsOn", + path: ["endsOn"], + }, + ) + .refine((b) => !(b.asNeeded === true && b.oneShot === true), { + message: AS_NEEDED_ONE_SHOT_MESSAGE, + path: ["asNeeded"], + }) + .refine( + (b) => b.asNeeded === true || (!!b.schedules && b.schedules.length >= 1), + { + message: "Mindestens ein Zeitfenster", + path: ["schedules"], + }, + ) + .refine( + (b) => b.asNeeded !== true || !b.schedules || b.schedules.length === 0, + { + message: AS_NEEDED_SCHEDULES_MESSAGE, + path: ["schedules"], + }, + ) + .meta({ + id: "CreateMedicationRequest", + description: + 'Create-medication body. The route enforces the v1.5 cross-field invariants on top of the per-schedule `rrule_xor_rolling` Zod refine: a `oneShot:true` medication may carry at most one schedule and that schedule must not declare a recurrence; `endsOn` is normalised to equal `startsOn` for one-shot doses; a recurring schedule with no `rrule`, `rollingIntervalDays`, or legacy `daysOfWeek` defaults to `rrule = "FREQ=DAILY"`; and `timesOfDay` is dual-written from `windowStart` when the caller omits it. v1.16.11 — `asNeeded: true` creates a PRN medication with ZERO schedules (`schedules` must be absent or empty, 422 otherwise); a scheduled medication still requires at least one schedule entry.', + }); + +export const updateMedicationSchema = z + .object({ + name: z.string().min(1).max(100).optional(), + dose: z.string().min(1).max(50).optional(), + category: z.enum(MEDICATION_CATEGORY_VALUES).optional(), + treatmentClass: z.enum(MEDICATION_TREATMENT_CLASS_VALUES).optional(), + dosesPerUnit: z.number().int().min(1).max(1000).nullable().optional(), + /** v1.16.10 — inventory units one dose consumes. Default 1. + * v1.16.12 — whole number 1–100 or a curated fraction (½ / ⅓ / ¼ …). */ + unitsPerDose: z + .number() + .refine(isSupportedUnitsPerDose, { message: UNITS_PER_DOSE_MESSAGE }) + .optional() + .describe( + "Inventory units consumed per dose — a whole number 1–100 or a supported split-pill fraction (¼ / ⅓ / ½ / ⅔ / ¾). The intake consumption hook decrements this many units per taken dose; already-stamped intake events keep their recorded consumption.", + ), + /** + * v1.17.0 — per-medication reorder lead time in days (0–60). `null` + * clears the override and reverts to the user-level default. + */ + reorderLeadDays: z + .number() + .int() + .min(0) + .max(60) + .nullable() + .optional() + .describe( + "Per-medication reorder lead time in days (0–60). null clears the override and reverts to the user-level reorderLeadDays default.", + ), + /** v1.6.0 — route of administration (ORAL | INJECTION | OTHER). */ + deliveryForm: z.enum(MEDICATION_DELIVERY_FORM_VALUES).optional(), + /** v1.8.5 — per-medication injection-site tracking opt-in. */ + trackInjectionSites: z + .boolean() + .optional() + .describe( + "Per-medication injection-site tracking opt-in. Only meaningful for an INJECTION delivery form. Set false to deactivate tracking.", + ), + /** v1.8.5 — per-medication allowed / preferred injection sites. */ + allowedInjectionSites: z + .array(injectionSiteEnum) + .max(INJECTION_SITE_VALUES.length) + .optional() + .describe( + "Per-medication allowed / preferred injection sites. Empty array clears the restriction. The effective pickable set subtracts the user's global exclusion (deny wins).", + ), + active: z.boolean().optional(), + notificationsEnabled: z.boolean().optional(), + /** v1.7.0 — iOS Live Activity opt-in for this medication's reminders. */ + liveActivityEnabled: z + .boolean() + .optional() + .describe( + "iOS Live Activity opt-in for this medication's reminders. The iOS client owns the ActivityKit lifecycle; the server only stores + echoes the flag.", + ), + /** v1.7.0 — iOS 26 AlarmKit critical-reminder opt-in. */ + criticalAlarmEnabled: z + .boolean() + .optional() + .describe( + "iOS 26 AlarmKit critical-reminder opt-in. Critical alarms bypass the device mute switch / Focus; the server stores the preference only.", + ), + /** v1.9.0 — optional WHO ATC classification code. */ + atcCode: atcCodeField, + /** v1.9.0 — optional RxNorm RxCUI (secondary, US). */ + rxNormCode: rxNormCodeField, + ...courseWindowFields, + schedules: z.array(scheduleSchema).optional(), + /** + * v1.5.5 — top-level primary-schedule grace bridge. The detail + * page settings section saves the reminder-window in minutes for + * the medication's primary schedule without re-sending the full + * `schedules` array. The route normalises this value onto the + * primary schedule's `reminderGraceMinutes` before the Prisma + * update so the persisted shape stays per-schedule. NULL clears + * the override and falls back to the legacy `windowEnd - + * windowStart` span. + */ + reminderGraceMinutes: z + .number() + .int() + .min(0) + .max(24 * 60) + .nullable() + .optional() + .describe( + "Detail-page bridge: primary-schedule reminder-window in minutes. The route maps the value onto the primary schedule's `reminderGraceMinutes` field; ignored when a full `schedules` array is also supplied.", + ), + }) + .refine((b) => b.oneShot !== true || !!b.startsOn, { + message: "startsOn is required when oneShot is true", + path: ["startsOn"], + }) + .refine( + (b) => + !b.startsOn || !b.endsOn || b.endsOn.getTime() >= b.startsOn.getTime(), + { + message: "endsOn must be on or after startsOn", + path: ["endsOn"], + }, + ) + .refine((b) => !(b.asNeeded === true && b.oneShot === true), { + message: AS_NEEDED_ONE_SHOT_MESSAGE, + path: ["asNeeded"], + }) + .refine( + (b) => b.asNeeded !== true || !b.schedules || b.schedules.length === 0, + { + message: AS_NEEDED_SCHEDULES_MESSAGE, + path: ["schedules"], + }, + ) + .refine( + // An empty replace-list is only legal on the as-needed path — for a + // scheduled medication it would wipe every schedule and strand the + // row in a state no surface can reason about. + (b) => b.asNeeded === true || !b.schedules || b.schedules.length >= 1, + { + message: "Mindestens ein Zeitfenster", + path: ["schedules"], + }, + ) + .meta({ + id: "UpdateMedicationRequest", + description: + "Update-medication body. Every field is optional; omitted fields are left untouched. Supplying `schedules` REPLACES the medication's full schedule list (the route deletes existing rows before re-creating). Flipping `active` to false records the current timestamp on `pausedAt`; flipping back to true clears it. v1.5 invariants on the `schedules` array match the create path. v1.16.11 — `asNeeded: true` requires the medication to end schedule-less: supply `schedules: []` to clear an existing plan (any schedule entry is a 422); an empty `schedules` array without `asNeeded: true` is likewise a 422.", + }); + +export type CreateMedicationInput = z.infer; diff --git a/src/lib/validations/medication/index.ts b/src/lib/validations/medication/index.ts new file mode 100644 index 000000000..44c2c4250 --- /dev/null +++ b/src/lib/validations/medication/index.ts @@ -0,0 +1,16 @@ +/** + * Medication request/response validation schemas. + * + * Barrel preserving the original `@/lib/validations/medication` import path. + * The schemas are split by sub-resource into focused sibling modules: + * - `base` — shared enums, constants, regexes, code fields, dose windows. + * - `schedule` — the per-schedule input schema. + * - `create-update` — create / update medication bodies + course window. + * - `intake` — intake / external / list / edit / bulk-delete schemas. + * - `inventory` — container + GLP-1 schemas. + */ +export * from "./base"; +export * from "./schedule"; +export * from "./create-update"; +export * from "./intake"; +export * from "./inventory"; diff --git a/src/lib/validations/medication/intake.ts b/src/lib/validations/medication/intake.ts new file mode 100644 index 000000000..5e3a03a49 --- /dev/null +++ b/src/lib/validations/medication/intake.ts @@ -0,0 +1,190 @@ +import { z } from "zod/v4"; + +import { boundedTakenAtSchema, injectionSiteEnum } from "./base"; + +export const intakeSchema = z + .object({ + medicationId: z + .string() + .min(1) + .describe( + "Server-narrowed from the URL path. The route layer overwrites whatever the body supplies before Zod parsing so a caller cannot log an intake against another medication.", + ), + scheduledFor: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .optional() + .describe( + "Slot the dose belongs to. Defaults to `takenAt` (or `now()` when both are absent) so the compliance pairing logic can pin the dose to a schedule slot.", + ), + takenAt: boundedTakenAtSchema + .optional() + .describe( + "When the dose was actually taken. NULL when `skipped` is true; defaults to `now()` for non-skipped intakes. Must not lie in the future (5-minute clock-skew allowance) nor more than 5 years in the past.", + ), + skipped: z + .boolean() + .optional() + .default(false) + .describe( + "True to log a skipped slot (no consumption, no inventory decrement, one-shot medications stay active).", + ), + idempotencyKey: z + .string() + .max(128) + .optional() + .describe( + "Caller-issued de-dup key. A second POST with the same key returns the original event without creating a new row.", + ), + /** + * v1.8.5 — optional injection-site capture. Only honoured on a + * non-skipped (taken) write for a medication with + * `deliveryForm === "INJECTION"` and `trackInjectionSites === true`. + * The site is validated server-side against the medication's + * effective allowed set (per-medication `allowedInjectionSites` + * minus the user's `globalExcludedInjectionSites` deny-list); a + * disallowed value is rejected with 422. Always optional — the + * client may omit it (the dose still records). + */ + injectionSite: injectionSiteEnum + .optional() + .describe( + "Optional injection site for a taken dose. Honoured only when the medication is an INJECTION with site-tracking enabled; validated against the medication's effective allowed set (per-medication allowed sites minus the user's global exclusion). A disallowed site returns 422. Omit to record the dose without a site.", + ), + /** + * v1.15.18 — late-take "attribute anyway" pin. An off-window take that + * band attribution would otherwise orphan to an ad-hoc row can be pinned + * onto a chosen scheduled slot via the UI's "diesem Slot zuordnen?" nudge. + * The instant MUST be a real slot of this medication on its day (the + * server validates it against the band anchors); an arbitrary instant is + * rejected with 422. Absent → default band attribution. + */ + forceSlotInstant: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .optional() + .describe( + "Late-take override: pin this taken dose onto the named scheduled slot instead of orphaning it to an ad-hoc row. Must be a real scheduled slot of this medication on its day (validated server-side against the dose-window band anchors); an instant that is not a slot returns 422. Absent applies the default window-band attribution.", + ), + /** + * v1.16.4 — per-intake dose override. Free text mirroring + * `Medication.dose` (max 50 chars). Persisted only on a taken + * (non-skipped) write. Absent = the configured medication dose + * applies; read paths fall back to it. + */ + doseTaken: z + .string() + .trim() + .min(1) + .max(50) + .optional() + .describe( + "Dose actually consumed for THIS intake when it deviates from (or documents) the medication's configured dose, e.g. a half tablet or a titration step. Free text, max 50 characters. Omit to record the take under the medication's configured dose.", + ), + }) + .meta({ + id: "MedicationIntakeRequest", + description: + "Per-medication intake log body. Idempotent via `idempotencyKey`; the server also dedupes by a 60-second sliding window when the key is absent. Non-skipped intakes auto-decrement pen inventory (best-effort), refresh the per-day compliance rollup, and — for one-shot medications — flip `active` to false. The optional `injectionSite` is persisted only for an INJECTION medication with site-tracking enabled and is validated against the medication's effective allowed set (422 on a disallowed value).", + }); + +export const externalIntakeSchema = z.object({ + medicationName: z.string().min(1).max(200), + // v1.16.9 — same plausibility bounds as the interactive create paths. + takenAt: boundedTakenAtSchema.optional(), + idempotencyKey: z.string().max(128), +}); + +export const listIntakeEventsSchema = z.object({ + limit: z.coerce.number().int().min(1).max(500).optional().default(25), + offset: z.coerce.number().int().min(0).optional().default(0), + sortBy: z + .enum(["scheduledFor", "takenAt", "source", "createdAt"]) + .optional() + .default("scheduledFor"), + sortDir: z.enum(["asc", "desc"]).optional().default("desc"), + /** + * v1.4.37 W3 — server-side status filter so the medication detail + * page (IntakeHistoryListV2) can hide unconfirmed / planned rows. + * + * - "all" (default): every event, preserves the byte-stable contract + * the iOS Swift client and existing dashboard consumers depend on. + * - "taken": only rows where the dose was confirmed taken + * (`takenAt IS NOT NULL AND skipped = false`). + * - "skipped": only rows the user explicitly skipped (`skipped = true`). + * - "completed": taken OR skipped — anything the user actually + * actioned. Excludes the ambiguous "missed / never confirmed" + * rows (`takenAt IS NULL AND skipped = false`) that the v1 list + * rendered as "verpasst" before the v1 component retired. + */ + status: z + .enum(["all", "taken", "skipped", "completed"]) + .optional() + .default("all"), +}); + +/** + * v1.15.19 — `takenAt` plausibility bounds on the edit path (audit P0-4). + * A date typo on an intake edit could park `takenAt` a month before its + * slot with no pushback anywhere. The schema rejects the physically + * implausible cases: a future instant (small skew allowance for client + * clocks) and anything older than the 5-year window the GLP-1 dose-change + * validator already established (`glp1DoseChangePostSchema`). Slot-distance + * checks stay out of the schema — it cannot see the medication — and live + * in the route (start-date guard) + the edit dialog (non-blocking hint). + */ +export const updateIntakeEventSchema = z + .object({ + takenAt: boundedTakenAtSchema.nullable().optional(), + skipped: z.boolean().optional(), + scheduledFor: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .optional(), + /** + * v1.15.18 — late-take "attribute anyway" pin on the edit path. When the + * edited `takenAt` lands outside every window the UI can offer to pin the + * take onto a chosen slot; the server validates the instant is a real + * scheduled slot (422 otherwise). Absent → the edit re-runs band + * attribution on the new `takenAt`. + * + * v1.15.20 — an explicit `null` UNPINS: the dose re-attributes by window + * band on its (unchanged or edited) `takenAt` and the binding provenance + * resets to AUTO — the "Zuordnung lösen" path. A pin / unpin no longer + * requires `takenAt` or `skipped` to change in the same request. + */ + forceSlotInstant: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .nullable() + .optional() + .describe( + "Late-take override on edit: pin the edited dose onto the named scheduled slot instead of re-attributing by window band. Must be a real scheduled slot of this medication on its day (validated server-side); a non-slot instant returns 422. Explicit null unpins: the dose re-attributes by window band on its takenAt (ad-hoc when no band matches). Absent re-runs the default window-band attribution on the edited `takenAt`.", + ), + }) + .meta({ + id: "UpdateMedicationIntakeEventRequest", + description: + "Edit a single intake event. v1.15.18 re-runs window-band slot attribution whenever `takenAt` or `skipped` change, snapping `scheduledFor` to the matched slot (or the take's own time when it falls in no window). `forceSlotInstant` overrides that to pin the take onto a named real slot (explicit null unpins, re-attributing by band); an explicit `scheduledFor` still wins when supplied directly. `takenAt` must not be in the future (5-minute clock-skew allowance) nor more than 5 years in the past; a `takenAt` before the medication's start date returns 422.", + }); + +/** + * v1.5.5 — bulk-delete request body. The detail-page intake-history + * preview surfaces a multi-select that posts the resulting eventIds + * here. The cap matches `listIntakeEventsSchema.limit` (500) so the + * client never selects more rows than the table can return at once. + * Server-side guarantees scoped-by-medication ownership via + * `assertMedicationOwnership` + a `userId` predicate on the + * `deleteMany`. + */ +export const bulkDeleteIntakeEventsSchema = z.object({ + eventIds: z.array(z.string().min(1).max(64)).min(1).max(500), +}); + +export type BulkDeleteIntakeEventsInput = z.infer< + typeof bulkDeleteIntakeEventsSchema +>; + +export type IntakeInput = z.infer; +export type ListIntakeEventsInput = z.infer; +export type UpdateIntakeEventInput = z.infer; diff --git a/src/lib/validations/medication/inventory.ts b/src/lib/validations/medication/inventory.ts new file mode 100644 index 000000000..5e735b085 --- /dev/null +++ b/src/lib/validations/medication/inventory.ts @@ -0,0 +1,193 @@ +import { z } from "zod/v4"; + +/** + * v1.4.25 W19b — inventory (pen / vial) CRUD validators. + * + * The Prisma model carries a 4-state enum + * (ACTIVE | IN_USE | EXPIRED | USED_UP); the API surface only lets + * the user explicitly transition into IN_USE (mark-as-first-use) or + * USED_UP (manual override). EXPIRED is owned by the daily cron and + * the intake hook, so the PATCH schema deliberately omits it. + */ +/** + * v1.16.10 — container kinds, mirrored from the Prisma + * `MedicationContainerType` enum. Display-level classification only. + */ +export const MEDICATION_CONTAINER_TYPE_VALUES = [ + "PEN", + "AMPOULE", + "BLISTER", + "INHALER", + "BOTTLE", + "OTHER", +] as const; +export type MedicationContainerTypeValue = + (typeof MEDICATION_CONTAINER_TYPE_VALUES)[number]; + +export const createInventoryItemSchema = z + .object({ + /** Units the container ships with. v1.16.10 raises the cap from + * 100 to 1000 (large tablet packs) and renames the wire field to + * `unitsTotal` — it counts units, mapped to doses via + * `Medication.unitsPerDose`. */ + unitsTotal: z + .number() + .min(1) + .max(1000) + .describe( + "Units the container ships with (tablets / ampoules / puffs; 1–1000, fractional allowed for split-pill packs). Dose-derived readouts divide by the medication's `unitsPerDose`.", + ), + /** v1.16.10 — container kind. Defaults to OTHER when absent. */ + containerType: z + .enum(MEDICATION_CONTAINER_TYPE_VALUES) + .optional() + .describe( + "Kind of physical container (PEN / AMPOULE / BLISTER / INHALER / BOTTLE / OTHER). Display-level only; defaults to OTHER.", + ), + printedExpiry: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .nullable() + .optional(), + purchasedAt: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .nullable() + .optional(), + notes: z.string().max(200).nullable().optional(), + }) + .meta({ + id: "CreateMedicationInventoryItemRequest", + description: + "Register a new supply container (pen / blister pack / bottle). `unitsTotal` counts UNITS (1–1000); the item starts ACTIVE with `unitsRemaining = unitsTotal` and the intake consumption hook decrements it per taken dose.", + }); + +export const updateInventoryItemSchema = z + .object({ + markAsFirstUseAt: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .optional() + .describe( + "Manually start the 30-day in-use clock (the user opened the container without logging an intake). ACTIVE flips to IN_USE; a backdated instant whose window already lapsed lands EXPIRED.", + ), + markAsUsedUp: z + .boolean() + .optional() + .describe( + "Terminal override: zero the remaining units and mark the container USED_UP (physically discarded).", + ), + printedExpiry: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .nullable() + .optional(), + /** + * v1.16.1 — stock correction (the Bestand tab's adjust / withdraw + * flow). Sets the remaining-unit count directly; the route clamps to + * `unitsTotal` and re-runs the canonical state machine (0 ⇒ USED_UP, + * a raise out of 0 re-evaluates against the expiry clocks). + * v1.16.10 raises the cap to 1000 alongside the capacity cap and + * renames the wire field to `unitsRemaining` (it always counted + * units), matching the response side. + */ + unitsRemaining: z + .number() + .min(0) + .max(1000) + .optional() + .describe( + "Absolute remaining-unit correction (0–1000, fractional allowed). Clamped server-side to the item's `unitsTotal`; the canonical state machine re-derives the state (0 ⇒ USED_UP).", + ), + notes: z.string().max(200).nullable().optional(), + }) + .meta({ + id: "UpdateMedicationInventoryItemRequest", + description: + "Per-item inventory mutation: manual first-use, used-up override, printed-expiry correction, absolute remaining-unit correction, notes. Every field is optional and commutative.", + }); + +export type CreateInventoryItemInput = z.infer< + typeof createInventoryItemSchema +>; +export type UpdateInventoryItemInput = z.infer< + typeof updateInventoryItemSchema +>; + +/** + * v1.4.25 W21 Fix-K — `POST /api/medications/[id]/glp1` body validators. + * + * The convenience route accepts either a `doseChange` or an `inventory` + * payload (the route picks one). Both branches were hand-rolled + * `typeof === "number"` checks pre-Fix-K, which let `NaN`, `Infinity`, + * negative doses, and unbounded notes slip through. + * + * Bounds: + * - `doseValue` is finite, non-negative, capped at 100 mg (covers every + * real-world GLP-1 step with headroom). + * - `doseUnit` is a short string (mg / mcg / IE). + * - `note` is capped at 500 characters so the field can't be used as a + * blob smuggler. + * - `effectiveFrom` is constrained to a ±5-year window around now — + * a paper-record back-fill or a planned future step both fit, but + * "1970" / "9999" do not. + * - `delta` is a non-zero finite integer in [−100, 100] — the legacy + * ledger counts pens, and ±100 pens per correction stays plenty + * (deliberately NOT raised with the v1.16.10 per-item unit cap). + * - `reason` is a bounded string (the route logs it; raw blob bad). + */ +const MAX_DOSE_MG = 100; +const MAX_NOTE_CHARS = 500; +const MAX_REASON_CHARS = 200; +const MIN_EFFECTIVE_FROM = new Date("2020-01-01T00:00:00Z"); +const FIVE_YEARS_MS = 5 * 365 * 24 * 60 * 60 * 1000; + +export const glp1DoseChangePostSchema = z.object({ + effectiveFrom: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .refine((d) => d.getTime() >= MIN_EFFECTIVE_FROM.getTime(), { + message: "effectiveFrom must be on or after 2020-01-01", + }) + .refine((d) => d.getTime() <= Date.now() + FIVE_YEARS_MS, { + message: "effectiveFrom must be within 5 years of now", + }), + doseValue: z.number().finite().min(0).max(MAX_DOSE_MG), + doseUnit: z.string().min(1).max(10), + note: z.string().max(MAX_NOTE_CHARS).nullable().optional(), +}); + +/** + * DEPRECATED write path (v1.16.10) — the `inventory.delta` branch feeds + * the legacy `MedicationInventoryEvent` running-sum ledger. The per-item + * endpoints (`POST /api/medications/[id]/inventory`, + * `PATCH /api/medications/[id]/inventory/[itemId]`) replaced it; reads + * fall back to the ledger only while a medication has zero inventory + * items. New callers must register containers instead of posting deltas. + */ +export const glp1InventoryPostSchema = z.object({ + delta: z + .number() + .int() + .finite() + .min(-100) + .max(100) + .refine((n) => n !== 0, { message: "delta must be non-zero" }) + .describe( + "Deprecated since v1.16.10: pen-count delta on the legacy running-sum ledger. Register containers via the inventory endpoints instead; reads use the ledger only while the medication has no inventory items.", + ), + reason: z.string().min(1).max(MAX_REASON_CHARS), +}); + +export const glp1PostBodySchema = z + .object({ + doseChange: glp1DoseChangePostSchema.optional(), + inventory: glp1InventoryPostSchema.optional(), + }) + .refine((b) => Boolean(b.doseChange) !== Boolean(b.inventory), { + message: "Body must carry exactly one of doseChange or inventory", + }); + +export type Glp1DoseChangePostInput = z.infer; +export type Glp1InventoryPostInput = z.infer; +export type Glp1PostBodyInput = z.infer; diff --git a/src/lib/validations/medication/schedule.ts b/src/lib/validations/medication/schedule.ts new file mode 100644 index 000000000..9ee4ef4b4 --- /dev/null +++ b/src/lib/validations/medication/schedule.ts @@ -0,0 +1,220 @@ +import { z } from "zod/v4"; + +import { SCHEDULE_TYPES } from "@/lib/medications/scheduling/recurrence"; +import { RRULE_PROPS, doseWindowEntrySchema, timeRegex } from "./base"; + +export const scheduleSchema = z + .object({ + windowStart: z + .string() + .regex(timeRegex, "Format: HH:mm") + .describe( + "Legacy single-time-of-intake (HH:mm, user local). v1.5 keeps the field for backwards compatibility with pre-wizard iOS clients; the new `timesOfDay` array supersedes it.", + ), + windowEnd: z + .string() + .regex(timeRegex, "Format: HH:mm") + .describe( + "Legacy reminder-window upper bound (HH:mm). Used to derive the late-classification grace span when `reminderGraceMinutes` is null.", + ), + label: z + .string() + .max(50) + .optional() + .describe('Optional human label (e.g. "Morning", "Evening").'), + dose: z + .string() + .max(50) + .optional() + .describe( + "Per-schedule dose override. NULL means the schedule inherits `Medication.dose`.", + ), + daysOfWeek: z + .array(z.number().int().min(0).max(6)) + .optional() + .describe( + "Legacy day-of-week filter (0=Sunday..6=Saturday). v1.5 reads new writes through `rrule` first; this field is preserved for pre-v1.5 rows and is the input the route serialises into the persisted `days_of_week` string.", + ), + intervalWeeks: z + .number() + .int() + .min(1) + .max(4) + .optional() + .describe( + "Legacy multi-week stride (1..4). Bi-weekly + tri-weekly were broken in the pre-v1.5 reminder worker; new writes encode the same intent via `rrule` (e.g. `FREQ=WEEKLY;INTERVAL=2;BYDAY=WE`).", + ), + /** + * v1.5 — first-class times-of-day. One or more HH:mm entries in + * the user's wall-clock; the engine applies them per matched day. + * Empty array falls back to `[windowStart]` for backwards-compat. + */ + timesOfDay: z + .array(z.string().regex(timeRegex, "Format: HH:mm")) + .max(8) + .optional() + .describe( + "v1.5 first-class points-in-time the dose is taken (HH:mm, user local). Up to 8 entries. Absent or empty means the route stamps `[windowStart]` so the new engine always sees a populated array.", + ), + /** + * v1.5 — reminder grace window (minutes). Replaces the implicit + * `windowEnd - windowStart` span for late-classification. NULL + * falls back to the legacy span. Capped at 24 hours. + */ + reminderGraceMinutes: z + .number() + .int() + .min(1) + .max(24 * 60) + .optional() + .describe( + "Reminder grace window in minutes. Caps at 24h. NULL falls back to the legacy `windowEnd - windowStart` span.", + ), + /** + * v1.5 — RFC 5545 RRULE string for calendar-anchored cadences. + * Mutually exclusive with `rollingIntervalDays`. + */ + rrule: z + .string() + .max(200) + .regex(RRULE_PROPS, "Invalid RRULE") + .optional() + .describe( + "RFC 5545 RRULE string (subset). Use for daily / weekly-with-BYDAY / multi-week / monthly / yearly cadences. Mutually exclusive with `rollingIntervalDays`. Examples: `FREQ=DAILY`, `FREQ=WEEKLY;BYDAY=MO,WE,FR`, `FREQ=WEEKLY;INTERVAL=2;BYDAY=WE`, `FREQ=MONTHLY;BYMONTHDAY=1`, `FREQ=YEARLY;BYMONTH=1;BYMONTHDAY=1`.", + ), + /** + * v1.5 — flexible-rolling interval in days, counted from the + * latest MedicationIntakeEvent.takenAt. Mutually exclusive with + * `rrule`. Range 1..365 days. + */ + rollingIntervalDays: z + .number() + .int() + .min(1) + .max(365) + .optional() + .describe( + "Flexible-rolling interval in days, counted forward from the latest `MedicationIntakeEvent.takenAt` (the dose re-anchors when logged). Mutually exclusive with `rrule`. Range 1..365.", + ), + /** + * v1.7.0 — schedule-type discriminator. SCHEDULED (default) keeps the + * rrule / rolling / legacy cadence. PRN is as-needed (never projected, + * reminded, or counted in compliance expected; still loggable). + * CYCLIC wraps the inner cadence with an N-on / M-off week phase. + */ + scheduleType: z + .enum(SCHEDULE_TYPES) + .optional() + .describe( + "Schedule type. SCHEDULED (default) = rrule / rolling / legacy cadence. PRN = as-needed (never projected, reminded, or counted in compliance expected; still loggable via the intake route). CYCLIC = N weeks on / M weeks off, gating whichever inner cadence the rrule / legacy fields describe.", + ), + /** v1.7.0 — cyclic "on" weeks. Required when `scheduleType === "CYCLIC"`. */ + cyclicOnWeeks: z + .number() + .int() + .min(1) + .max(52) + .optional() + .describe( + 'Cyclic "on" weeks (1..52). Required when `scheduleType` is CYCLIC; ignored otherwise.', + ), + /** v1.7.0 — cyclic "off" weeks. Required when `scheduleType === "CYCLIC"`. */ + cyclicOffWeeks: z + .number() + .int() + .min(0) + .max(52) + .optional() + .describe( + 'Cyclic "off" weeks (0..52). Required when `scheduleType` is CYCLIC; ignored otherwise.', + ), + /** + * v1.15.18 — per-dose configurable on-time intake window (the maintainer's + * "07:00–09:00" lever). One entry per dose time the user wants an explicit + * range for; a `timeOfDay` with no entry keeps the symmetric ±1h default. + * Each `timeOfDay` MUST match one of the schedule's `timesOfDay` (or the + * legacy `windowStart`), and `start <= end` within the day. Absent → every + * slot uses the default derivation (unchanged behaviour). + */ + doseWindows: z + .array(doseWindowEntrySchema) + .max(8) + .optional() + .describe( + "Per-dose on-time intake windows. Each `{ timeOfDay, start, end }` HH:mm triple sets the explicit on-time band for the matching dose time; a dose time with no entry keeps the symmetric ±1h default. `timeOfDay` must match one of `timesOfDay` (or `windowStart`); `start <= end`. Up to 8 entries. Absent leaves every slot on the default derivation. The late tail stays cadence-derived.", + ), + }) + .refine((s) => !(s.rrule && s.rollingIntervalDays), { + message: "A schedule can be calendar-anchored (rrule) or rolling, not both", + path: ["rrule"], + }) + .refine( + (s) => + s.scheduleType !== "CYCLIC" || + (s.cyclicOnWeeks !== undefined && s.cyclicOffWeeks !== undefined), + { + message: "cyclic schedules require both cyclicOnWeeks and cyclicOffWeeks", + path: ["cyclicOnWeeks"], + }, + ) + .refine( + (s) => + s.scheduleType !== "PRN" || + (s.rrule === undefined && s.rollingIntervalDays === undefined), + { + message: + "PRN schedules cannot carry a cadence (rrule or rollingIntervalDays)", + path: ["scheduleType"], + }, + ) + .refine( + (s) => + s.rollingIntervalDays === undefined || + s.rollingIntervalDays === null || + !s.timesOfDay || + s.timesOfDay.length <= 1, + { + message: "rolling-cadence schedules accept at most one time of day", + path: ["timesOfDay"], + }, + ) + .refine( + (s) => { + // Every per-dose window must name a real dose time. The effective dose + // times are `timesOfDay` when set, else the single legacy `windowStart` + // (mirrors the engine's `effectiveTimesOfDay`). + if (!s.doseWindows || s.doseWindows.length === 0) return true; + const times = new Set( + s.timesOfDay && s.timesOfDay.length > 0 + ? s.timesOfDay + : [s.windowStart], + ); + return s.doseWindows.every((w) => times.has(w.timeOfDay)); + }, + { + message: + "Each doseWindows.timeOfDay must match one of the schedule's timesOfDay", + path: ["doseWindows"], + }, + ) + .refine( + (s) => { + // A dose time may carry at most one explicit window. + if (!s.doseWindows || s.doseWindows.length === 0) return true; + const seen = new Set(); + for (const w of s.doseWindows) { + if (seen.has(w.timeOfDay)) return false; + seen.add(w.timeOfDay); + } + return true; + }, + { + message: "doseWindows must not repeat a timeOfDay", + path: ["doseWindows"], + }, + ) + .meta({ + id: "MedicationScheduleInput", + description: + "Single schedule entry on a medication. v1.5 introduces `timesOfDay`, `rrule`, `rollingIntervalDays`, and `reminderGraceMinutes` as first-class fields; `windowStart`, `windowEnd`, `daysOfWeek`, and `intervalWeeks` are preserved through the v1.5.x line for backwards compatibility. **`rrule` and `rollingIntervalDays` are mutually exclusive** — supplying both fails 422 (`rrule_xor_rolling`). The DB enforces the same invariant via a CHECK constraint.", + }); From fa5c6500b579eb3497cd6b148bdf2bfb4b5e4a8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 21 Jun 2026 01:15:54 +0200 Subject: [PATCH 4/6] refactor(settings): split import-panel into per-source card components --- src/components/settings/import-panel.tsx | 920 ------------------ .../import-panel/apple-health-import-card.tsx | 274 ++++++ .../settings/import-panel/constants.ts | 6 + .../settings/import-panel/csv-import-card.tsx | 275 ++++++ .../import-panel/import-card-shell.tsx | 31 + .../settings/import-panel/import-examples.ts | 68 ++ .../settings/import-panel/index.tsx | 56 ++ .../import-panel/json-import-card.tsx | 235 +++++ 8 files changed, 945 insertions(+), 920 deletions(-) delete mode 100644 src/components/settings/import-panel.tsx create mode 100644 src/components/settings/import-panel/apple-health-import-card.tsx create mode 100644 src/components/settings/import-panel/constants.ts create mode 100644 src/components/settings/import-panel/csv-import-card.tsx create mode 100644 src/components/settings/import-panel/import-card-shell.tsx create mode 100644 src/components/settings/import-panel/import-examples.ts create mode 100644 src/components/settings/import-panel/index.tsx create mode 100644 src/components/settings/import-panel/json-import-card.tsx diff --git a/src/components/settings/import-panel.tsx b/src/components/settings/import-panel.tsx deleted file mode 100644 index c42fc41b4..000000000 --- a/src/components/settings/import-panel.tsx +++ /dev/null @@ -1,920 +0,0 @@ -"use client"; - -/** - * v1.15.7 — Settings → Export & Import → Import area. - * - * Web UI for the two existing import backends (issue #281: the routes - * shipped without a surface). Two clearly-separated controls: - * - * 1. Apple Health `export.zip` — multipart POST to - * `/api/import/apple-health-export` (field `file`), then poll - * `GET /api/import/apple-health-export/[jobId]/status` on a - * `refetchInterval` while the job runs, stopping on a terminal - * state (`done` / `failed`). The upload streams server-side so a - * multi-GB archive never buffers in the browser beyond the POST. - * Idempotent on the file's SHA-256 — re-uploading the same archive - * merges rather than duplicating. - * - * 2. Generic JSON import — upload a `.json` file OR paste JSON, - * validated client-side for parseability, POSTed to `/api/import`. - * A "Download example" button mints a small valid example as a Blob - * (no static asset). Returns `{ measurements, moodEntries, skipped }` - * counts. - * - * Both controls surface rate-limit (429) and error states cleanly and - * announce progress to assistive tech via `aria-live` regions. - */ - -import { useCallback, useId, useRef, useState } from "react"; -import Link from "next/link"; -import { useQuery } from "@tanstack/react-query"; -import { - AlertCircle, - CheckCircle2, - Download, - FileJson, - FileSpreadsheet, - Loader2, - Upload, -} from "lucide-react"; - -import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; -import { Progress } from "@/components/ui/progress"; -import { SettingsCard } from "@/components/settings/settings-card"; -import { Textarea } from "@/components/ui/textarea"; -import { queryKeys } from "@/lib/query-keys"; -import { useTranslations } from "@/lib/i18n/context"; -import { cn } from "@/lib/utils"; -import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch"; -import { CSV_EXAMPLE_COLUMNS } from "@/lib/import/csv-measurements"; - -/** - * Upper bound for the paste textareas, mirroring the 16 MB server-side body - * ceiling on `/api/import` and `/api/import/csv`. Caps an accidental over-paste - * before it ever reaches the route and feeds the live character counter. - */ -const MAX_PASTE_CHARS = 16 * 1024 * 1024; - -// ─────────────────────────── Section wrapper ─────────────────────────── - -export function ImportPanel() { - const { t } = useTranslations(); - return ( -
-
-

- {t("settings.sections.export.import.heading")} -

-

- {t("settings.sections.export.import.description")} -

-
-
- - - -
-
- ); -} - -// ─────────────────────────── Card shell ─────────────────────────── - -interface ImportCardShellProps { - testId: string; - icon: React.ComponentType<{ className?: string }>; - title: string; - description: string; - children: React.ReactNode; -} - -function ImportCardShell({ - testId, - icon: Icon, - title, - description, - children, -}: ImportCardShellProps) { - return ( - -
-
-

{description}

-
{children}
-
- ); -} - -// ─────────────────────────── Apple Health import ─────────────────────────── - -/** Terminal states the status poll stops on. */ -const TERMINAL_STATES: readonly string[] = ["done", "failed"]; - -interface JobStatus { - jobId: string; - status: string; - progress: { - currentPhase?: string; - recordsRead?: number; - rowsUpserted?: number; - percent?: number | null; - } | null; - result: { - totals?: { recordsRead?: number; rowsUpserted?: number }; - clinical?: { skipped?: number }; - } | null; - failureReason: string | null; -} - -function AppleHealthImportCard() { - const { t } = useTranslations(); - const fileInputRef = useRef(null); - const [jobId, setJobId] = useState(null); - const [uploading, setUploading] = useState(false); - const [uploadError, setUploadError] = useState(null); - const [dragActive, setDragActive] = useState(false); - const dropDescId = useId(); - - const statusQuery = useQuery({ - queryKey: queryKeys.importJobStatus(jobId ?? "none"), - enabled: jobId !== null, - refetchInterval: (query) => { - const data = query.state.data as JobStatus | undefined; - if (data && TERMINAL_STATES.includes(data.status)) return false; - return 2000; - }, - queryFn: async (): Promise => { - return apiGet( - `/api/import/apple-health-export/${jobId}/status`, - { credentials: "include" }, - ); - }, - }); - - const upload = useCallback( - async (file: File) => { - setUploadError(null); - setUploading(true); - setJobId(null); - try { - const form = new FormData(); - form.append("file", file); - const res = await apiFetchRaw("/api/import/apple-health-export", { - method: "POST", - credentials: "include", - body: form, - }); - if (res.status === 429) { - setUploadError( - t("settings.sections.export.import.appleHealth.rateLimited"), - ); - return; - } - if (res.status === 413) { - setUploadError( - t("settings.sections.export.import.appleHealth.tooLarge"), - ); - return; - } - if (!res.ok) { - setUploadError( - t("settings.sections.export.import.appleHealth.uploadFailed"), - ); - return; - } - const body = (await res.json()).data as { - jobId: string; - status: string; - }; - if (!body?.jobId) { - setUploadError( - t("settings.sections.export.import.appleHealth.uploadFailed"), - ); - return; - } - setJobId(body.jobId); - } catch { - setUploadError( - t("settings.sections.export.import.appleHealth.uploadFailed"), - ); - } finally { - setUploading(false); - } - }, - [t], - ); - - function onFileChange(e: React.ChangeEvent) { - const file = e.target.files?.[0]; - if (file) void upload(file); - // Reset so picking the same file twice re-fires the change event. - e.target.value = ""; - } - - function onDrop(e: React.DragEvent) { - e.preventDefault(); - setDragActive(false); - const file = e.dataTransfer.files?.[0]; - if (file) void upload(file); - } - - const status = statusQuery.data ?? null; - const isRunning = status !== null && !TERMINAL_STATES.includes(status.status); - const isDone = status?.status === "done"; - const isFailed = status?.status === "failed"; - const busy = uploading || isRunning; - const percent = status?.progress?.percent ?? null; - - return ( - - {/* Keyboard-operable drop area. The hidden file input does the - actual file selection; the div forwards Enter/Space to it. */} -
!busy && fileInputRef.current?.click()} - onKeyDown={(e) => { - if ((e.key === "Enter" || e.key === " ") && !busy) { - e.preventDefault(); - fileInputRef.current?.click(); - } - }} - onDragOver={(e) => { - e.preventDefault(); - if (!busy) setDragActive(true); - }} - onDragLeave={() => setDragActive(false)} - onDrop={(e) => !busy && onDrop(e)} - className={cn( - "border-border bg-muted/20 hover:bg-muted/40 focus-visible:ring-ring/50 flex min-h-24 cursor-pointer flex-col items-center justify-center gap-1 rounded-lg border border-dashed p-4 text-center transition-colors focus-visible:ring-2 focus-visible:outline-none", - dragActive && "border-primary bg-primary/5", - busy && "pointer-events-none opacity-60", - )} - > -
- - -

- {t("settings.sections.export.import.appleHealth.idempotencyNote")} -

- - {/* Live progress / outcome — announced to assistive tech. */} -
- {uploading && ( -

- - {t("settings.sections.export.import.appleHealth.uploading")} -

- )} - {isRunning && ( -
-

- - {t( - `settings.sections.export.import.appleHealth.phase.${status?.status ?? "queued"}`, - )} -

- {typeof percent === "number" && } - {typeof status?.progress?.rowsUpserted === "number" && ( -

- {t("settings.sections.export.import.appleHealth.rowsImported", { - count: status.progress.rowsUpserted, - })} -

- )} -
- )} - {isDone && ( -
-
- )} - {(isFailed || uploadError) && ( -

-

- )} -
- -
- -
-
- ); -} - -// ─────────────────────────── Generic JSON import ─────────────────────────── - -/** - * Small valid example payload, minted as a downloadable Blob by the - * "Download example" button and used by the docs. Exported so a test can - * assert it stays a valid import body — the button and the route schema - * must never drift. - */ -export const EXAMPLE_IMPORT = { - measurements: [ - { - type: "WEIGHT", - value: 80.5, - unit: "kg", - measuredAt: "2026-05-01T08:00:00.000Z", - source: "manual", - notes: "morning", - }, - { - type: "BLOOD_PRESSURE_SYS", - value: 120, - unit: "mmHg", - measuredAt: "2026-05-01T08:05:00.000Z", - }, - { - type: "BLOOD_PRESSURE_DIA", - value: 80, - unit: "mmHg", - measuredAt: "2026-05-01T08:05:00.000Z", - }, - ], - moodEntries: [ - { - date: "2026-05-01", - mood: "GUT", - score: 4, - tags: "work,exercise", - }, - ], -}; - -interface JsonImportResult { - measurements: number; - moodEntries: number; - skipped: number; -} - -/** - * Client-side parse guard for the JSON-import textarea. Returns the - * parsed value when the text is valid JSON, otherwise a failure marker — - * we never POST an unparseable body. Exported for unit testing so the - * guard is covered without a browser. - */ -export function parseImportJson( - text: string, -): { ok: true; value: unknown } | { ok: false } { - try { - return { ok: true, value: JSON.parse(text) }; - } catch { - return { ok: false }; - } -} - -function JsonImportCard() { - const { t } = useTranslations(); - const fileInputRef = useRef(null); - const [text, setText] = useState(""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [result, setResult] = useState(null); - const textareaId = useId(); - - async function onFileChange(e: React.ChangeEvent) { - const file = e.target.files?.[0]; - e.target.value = ""; - if (!file) return; - setError(null); - setResult(null); - try { - const content = await file.text(); - setText(content); - } catch { - setError(t("settings.sections.export.import.json.readFailed")); - } - } - - function downloadExample() { - const blob = new Blob([JSON.stringify(EXAMPLE_IMPORT, null, 2)], { - type: "application/json", - }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "healthlog-import-example.json"; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); - } - - async function handleImport() { - setError(null); - setResult(null); - const parsed = parseImportJson(text); - if (!parsed.ok) { - setError(t("settings.sections.export.import.json.invalidJson")); - return; - } - const payload = parsed.value; - setBusy(true); - try { - const res = await apiFetchRaw("/api/import", { - method: "POST", - headers: { "Content-Type": "application/json" }, - credentials: "include", - body: JSON.stringify(payload), - }); - if (res.status === 429) { - setError(t("settings.sections.export.import.json.rateLimited")); - return; - } - if (res.status === 422) { - const body = await res.json().catch(() => null); - setError( - body?.error ?? - t("settings.sections.export.import.json.invalidPayload"), - ); - return; - } - if (!res.ok) { - setError(t("settings.sections.export.import.json.failed")); - return; - } - const data = (await res.json()).data as JsonImportResult; - setResult({ - measurements: data?.measurements ?? 0, - moodEntries: data?.moodEntries ?? 0, - skipped: data?.skipped ?? 0, - }); - } catch { - setError(t("settings.sections.export.import.json.failed")); - } finally { - setBusy(false); - } - } - - return ( - -
- -