diff --git a/src/__tests__/idempotency-contract-publication.test.ts b/src/__tests__/idempotency-contract-publication.test.ts index eb5d93a45..442aadbbd 100644 --- a/src/__tests__/idempotency-contract-publication.test.ts +++ b/src/__tests__/idempotency-contract-publication.test.ts @@ -107,20 +107,24 @@ function idempotentOperationsFromContract(): Set { * Idempotent operations that cannot be published, because the PATH is not in * the document at all. A header cannot hang on a path that does not exist. * - * Two entries, and the list was four until the test below refused it. The first + * One entry, and the list was four until the test below refused it. The first * draft claimed the whole `/api/medications` family was missing, on the * strength of a grep for path literals in `src/lib/openapi/routes/` that came * back empty. It came back empty because those paths are not written as - * searchable literals there — the emitted document carries 19 of them, - * including every one the iOS client actually argues about. Measure the - * artefact, not the source that builds it. + * searchable literals there — the emitted document carries them, including + * every one the iOS client actually argues about. Measure the artefact, not + * the source that builds it. + * + * `/api/insights/feedback` left this list when the route joined the route + * table, and the leg below is what made that a diff rather than a permanent + * exemption: the moment the path appeared in the document, the entry claiming + * it could not be published became false and the run went red. An exemption + * nobody removes is how a gap becomes furniture. * * Listed rather than filtered silently, so the exclusion stays visible in the * file that would otherwise claim full coverage. */ const UNPUBLISHED_PATHS: Readonly> = { - "/api/insights/feedback": - "Coach feedback write; never added to the route table. A real gap, small.", "/api/admin/backups/{id}/restore": "Admin surface: cookie-only by construction, so arguably out of scope for a client contract.", }; diff --git a/src/__tests__/sharing-surface-guard.test.ts b/src/__tests__/sharing-surface-guard.test.ts index 09cd78e1b..97c1499e0 100644 --- a/src/__tests__/sharing-surface-guard.test.ts +++ b/src/__tests__/sharing-surface-guard.test.ts @@ -1214,7 +1214,7 @@ const DELEGABLE_ROUTES: Record = { }, "app/api/medications/[id]/glp1/route.ts": { domain: "medications", - why: "Recording a titration step. Reached only through this module's MANAGE arm; the argument for admitting it is the manage literal's reason line, and the entry here is what leg (a) freezes and leg (f) checks the section against.", + why: "Reading the titration history, the recent injections and the supply math, and recording a titration step. The READ arm joined the MANAGE one when the route was published: the write had resolved the record since v1.37.0 while the read still resolved the caller, so a manager could add a dose the history refused them — may write, may not read, which the model does not produce. The read is at the level `/inventory`, `/side-effects` and `/cadence` use for the same medication's data and is strictly narrower than the write already admitted; the argument for the write is the manage literal's reason line.", }, "app/api/medications/[id]/intake/[eventId]/route.ts": { domain: "medications", diff --git a/src/app/api/insights/coach/seeded-question/route.ts b/src/app/api/insights/coach/seeded-question/route.ts index 457099e22..b8986bc1f 100644 --- a/src/app/api/insights/coach/seeded-question/route.ts +++ b/src/app/api/insights/coach/seeded-question/route.ts @@ -9,9 +9,11 @@ * keeps its neutral greeting — never a fabricated opener. * * The signal is selected SERVER-SIDE; the client renders the resolved DTO - * and never recomputes it (server-authoritative parity). This is an - * INTERNAL same-origin route — it is deliberately NOT registered in the - * OpenAPI contract the iOS client consumes. + * and never recomputes it (server-authoritative parity). It IS registered in + * the OpenAPI contract. This comment said the opposite until the route was + * published — it shipped as a same-origin web surface and the sentence was + * never revisited. A comment that contradicts the registry is worse than no + * comment, because the next reader believes it instead of checking. * * Mirrors the sibling derived routes: `apiHandler` wrapper, `requireAuth` * (userId narrowed from the session/Bearer, never a body/query field), diff --git a/src/app/api/medications/[id]/glp1/route.ts b/src/app/api/medications/[id]/glp1/route.ts index c9f76d0c5..180badac9 100644 --- a/src/app/api/medications/[id]/glp1/route.ts +++ b/src/app/api/medications/[id]/glp1/route.ts @@ -15,7 +15,7 @@ */ import { prisma } from "@/lib/db"; -import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler"; +import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { auditLog } from "@/lib/auth/audit"; import { annotate } from "@/lib/logging/context"; import { @@ -53,7 +53,16 @@ const POST_WINDOW_MS = 60_000; export const GET = apiHandler( async (_request: NextRequest, { params }: RouteParams) => { - const { user } = await requireAuth(); + // The read resolves the RECORD, at the level its siblings use. It did not + // until the route was published, and the asymmetry that produced is the + // reason it is written down here: the POST below has resolved the record + // since v1.37.0, so a manager could add a titration step to a medication + // whose history the GET refused them — may write, may not read, which is + // not a shape any reading of the sharing model produces. `read` rather + // than the POST's `manage` because that is what `/inventory`, + // `/side-effects` and `/cadence` ask for the same medication's data, and + // this read is strictly narrower than the write already permitted. + const { user } = await requireRecordAuth("read", "medications"); const { id } = await params; // v1.5.5 F-1 C-4 — close the §10 invariant 24 gap. The destructive diff --git a/src/app/api/medications/intake/route.ts b/src/app/api/medications/intake/route.ts index 4c39b59d7..fd0b5c2ca 100644 --- a/src/app/api/medications/intake/route.ts +++ b/src/app/api/medications/intake/route.ts @@ -12,7 +12,6 @@ * Updates the named MedicationIntakeEvent and returns the updated row. */ import { NextRequest } from "next/server"; -import { z } from "zod/v4"; import { apiHandler, requireRecordAuth } from "@/lib/api-handler"; import { apiError, @@ -46,7 +45,8 @@ import { restoreForIntake, } from "@/lib/medications/inventory/consumption"; import { - injectionSiteEnum, + intakeAggregateQuerySchema, + intakeStatusUpdateSchema, type InjectionSiteValue, } from "@/lib/validations/medication"; import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync"; @@ -54,40 +54,15 @@ import { dispatchMedicationIntakeWebClear } from "@/lib/notifications/web-push-c import { notifyDelegatedIntake } from "@/lib/notifications/delegated-intake"; import { countOutstandingDosesToday } from "@/lib/medications/outstanding-doses"; -const querySchema = z.object({ - scope: z.enum(["today", "compliance"]), - days: z.coerce.number().int().min(1).max(365).optional().default(30), -}); - -const updateSchema = z.object({ - intakeId: z.string().min(1), - status: z.enum(["taken", "skipped", "snoozed"]), - takenAt: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .optional(), - snoozedUntil: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .optional(), - // v1.8.5 — optional injection site captured alongside a "taken" - // status toggle. Validated server-side against the medication's - // effective allowed set; ignored for skipped / snoozed. - injectionSite: injectionSiteEnum.optional(), - // v1.15.18 — late-take "attribute anyway" pin. When a "taken" toggle lands - // outside every dose window the UI can offer to pin the take onto a chosen - // real slot ("diesem Slot zuordnen?"); the server validates the instant is - // a real slot of the medication (422 otherwise). Ignored for skip / snooze. - forceSlotInstant: z.iso - .datetime({ offset: true }) - .transform((s) => new Date(s)) - .optional(), -}); +// The query + body schemas moved to `@/lib/validations/medication` when this +// endpoint joined the published contract: the OpenAPI registry generates from +// the validation modules, and a schema that only exists inside a handler is +// one the spec has to restate — which is a second copy to keep in step. export const GET = apiHandler(async (request: NextRequest) => { const { user } = await requireRecordAuth("read", "medications"); - const parsed = querySchema.safeParse( + const parsed = intakeAggregateQuerySchema.safeParse( Object.fromEntries(request.nextUrl.searchParams), ); if (!parsed.success) { @@ -233,7 +208,7 @@ export const POST = apiHandler(async (request: NextRequest) => { }); if (error) return error; - const parsed = updateSchema.safeParse(body); + const parsed = intakeStatusUpdateSchema.safeParse(body); if (!parsed.success) { // v1.4.43 W6 — intake-event update hot path; multi-issue 422 + // audit breadcrumb keyed `medications.intake.update.validation-failed`. diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index 71706c202..6ed940018 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -27,6 +27,7 @@ import { ACCEPTED_INSIGHTS_TILE_IDS, INSIGHTS_SECTION_IDS, } from "@/lib/insights-layout"; +import { COACH_CONVERSATION_TITLE_MAX } from "@/lib/ai/coach/types"; import { COACH_FACT_CATEGORIES } from "@/lib/ai/coach/facts"; import { COACH_PLAN_STATUSES } from "@/lib/ai/coach/plans"; import { @@ -691,6 +692,22 @@ const coachConversationDetailSchema = z "Full conversation with every message decrypted server-side. The client renders the bodies directly; no decryption key is involved.", }); +const renameCoachConversationRequest = z + .object({ + title: z + .string() + .min(1) + .max(COACH_CONVERSATION_TITLE_MAX) + .describe( + "The new title. Trimmed before validation, so whitespace alone is refused as empty. 1..80 characters.", + ), + }) + .meta({ + id: "RenameCoachConversationRequest", + description: + "Rename body. Strict — an unrecognised key is refused rather than dropped, which is not how most partial updates on this surface behave.", + }); + const coachConversationDeletedResponse = z.object({ deleted: z .literal(true) @@ -851,6 +868,52 @@ export const coachPaths: NonNullable = { ...stdResponses, }, }, + patch: { + tags: ["Insights"], + summary: "Rename one Coach conversation", + description: + "Sets the conversation's title. The only field this verb writes — the body is `.strict()`, so an unrecognised key is REFUSED rather than ignored, unlike most partial updates on this surface. The title is trimmed and must be 1..80 characters after trimming. A foreign or unknown id maps to 404 (never 403), so the existence channel does not leak across accounts. Coach-gated. Auth via cookie or Bearer; the caller is always resolved as themselves, so a conversation in a shared record is not reachable here.", + parameters: [ + { + name: "id", + in: "path", + required: true, + schema: { type: "string" }, + description: "Conversation id.", + }, + ], + requestBody: { + required: true, + content: { + "application/json": { schema: renameCoachConversationRequest }, + }, + }, + responses: { + "200": { + description: + "The new id and title. Deliberately NOT the full conversation — nothing else changed, and re-sending the messages on a rename would be a large body for no new information.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ id: z.string(), title: z.string() }), + "CoachConversationRenamed", + ), + }, + }, + }, + "404": { + description: "Conversation not found or not owned by the caller.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "422": { + description: + "The body failed validation — an empty or over-long title, or an unrecognised key. Multi-issue envelope with `meta.errorCode` = `coach.conversation.invalidTitle`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "401": stdResponses["401"], + "429": stdResponses["429"], + }, + }, delete: { tags: ["Insights"], summary: "Delete one Coach conversation", diff --git a/src/lib/openapi/routes/cycle.ts b/src/lib/openapi/routes/cycle.ts index 4f4956460..e4dacb5f5 100644 --- a/src/lib/openapi/routes/cycle.ts +++ b/src/lib/openapi/routes/cycle.ts @@ -24,6 +24,10 @@ import { cyclePeriodSchema, cyclePrefsSchema, } from "@/lib/validations/cycle"; +import { + createCustomSymptomSchema, + updateCustomSymptomSchema, +} from "@/lib/cycle/custom-symptoms-shared"; import { dataEnvelope, errorEnvelope, @@ -434,7 +438,219 @@ const cycleDisabledOnADelegableRoute = recordRefusal( CYCLE_DISABLED_DESCRIPTION, ); +// ── Custom symptom catalogue (v1.15.1) ─────────────────────────────── +// The per-record symptom vocabulary the log-day sheet merges into the seeded +// chip grid. The label is intent-revealing free text, so it is encrypted at +// rest and never reaches a wide event or an audit excerpt — only the icon and +// the minted key do. + +// `.meta()` RETURNS a new schema in Zod 4 rather than annotating in place, so +// the result has to be captured and referenced. A bare `schema.meta({...})` +// statement registers nothing, and the component id it names never reaches +// the document. +const createCycleCustomSymptomRequest = createCustomSymptomSchema.meta({ + id: "CreateCycleCustomSymptomRequest", + description: + "Create-a-custom-symptom body. `label` is trimmed and must be 1..40 characters. `icon` is optional and must name one of the twenty allow-listed Lucide icons (unknown names 422 rather than falling back). `categoryKey` is reserved for a future per-symptom category choice and today accepts only the literal `custom`. The minted key, the sort order and the owning record all come from the server.", +}); + +const updateCycleCustomSymptomRequest = updateCustomSymptomSchema.meta({ + id: "UpdateCycleCustomSymptomRequest", + description: + "Partial custom-symptom edit; at least one of `label` / `icon` / `isActive` is required. `isActive: false` hides the symptom while every day log that references it stays intact; `isActive: true` brings it back, and the per-record cap is re-checked on the way in so hiding and re-enabling cannot be used to exceed it.", +}); + +const cycleCustomSymptom = z + .object({ + key: z + .string() + .describe( + "The minted `custom:` key. Stable, and the value a day-log's symptom list carries for this symptom.", + ), + label: z + .string() + .nullable() + .describe( + "The decrypted label. Null when the stored ciphertext could not be decrypted — one unreadable row fails soft rather than taking the whole catalogue read down, and the client falls back to the generic label.", + ), + icon: z + .string() + .nullable() + .describe( + "Lucide icon name from the closed allow-list, or null. The allow-list is limited to names the iOS client maps to an SF Symbol, so a custom symptom never falls back to the generic glyph on one platform.", + ), + custom: z + .literal(true) + .describe( + "Always true. Lets a client merge this list into the seeded catalogue and still tell the two apart.", + ), + }) + .meta({ + id: "CycleCustomSymptom", + description: + "One user-minted cycle symptom. The label is stored encrypted and decrypted on read; the key is what a day log references.", + }); + export const cyclePaths: NonNullable = { + "/api/cycle/symptoms/custom": { + get: { + tags: ["Cycle"], + summary: "List the record's custom cycle symptoms (v1.15.1)", + description: + "Returns the active custom symptoms in `sortOrder`, labels decrypted, so the log-day sheet can merge them into the seeded chip grid. Deactivated symptoms are excluded. Gated: `cycle.disabled` 403 when cycle tracking is off for the RECORD. Delegable at READ level over the `cycle` section. Cookie or Bearer auth.", + responses: { + "200": { + description: "The active custom symptoms (possibly empty).", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ symptoms: z.array(cycleCustomSymptom) }), + "CycleCustomSymptomListEnvelope", + ), + }, + }, + }, + ...cycleDisabledOnADelegableRoute, + ...stdResponses, + }, + }, + post: { + tags: ["Cycle"], + summary: "Create a custom cycle symptom (v1.15.1)", + description: + "Mints a `custom:` key, encrypts the label at rest, and stores the row under the global `custom` category owned by the record. Returns 201 with the created symptom, the submitted label echoed back in plaintext. Capped at 50 active custom symptoms per record. Rate-limited 30/min keyed on the ACTOR, so a delegate burns their own allowance rather than locking the owner out and cannot collect a fresh one by switching records. Audits as `cycle.symptom.custom.create` with the icon only — never the label. Delegable at MANAGE level over the `cycle` section: the record's own symptom vocabulary is what the day-log writes need in order to say anything.", + requestBody: { + required: true, + content: { + "application/json": { schema: createCycleCustomSymptomRequest }, + }, + }, + responses: { + "201": { + description: "The created custom symptom.", + content: { + "application/json": { + schema: dataEnvelope( + cycleCustomSymptom, + "CycleCustomSymptomCreatedEnvelope", + ), + }, + }, + }, + ...cycleDisabledOnADelegableRoute, + ...stdResponses, + "422": { + description: + "Either the body failed validation — the multi-issue envelope with `meta.errorCode` = `cycle.symptom.custom.invalid` (an empty or over-40-character label, an icon outside the allow-list, a `categoryKey` other than `custom`) — or the record already holds 50 active custom symptoms, in which case `meta.errorCode` = `cycle.symptom.custom.limit` and there is no issue list. Branch on the code, not on the presence of issues.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: + "More than 30 creates in the trailing minute for this ACTOR (`cycle:symptom:custom:`).", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, + "/api/cycle/symptoms/custom/{key}": { + patch: { + tags: ["Cycle"], + summary: "Rename, re-icon or hide a custom cycle symptom (v1.15.1)", + description: + "Edits one custom symptom the CALLER owns. The key must carry the `custom:` prefix — a catalogue key is refused as not-found before anything is read — and it is resolved against the caller's own rows only, so another account's key is a 404 and never a 403. A re-activation re-checks the 50-symptom cap, because a hidden row does not count toward it and skipping the check would make hide-then-enable a way around the limit. The audit row records which FIELDS the write touched and the resulting active state, never the decrypted label. Rate-limited 30/min. Gated: `cycle.disabled` 403 when cycle tracking is off. NOT delegable — unlike the collection this hangs off, both verbs here resolve the caller as themselves, so a delegate can create a symptom in a shared record and cannot then rename or hide it. Cookie or Bearer auth.", + requestParams: { + path: z.object({ + key: z + .string() + .describe("The `custom:` key. A catalogue key 404s."), + }), + }, + requestBody: { + required: true, + content: { + "application/json": { schema: updateCycleCustomSymptomRequest }, + }, + }, + responses: { + "200": { + description: + "The edited symptom, with the stored label decrypted and the resulting `isActive` — a field the create response does not carry.", + content: { + "application/json": { + schema: dataEnvelope( + cycleCustomSymptom.extend({ isActive: z.boolean() }), + "CycleCustomSymptomUpdatedEnvelope", + ), + }, + }, + }, + "404": { + description: + "The key is not a `custom:` key, or names no symptom this account owns. The two are indistinguishable.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...cycleDisabledResponse, + ...stdResponses, + "422": { + description: + "Either the body failed validation — the multi-issue envelope with `meta.errorCode` = `cycle.symptom.custom.invalid` — or re-activating would exceed the 50 active custom symptoms, in which case `meta.errorCode` = `cycle.symptom.custom.limit` and there is no issue list.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: + "More than 30 edits or deletes in the trailing minute for this account (`cycle:symptom:custom:`, shared with the DELETE below — and note this bucket keys on the USER, where the create on the collection keys on the ACTOR).", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + delete: { + tags: ["Cycle"], + summary: "Hide or purge a custom cycle symptom (v1.15.1)", + description: + "Two different operations behind one verb, chosen by `purge`. By DEFAULT this is not a delete at all: the row is deactivated, so it leaves the chip grid while every day log that recorded it keeps its reference and its history reads unchanged. With `purge=true` the row is HARD-deleted and the foreign-key cascade takes every `cycle_symptom_link` with it — the days that recorded the symptom lose that fact, and nothing reconstructs it. There is no confirmation step on the wire and no undo. The response reports which of the two happened in `purged`. The key must carry the `custom:` prefix and resolves against the caller's own rows only. The audit row records the mode and nothing else — never the decrypted label. Rate-limited 30/min. Gated: `cycle.disabled` 403. NOT delegable. Cookie or Bearer auth.", + requestParams: { + path: z.object({ + key: z + .string() + .describe("The `custom:` key. A catalogue key 404s."), + }), + query: z.object({ + purge: z + .literal("true") + .optional() + .describe( + "Send exactly `true` for the hard delete with its cascade. Any other value, and omitting it, deactivates instead — the test is an equality against the string, not a boolean parse.", + ), + }), + }, + responses: { + "200": { + description: + "Done. `purged: true` means the row and its day-log links are gone; `false` means it was deactivated and its history is intact.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ key: z.string(), purged: z.boolean() }), + "CycleCustomSymptomDeletedEnvelope", + ), + }, + }, + }, + "404": { + description: + "The key is not a `custom:` key, or names no symptom this account owns.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...cycleDisabledResponse, + ...stdResponses, + "429": { + description: + "More than 30 edits or deletes in the trailing minute for this account.", + content: { "application/json": { schema: errorEnvelope } }, + }, + }, + }, + }, "/api/cycle/day-logs": { get: { tags: ["Cycle"], diff --git a/src/lib/openapi/routes/dashboard.ts b/src/lib/openapi/routes/dashboard.ts index 34950a54d..e9dc74148 100644 --- a/src/lib/openapi/routes/dashboard.ts +++ b/src/lib/openapi/routes/dashboard.ts @@ -1,5 +1,6 @@ /** - * OpenAPI route table — dashboard widget layout (`/api/dashboard/widgets`). + * OpenAPI route table — dashboard widget layout (`/api/dashboard/widgets`) + * and the native-client aggregator (`/api/dashboard/summary`). * * v1.32.21 (R5a) — this endpoint was ENTIRELY absent from the registry * even though it is the reference implementation for the optimistic- @@ -25,6 +26,7 @@ import { HERO_PRIMARY_CONTENTS, } from "@/lib/dashboard-layout"; import { PRIORITY_ITEM_KINDS } from "@/lib/daily/priority-item"; +import { sleepRhythmResponse } from "./measurements"; import { baseUpdatedAtField, conflictResponse409, @@ -106,7 +108,265 @@ const dashboardLayoutResult = dashboardLayoutSchema "Resolved dashboard layout plus the optimistic-concurrency `updatedAt` token.", }); +// ── The native-client dashboard aggregator ─────────────────────────── +// +// `GET /api/dashboard/summary` predates the registry and is the shape the iOS +// DashboardSummary view decodes. It is a sibling of `/api/dashboard/snapshot` +// and NOT the same payload: the snapshot serves the web first paint from the +// shared builder, this one assembles its own bounded SQL aggregates and +// normalises every metric to an iOS-friendly `kind`. + +const dashboardMetricKind = z + .enum([ + "weight", + "bloodPressure", + "pulse", + "bodyFat", + "glucose", + "sleep", + "steps", + "totalBodyWater", + "boneMass", + "oxygenSaturation", + "mood", + "bmi", + ]) + .meta({ id: "DashboardMetricKind" }); + +const dashboardMetricCard = z + .object({ + id: z + .string() + .describe( + "Stable card id. Equal to `kind` for every card except blood pressure, whose id is `bp`.", + ), + kind: dashboardMetricKind, + titleKey: z + .string() + .describe( + "i18n key for the metric's title (e.g. `dashboard.metric.title.weight`). The wire is language-neutral — resolve the key against your own bundle.", + ), + latestValue: z + .number() + .nullable() + .describe( + "Most recent reading regardless of age, systolic for `bloodPressure`, last night's time asleep in HOURS for `sleep`, the latest daily mean for `mood`, and the derived figure for `bmi`. Null when the card is alive on history alone.", + ), + secondaryValue: z + .number() + .nullable() + .describe( + "Diastolic value on the `bloodPressure` card; null on every other kind.", + ), + unitKey: z + .string() + .describe("i18n key for the display unit, resolved the same way."), + unit: z + .string() + .nullable() + .describe( + "Explicit unit token, set only on the `sleep` card (`h`, because its value is a per-night total in hours rather than the canonical SLEEP_DURATION minutes). Null on every other kind.", + ), + sleepStages: z + .record(z.string(), z.number()) + .nullable() + .describe( + "Per-stage HOURS for the headline night, sleep card only. Null for every other kind and for a legacy bare-duration night with no stage rows.", + ), + sleepSourceDiscrepancy: z + .object({ + deltaMinutes: z.number().int().nonnegative(), + sources: z.array( + z.object({ + source: z.string(), + deviceType: z.string().nullable(), + asleepMinutes: z.number().int().nonnegative(), + }), + ), + }) + .nullable() + .describe( + "Non-null on the sleep card when two writer buckets reported clearly different asleep totals for the headline night's main session. Observational only — `latestValue` stays the winning writer's total; render a discreet 'sources disagree' hint. Null on every other kind and when the writers agree.", + ), + trend: z + .enum(["up", "down", "flat", "unknown"]) + .describe( + "First-to-last direction across `sparkline`, with a 1 % (min 1 unit) dead band. `unknown` under two points.", + ), + sparkline: z + .array(z.number()) + .describe( + "Up to seven points: the last seven DAY rollup buckets for a measured metric regardless of age (daily sum for cumulative metrics such as steps, daily mean otherwise), the trailing nights' asleep hours for `sleep`, the last seven daily means for `mood`, and the trailing derived series for `bmi`. May be empty.", + ), + updatedAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "Instant behind `latestValue`, falling back to the metric's newest reading. For `mood` this is the day START of the latest daily bucket, not a reading time — the series pins a day, so inventing an instant would be a lie. For `bmi` it is the WEIGHT metadata, since BMI moves exactly when weight does.", + ), + allTimeCount: z + .number() + .int() + .describe( + "Total readings ever logged for this metric — the gate that keeps a tile visible through a logging gap. Summed across both sides for `bloodPressure`; the mood entry count for `mood`; the WEIGHT count for `bmi`.", + ), + lastSeenAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "The metric's newest reading, for the 'last reading N days ago' caption when it is older than the sparkline window.", + ), + }) + .meta({ + id: "DashboardMetricCard", + description: + "One dashboard metric tile, normalised to the iOS-friendly `kind` rather than the canonical measurement-type enum. A card appears only when the metric has a reading or any history at all, so the array's LENGTH and MEMBERSHIP both vary per account — do not index it positionally.", + }); + +const dashboardSummaryResponse = z + .object({ + greeting: z + .object({ + salutation: z + .string() + .describe( + "Already-localised greeting including the display name (or username). One of the few strings this payload still translates server-side, in the account's own locale.", + ), + date: z.iso.datetime({ offset: true }).describe("Build instant."), + }) + .describe("Header block."), + streak: z + .object({ + currentDays: z + .number() + .int() + .describe( + "Consecutive logging days up to today in the account's display timezone. Yesterday still counts while today is unlogged, so the streak does not visibly break before the day is over.", + ), + longest: z + .number() + .int() + .describe("Longest run inside the trailing 365 days — NOT all time."), + label: z.string().describe("Already-localised caption."), + }) + .describe( + "Logging streak over measurements plus resolved medication intakes (taken or skipped).", + ), + compliance: z + .object({ + scheduledToday: z.number().int(), + takenToday: z.number().int(), + }) + .describe( + "Today's dose tally from the shared projector — every today-window slot, and the ones with a `takenAt` that were not skipped.", + ), + highlightInsight: z + .null() + .describe( + "Reserved. The handler pins this to null unconditionally; no code path has ever populated it. Treat it as absent rather than as 'no insight today'.", + ), + metrics: z + .array(dashboardMetricCard) + .describe( + "The metric tiles. Filtered against the account's module map AFTER the cache read, so a card whose measurement type belongs to a disabled module is OMITTED rather than refused — the same choice the snapshot sibling and the sync feed make, because refusing the whole aggregate would blank the dashboard over one disabled module.", + ), + sleepRhythm: sleepRhythmResponse.describe( + "The same server-authoritative sleep-debt + chronotype + average-per-night DTO `GET /api/sleep/rhythm` returns, off the same canonical night reconstruction. Always present, with its own calm `partial` / `learning` states — it is NOT gated by the sleep module here, so it can carry figures while the sleep metric CARD is filtered out.", + ), + lastUpdated: z.iso + .datetime({ offset: true }) + .describe("Build instant of the cached body."), + }) + .meta({ + id: "DashboardSummaryResponse", + description: + "The native client's dashboard aggregator: greeting, logging streak, today's dose tally, the per-metric tiles, and the sleep-rhythm DTO. Served through a 60 s fresh / 1 h stale read-through cache, so `greeting.date` and `lastUpdated` can be up to an hour old on a stale-window serve; writes mark the bucket so the account's own action lands on the very next read. No LLM is reachable from this path.", + }); + +const chartOverlayPrefsPutBody = z + .object({ + chartKey: z + .enum(CHART_OVERLAY_KEYS) + .describe("Which chart's overlays are being written. Closed enum."), + prefs: z + .object({ + showTrendIndicator: z.boolean(), + showTrendArrow: z.boolean(), + showTargetRange: z.boolean(), + comparisonBaseline: z + .enum(["none", "lastMonth", "lastYear"]) + .optional() + .describe("Defaults to `none` when omitted."), + rangePoints: z + .union([z.literal(0), z.literal(7), z.literal(30), z.literal(90)]) + .optional() + .describe( + "Persisted range-tab selection. Optional so a client on an older bundle that never sends it does not 422; omitting it stores the slot WITHOUT the field rather than storing a default.", + ), + }) + .describe( + "The complete overlay state for that chart. The three booleans are REQUIRED — this replaces the chart's slot wholesale rather than merging into it, so a body that names only the toggle being flipped silently clears the other two.", + ), + }) + .meta({ + id: "ChartOverlayPrefsPutBody", + description: + "One chart's overlay preferences. Partial across CHARTS (only the named `chartKey` is touched, every other chart's slot is preserved) and total WITHIN a chart (the named slot is replaced).", + }); + export const dashboardWidgetPaths: NonNullable = { + "/api/dashboard/chart-overlay-prefs": { + put: { + tags: ["Dashboard"], + summary: "Save one chart's overlay preferences", + description: + "Writes the overlay toggles for a single chart into the same `dashboardWidgetsJson` blob `GET /api/dashboard/widgets` reads, without the client having to round-trip the whole layout — which is what it exists for: a chart wrapper knows the chart it is flipping and nothing else, and making it re-send the entire widget array would be both wasteful and racy across several charts at once. The read-modify-write runs inside a SERIALIZABLE transaction, so two tabs toggling different charts cannot clobber one another. Note the granularity: partial across charts, TOTAL within one — the named slot is replaced, so a body carrying only the toggle being changed clears the other two. This route accepts NO optimistic-concurrency token, unlike its `/api/dashboard/widgets` sibling; the transaction is what protects it instead. A malformed body is refused with the multi-issue 422 and an audit breadcrumb (`dashboard.chart-overlay.validation-failed`, deduplicated to one row per account per minute so a looping client cannot flood the ledger). Body capped at 64 KiB. Cookie or Bearer auth; the caller is always resolved as themselves, so these are never another record's preferences — the same choice the sibling PUT and DELETE make.", + requestBody: { + required: true, + content: { + "application/json": { schema: chartOverlayPrefsPutBody }, + }, + }, + responses: { + "200": { + description: + "Saved. The response confirms the write and does NOT echo the resolved layout — re-read the widget layout if the new state is needed.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ saved: z.literal(true) }), + "ChartOverlayPrefsSavedEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/dashboard/summary": { + get: { + tags: ["Dashboard"], + summary: "Native-client dashboard aggregator", + description: + "Assembles the greeting, the logging streak, today's medication tally, the per-metric tiles and the sleep-rhythm DTO in one round-trip from bounded SQL aggregates. Distinct from `GET /api/dashboard/snapshot`, which serves the web first paint from the shared builder: this route normalises every metric to an iOS-friendly `kind` and carries no briefing, no health score and no target bands. Module handling is OMIT, not refuse — a disabled module drops its metric card and leaves the rest of the payload intact. Delegable at MANAGE level over the whole record: the payload is computed across every domain, and there is no provider anywhere on the path. Cookie or Bearer auth.", + responses: { + ...recordRefusal(), + "200": { + description: "The dashboard summary.", + content: { + "application/json": { + schema: dataEnvelope( + dashboardSummaryResponse, + "DashboardSummaryEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/dashboard/widgets": { get: { tags: ["Dashboard"], diff --git a/src/lib/openapi/routes/export.ts b/src/lib/openapi/routes/export.ts index ecee44185..6cc215f33 100644 --- a/src/lib/openapi/routes/export.ts +++ b/src/lib/openapi/routes/export.ts @@ -1,16 +1,130 @@ /** - * OpenAPI route table — generic data export (passphrase-encrypted variant). + * OpenAPI route table — the whole export surface: the mixed plaintext + * download, the three per-type CSV downloads, the restore-compatible full + * backup, and the passphrase-encrypted archive. * - * The plaintext `GET /api/export/full-backup` + per-type CSV routes predate the - * OpenAPI registry and are not iOS-contract surfaces; this module documents the - * v1.23 passphrase-encrypted variant because it adds a request flag + a binary - * response shape the iOS client may consume. + * All of it predated the registry, and none of it was caught, because + * `pnpm openapi:check` compares the registry against the YAML and never the + * ROUTE TREE against the registry: a route registered nowhere drifts with + * every gate green. + * + * One property is worth reading before any of the operations below: NONE of + * these responses is the standard `{ data, error }` envelope. Each route + * builds its own `NextResponse` so it can stream and set a filename, so a + * client decoding these with the shared envelope decoder will fail on all of + * them. Every one of them shares the `export:` rate bucket, so ten + * downloads an hour is the budget across the whole family and not per route. * * Part of the OpenAPI route table; aggregated in `./index.ts`. */ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; -import { stdResponses } from "./shared"; +import { backupPayloadSchema } from "@/lib/validations/backup"; +import { errorEnvelope, stdResponses } from "./shared"; + +const exportQuery = z + .object({ + format: z + .enum(["csv", "json"]) + .optional() + .describe( + "Serialisation. Omitted means `json`. Anything else is refused with 422 — the value is NOT clamped to the default.", + ), + type: z + .enum(["measurements", "medications", "intake", "mood", "all"]) + .optional() + .describe( + "Which sections to include. Omitted means `all`. Anything else is refused with 422.", + ), + }) + .meta({ id: "ExportQuery" }); + +const forgivingSince = z.iso + .datetime({ offset: true }) + .optional() + .describe( + "Inclusive lower bound. Parsed with the plain Date constructor, so an ISO date (`2026-07-14`) works as well as a full instant — and an UNPARSEABLE value is silently DROPPED from the filter rather than refused, which widens the export instead of erroring. Validate on your side.", + ); + +const forgivingUntil = z.iso + .datetime({ offset: true }) + .optional() + .describe("Inclusive upper bound, with the same forgiving parse as `since`."); + +const exportRangeQuery = z + .object({ since: forgivingSince, until: forgivingUntil }) + .meta({ id: "ExportRangeQuery" }); + +const exportMeasurementsQuery = z + .object({ + since: forgivingSince, + until: forgivingUntil, + granularity: z + .literal("raw") + .optional() + .describe( + "Send `raw` for one row per stored sample, per-sleep-stage rows included. Anything else — including a misspelling — falls through to the default night aggregation rather than 422-ing.", + ), + }) + .meta({ id: "ExportMeasurementsQuery" }); + +const exportMedicationsQuery = z + .object({ + intake: z + .literal("false") + .optional() + .describe( + "Send exactly `false` to omit the intake-history section. Any other value, and omitting it, leaves the history IN — the test is an equality against the string, not a boolean parse.", + ), + medicationId: z + .string() + .optional() + .describe( + "Scope both sections to one medication. Always narrowed by the account, so an id this account does not hold answers 404.", + ), + since: forgivingSince, + until: forgivingUntil, + }) + .meta({ id: "ExportMedicationsQuery" }); + +const exportJsonBody = z + .object({ + data: z + .object({ + measurements: z + .array(z.record(z.string(), z.unknown())) + .optional() + .describe( + "Flattened measurement rows, streamed page by page. Present for `type=measurements` and `type=all`.", + ), + medications: z + .array(z.record(z.string(), z.unknown())) + .optional() + .describe( + "Flattened medications with their schedules. Present for `type=medications` and `type=all`.", + ), + intakeEvents: z + .array(z.record(z.string(), z.unknown())) + .optional() + .describe( + "Flattened intake events (tombstoned rows excluded), newest scheduled slot first. Present for `type=intake` and `type=all`.", + ), + moodEntries: z + .array(z.record(z.string(), z.unknown())) + .optional() + .describe( + "Flattened mood entries with the note decrypted. Present for `type=mood` and `type=all`.", + ), + }) + .describe( + "Only the sections the `type` parameter selected are present; the others are absent keys, not empty arrays. Row shapes are the export formatters' flat column sets (the same columns the CSV variant emits as headers), not the API resource shapes — they are deliberately typed loosely here rather than restated, because the formatter is the single source and a restatement would drift.", + ), + }) + .meta({ + id: "ExportJsonBody", + description: + "The JSON download body. NOTE: this is NOT the standard `{ data, error }` envelope — the route builds the response itself so it can stream, and it carries `data` alone with no `error` key. Do not decode it with the shared envelope decoder.", + }); const encryptedExportRequest = z .object({ @@ -21,7 +135,154 @@ const encryptedExportRequest = z }) .meta({ id: "EncryptedExportRequest" }); +// ── The restore-compatible full backup ─────────────────────────────── +// +// The payload is described by the canonical `backupPayloadSchema` — the same +// object the admin restore parses the uploaded file with — rather than by a +// second declaration here. A restatement would be a second thing to keep in +// step with a 130-section wire format, and the previous prose beside the +// route is exactly what went wrong last time: it named six domains as +// export-only that the restore has re-created for releases. +// +// One correction the prose has to carry, because the shape does not: the +// builder has TWO modes and this route only ever produces one of them. The +// route calls the builder with no options, so `purpose` is undefined and the +// PORTABLE mode is what a user downloads. The disaster-recovery mode — row +// ids, tombstones, ciphertext verbatim, operator settings, the mental-health +// screeners and the consent receipts — is reached only by the weekly worker +// and the admin path. Describing this route as if it could produce either +// would be describing an option no caller has. +const fullBackupPayload = backupPayloadSchema.meta({ + id: "FullBackupPayload", + description: + "The canonical restore-compatible backup, in its PORTABLE form. Every section a user's own record holds rides it, and the `manifest` key declares what does not. Portable specifics: soft-deleted rows are dropped rather than carried as tombstones; encrypted columns arrive DECRYPTED as plain text, so the file is readable by whoever holds it and readable by an instance that does not hold this one's key; row ids are omitted for the sections that only carry them in a disaster-recovery file. Three things are omitted outright and say so in `manifest`: original uploaded document BYTES (metadata and any generated summary ride, the files do not — download them individually), workout GPS routes and per-sample heart-rate / pace series (summary records ride), and the sensitive pair — completed WHO-5 / PHQ / GAD administrations, whose encrypted per-item answers include the PHQ-9 self-harm item, and consent receipts, which are given to ONE operator and would assert elsewhere an agreement that operator never obtained. Both of the sensitive sections arrive as empty arrays with their `manifest` entry set to `omitted`, so their absence is stated rather than inferred. Beyond the declared omissions there is a real, undeclared gap: roughly thirty of the models the backup plan marks as backed up have neither a reader here nor a restore branch yet, and `src/lib/export/backup-plan.ts` is the enumeration of which. `schemaVersion` is `2` on everything written today; the parser still accepts `1`.", +}); + export const exportPaths: NonNullable = { + "/api/export": { + get: { + tags: ["Export"], + summary: "Download measurements, medications, intake and mood", + description: + "The plaintext download. Returns `application/json` or `text/csv` (never the standard response envelope) with a `Content-Disposition: attachment` filename of `healthlog-export-.`. Measurements stream page by page, so a multi-year account does not buffer its whole history in memory; the other sections are assembled in full first. The CSV variant concatenates the selected sections under `# Measurements` / `# Medications` / `# Intake Events` / `# Mood Entries` headings, so it is a CONCATENATION of tables rather than one table — a single-table CSV parser will choke on it. Mood notes are decrypted into the download; measurement notes ride their exported column. Shares the `export:` bucket with the encrypted variant (10 per hour) and audits every attempt as `export.download`. Cookie or Bearer auth; the caller is always resolved as themselves, so this download cannot be delegated to a shared record. Not module-gated: a disabled module's rows are still exported, because this is the account's own data leaving on request.", + requestParams: { query: exportQuery }, + responses: { + "200": { + description: + "The export. `Content-Type` follows `format`; the body is `ExportJsonBody` for JSON and a multi-section CSV document for CSV.", + content: { + "application/json": { schema: exportJsonBody }, + "text/csv": { schema: z.string() }, + }, + }, + "422": { + description: + "`format` or `type` carried a value outside its enum. The message names the accepted set; nothing is exported. Note that this route validates by hand rather than through Zod, so the response is the single-message error envelope and NOT the multi-issue 422 the rest of the surface returns.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: + "More than 10 exports in the trailing hour for this account (`export:`, shared with POST /api/export/encrypted).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "401": stdResponses["401"], + }, + }, + }, + "/api/export/full-backup": { + get: { + tags: ["Export"], + summary: "Download the restore-compatible full backup", + description: + "The disaster-recovery-shaped JSON dump, in its PORTABLE form. It matches the canonical backup payload byte for byte with the weekly worker's file, so a user can hand this to an operator and the admin restore accepts it without conversion. Two things the shape cannot state: this route ALWAYS produces the portable mode — it calls the builder with no options, so there is no parameter that reaches the disaster-recovery mode, which is the worker and admin path alone — and the file is NOT the standard response envelope but the raw payload, because the restore expects the payload and not `{ data: … }`. Encrypted columns arrive decrypted, so the file is as sensitive as the record it came from; the `manifest` key inside it declares what was deliberately left out. `Content-Disposition: attachment` with `healthlog-backup--.json`, `Cache-Control: no-store`. Shares the `export:` bucket (10 per hour) with every other export route. Audits as `user.export.full-backup` with the row counts. Cookie or Bearer auth; the caller is always resolved as themselves, so this download cannot be delegated to a shared record.", + responses: { + "200": { + description: + "The backup payload. `application/json; charset=utf-8`, served as an attachment.", + content: { + "application/json": { schema: fullBackupPayload }, + }, + }, + "429": { + description: + "More than 10 exports in the trailing hour for this account (`export:`, shared across the export family).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "401": stdResponses["401"], + }, + }, + }, + "/api/export/measurements": { + get: { + tags: ["Export"], + summary: "Download measurements as CSV", + description: + "Streams the account's measurements as a single-table CSV, page by page, so a multi-year history never buffers whole. Soft-deleted rows are excluded, so an undone entry or a pending-sync tombstone never reaches the file. Sleep is night-aggregated by default; `granularity=raw` emits the per-stage rows instead. Glucose is written in the account's display unit rather than the canonical storage unit — which is the one way this file differs from the mixed `/api/export` download, whose measurement section pins mg/dL. `since` and `until` are FORGIVING: an unparseable date is dropped from the filter rather than refused, so a typo widens the export silently instead of erroring. Errors from authentication and the first page are returned normally; a failure after the first page arrives mid-stream, with a 200 already sent. `Content-Disposition: attachment` with `healthlog-measurements--.csv`. Shares the `export:` bucket (10 per hour). Audits as `user.export.measurements`. Cookie or Bearer auth; not delegable.", + requestParams: { query: exportMeasurementsQuery }, + responses: { + "200": { + description: + "The CSV. `text/csv; charset=utf-8`, served as an attachment, `Cache-Control: no-store`.", + content: { "text/csv": { schema: z.string() } }, + }, + "429": { + description: + "More than 10 exports in the trailing hour for this account.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "401": stdResponses["401"], + }, + }, + }, + "/api/export/medications": { + get: { + tags: ["Export"], + summary: "Download medications (and their intake history) as CSV", + description: + "The medication list, plus the intake history unless it is switched off. The two ride one file as SEPARATE tables under `# Medications` and `# Intake history` headings, so a single-table CSV parser will choke on it. `medicationId` scopes both sections to one medication — the id is always narrowed by the account, so a foreign or unknown id resolves to no medication and answers 404 rather than an empty file. `since` / `until` bound the intake section only, on `scheduledFor`, and are FORGIVING: an unparseable date is dropped from the filter rather than refused. Tombstoned intake rows are excluded. Note the default: `intake` is on unless the caller sends exactly `false`, so any other value — including `0` or `no` — leaves the history in. `Content-Disposition: attachment` with `healthlog-medications--.csv`. Shares the `export:` bucket (10 per hour). Audits as `user.export.medications`. Cookie or Bearer auth; not delegable.", + requestParams: { query: exportMedicationsQuery }, + responses: { + "200": { + description: + "The CSV. `text/csv; charset=utf-8`, served as an attachment, `Cache-Control: no-store`.", + content: { "text/csv": { schema: z.string() } }, + }, + "404": { + description: + "`medicationId` named a medication this account does not hold. An unknown id and another account's id are indistinguishable.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: + "More than 10 exports in the trailing hour for this account.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "401": stdResponses["401"], + }, + }, + }, + "/api/export/mood": { + get: { + tags: ["Export"], + summary: "Download mood entries as CSV", + description: + "The account's mood entries as a single-table CSV, newest first, with the note DECRYPTED into the file — the whole point of a portable export, and the reason the file is as sensitive as the entries themselves. Tombstoned rows are excluded. `since` / `until` bound `moodLoggedAt` and are FORGIVING: an unparseable date is dropped from the filter rather than refused. Assembled in full before it is sent, unlike the measurement download. `Content-Disposition: attachment` with `healthlog-mood--.csv`. Shares the `export:` bucket (10 per hour). Audits as `user.export.mood`. NOT module-gated — a switched-off mood module still exports its entries, because this is the account's own data leaving on request. Cookie or Bearer auth; not delegable.", + requestParams: { query: exportRangeQuery }, + responses: { + "200": { + description: + "The CSV. `text/csv; charset=utf-8`, served as an attachment, `Cache-Control: no-store`.", + content: { "text/csv": { schema: z.string() } }, + }, + "429": { + description: + "More than 10 exports in the trailing hour for this account.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "401": stdResponses["401"], + }, + }, + }, "/api/export/encrypted": { post: { tags: ["Export"], diff --git a/src/lib/openapi/routes/index.ts b/src/lib/openapi/routes/index.ts index 746e669bb..a77ccf325 100644 --- a/src/lib/openapi/routes/index.ts +++ b/src/lib/openapi/routes/index.ts @@ -162,6 +162,8 @@ export const openApiPaths: NonNullable = { ...awardsPaths, ...environmentPaths, ...ingestPaths, + // The environmental-context overview. Its own module because nothing else + // owns the surface (appended, spread order is load-bearing). }; export const openApiComponents: NonNullable = { diff --git a/src/lib/openapi/routes/insights/paths.ts b/src/lib/openapi/routes/insights/paths.ts index cbb8b383c..ec3b8220c 100644 --- a/src/lib/openapi/routes/insights/paths.ts +++ b/src/lib/openapi/routes/insights/paths.ts @@ -10,6 +10,8 @@ import { MODULE_DISABLED_DESCRIPTION, dataEnvelope, errorEnvelope, + idempotencyKeyParameter, + idempotentWrite, moduleDisabledResponse, recordRefusal, stdResponses, @@ -44,7 +46,33 @@ import { ecgDetailQuery, ecgDetailResponse, rhythmEventsResponse, + analyticsQuery, + analyticsResponse, + insightsSettingsResponse, + insightsSettingsPutRequest, + targetsResponse, + providerChainResponse, + providerChainPutRequest, + insightsGenerateReadResponse, + insightsGeneratePostRequest, + insightsGeneratePostResponse, + coachReadQuery, + coachReadStripResponse, + coachSeededQuestionResponse, + narrativeQuery, + narrativeResponse, + glp1TimelineQuery, + glp1TimelineResponse, + intradayPulseQuery, + intradayPulseResponse, } from "./schemas"; +import { recommendationFeedbackRequestSchema } from "@/lib/validations/recommendation-feedback"; + +const insightsFeedbackRequest = recommendationFeedbackRequestSchema.meta({ + id: "InsightsFeedbackRequest", + description: + "A thumbs-up / thumbs-down on one recommendation. The body carries the rec id AND a snapshot of the text as rendered, because deduplication is on the pair: a regeneration that rewrites the same id with new text is a genuinely different rating, which is the signal the aggregator wants. `providerType` and `promptVersion` are NOT accepted — the server fills both from the account's own attribution, so a client cannot tamper with the slice the quality dashboard reports on.", +}); export const insightsPaths: NonNullable = { "/api/dashboard/snapshot": { @@ -145,6 +173,398 @@ export const insightsPaths: NonNullable = { }, }, }, + "/api/analytics": { + get: { + tags: ["Analytics"], + summary: "The full analytics envelope (two slices behind one path)", + description: + "The oldest composite read in the application. `?slice=summaries` answers the slim slice — per-type summaries and the freshness map, resolved from two SQL passes over the rollup tier. Everything else answers the thick default: the same summaries plus BMI, the blood-pressure in-target windows, per-context glucose and the clinical glucose panel, the three correlation hypotheses, the full health-score report and the trailing-30-day sleep-stage breakdown. No LLM is reachable from either path. Both are served through a stale-while-revalidate cache, and the DEFAULT slice's cache key includes the reader's locale, because the correlation narration is localised prose. Two behaviours worth knowing before integrating: the thick slice RECORDS the health score it computes, so this read is not side-effect-free, and it kicks off a background rollup refresh it deliberately does not await, so a reading logged seconds ago can be missing from the very first request after it lands and present on the next. Module handling is per BLOCK, not per request: the glucose blocks go null when the glucose module is off, and the health score simply drops the pillars whose modules are off. Cookie or Bearer auth; the caller is always resolved as themselves, so this read cannot be delegated to a shared record.", + requestParams: { query: analyticsQuery }, + responses: { + "200": { + description: + "`AnalyticsDefaultSlice`, or `AnalyticsSummariesSlice` when `slice=summaries`. `Cache-Control` is the bfcache-friendly `private, max-age=0, must-revalidate` rather than `no-store`.", + content: { + "application/json": { + schema: dataEnvelope(analyticsResponse, "AnalyticsEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/generate": { + get: { + tags: ["Insights"], + summary: "Read the cached advisor briefing (never generates)", + description: + 'The read-only advisor. It serves the cached payload and NEVER calls a provider — the surfaces that mount on page load used to POST here, which blocked first paint on the whole provider chain. When the cache is stale or missing AND a provider exists it enqueues an out-of-band warm and says so in `revalidating`; the next read reflects it. User-initiated regeneration is the POST. Four honesty signals ride the payload and are the whole point of it: `hasProvider: false` means no warm pass can ever refresh what is shown, so stop polling and offer to connect one; `generationFailed` means the last attempt failed and the text is HELD rather than fresh; `generationFailureClass` says which lever to point at; and `briefingOmittedReason: "ungrounded"` means a generation SUCCEEDED and the grounding gate withheld the briefing, which is a different state from a failure. Gated on the Coach assistant surface — an operator who turns Coach off empties every advisor consumer. Cookie or Bearer auth; the caller is always resolved as themselves, so this read cannot be delegated to a shared record.', + responses: { + "200": { + description: + "The cached payload, or the empty shape when nothing is cached. Always 200 — an absent briefing is a normal answer here, not an error.", + content: { + "application/json": { + schema: dataEnvelope( + insightsGenerateReadResponse, + "InsightsGenerateReadEnvelope", + ), + }, + }, + }, + "403": { + description: + "The operator has switched the Coach assistant surface off (`meta.errorCode` = `assistant.disabled.coach`).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "401": stdResponses["401"], + "429": stdResponses["429"], + }, + }, + post: { + tags: ["Insights"], + summary: "Generate the advisor briefing inline (costs tokens)", + description: + "The only path in the advisor family that calls a provider, and it does so INLINE. Two gates bound it, and they bound different things. The RATE gate is `insights:`, ten generations per hour by default and configurable by the operator through `INSIGHTS_RATE_LIMIT_PER_HOUR` (a value below 1 falls back to the default); it is checked AFTER the cache short-circuit, so a cache hit never spends a token. The BUDGET gate is the day's token ceiling, refused at reservation time before any provider is contacted — so it is not a provider failure, records no failure marker, and answers 429 with `meta.errorCode` = `insights.generate.budgetExceeded`, which is how a client tells 'you asked too often' from 'the day's spend is gone'. Two more things a caller should know before integrating: a plain 200 does NOT mean a provider ran — a fresh cache inside 24 hours answers `cached: true`, and so does an exhausted quota when the fresh cache merely lacked a briefing — and a request that TIMES OUT client-side may still complete on the server, writing the briefing to the cache or a dated failure marker, which is exactly the pair the read GET reports so a client can settle an aborted regenerate by polling rather than guessing. Gated on the Coach assistant surface, consent-gated when the resolved chain egresses through the operator's server-managed key. Cookie or Bearer auth; not delegable.", + requestBody: { + required: false, + content: { + "application/json": { schema: insightsGeneratePostRequest }, + }, + }, + responses: { + "200": { + description: + "A payload — freshly generated, served from the 24-hour cache, or served from a briefingless cache after the hourly quota refused the regeneration. `cached` tells the three apart.", + content: { + "application/json": { + schema: dataEnvelope( + insightsGeneratePostResponse, + "InsightsGeneratePostEnvelope", + ), + }, + }, + }, + "403": { + description: + "The Coach assistant surface is off (`assistant.disabled.coach`), or the resolved chain egresses through the operator's key and no consent receipt is on file (`consent.ai.required`).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "409": { + description: + "The account's privacy mode, exclude list, locale or profile changed while the generation was running, so the finished text describes a scope that no longer applies and is discarded rather than written. `meta.errorCode` = `insights.generate.scopeChanged` or `insights.generate.profileScopeChanged`. Retry.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "422": { + description: + "No AI provider is configured anywhere for this account, or the provider answered something that was not valid JSON (`meta.errorCode` = `ai_response_truncated` when the answer was cut off mid-stream rather than malformed), or the finished prose was withheld by the outbound safety screen (`insights.generate.outboundScreened`).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: + "Three different refusals share this status and are told apart by `meta.errorCode`. No code: more than the hourly limit of generations for this account. `insights.generate.budgetExceeded`: the DAY's token ceiling refused the reservation before any provider was contacted — retry tomorrow, not in an hour. No code, with provider wording: the upstream provider rate-limited every hop of the chain.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "503": { + description: + "Every provider in the chain failed for a reason that is not a rate limit — auth, timeout or an upstream 5xx. A dated failure marker is written, so the read GET reports the briefing as held rather than fresh.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "401": stdResponses["401"], + }, + }, + }, + "/api/insights/provider-chain": { + get: { + tags: ["Insights"], + summary: "Read the account's AI-provider chain", + description: + "The chain as the settings surface renders it: the resolved head, the last provider that actually worked, and the PERSISTED list with disabled entries included. That last point is the one that matters — the resolved list drops disabled entries, and returning it here once made a disabled provider vanish from the settings list along with the toggle that would have brought it back. No key, token or account id is ever on this wire; the response carries provider TYPES and boolean state only. Cookie or Bearer auth; not delegable.", + responses: { + "200": { + description: "The chain summary.", + content: { + "application/json": { + schema: dataEnvelope( + providerChainResponse, + "ProviderChainEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Insights"], + summary: "Replace the account's AI-provider chain", + description: + "Persists the chain wholesale in the order the array gives. Any `priority` a client sends is accepted and IGNORED — the server recomputes it from insertion order, so a stale tab cannot store a chain whose displayed order disagrees with its stored one. A repeated provider type is refused rather than deduplicated on read, because drift between the two representations is the kind of thing that costs an evening. No credential is written or read here. Body capped at 64 KiB. Cookie or Bearer auth; not delegable.", + requestBody: { + required: true, + content: { + "application/json": { schema: providerChainPutRequest }, + }, + }, + responses: { + "200": { + description: + "Saved. The response confirms the write and does not echo the chain — re-read it if the resolved state is needed.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ saved: z.literal(true) }), + "ProviderChainSavedEnvelope", + ), + }, + }, + }, + "422": { + description: + "The body was not a valid chain — an empty array, more entries than there are provider types, an unknown provider type, or the same type twice. A single-message envelope with no issue list.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "401": stdResponses["401"], + "429": stdResponses["429"], + }, + }, + }, + "/api/insights/feedback": { + post: { + tags: ["Insights"], + summary: "Rate one recommendation", + description: + "Records a thumbs-up or thumbs-down against one recommendation, with the rendered text snapshotted alongside the id. The server fills the provider and prompt-version attribution from the account's own state so a client cannot skew the slice the quality dashboard reports on. Idempotent: send `Idempotency-Key` and a retry replays the first response rather than writing twice. Rate-limited 60/h per account — the unique index only catches EXACT replays, so without the cap a client could distort the aggregator by varying the id or the text per request. Body capped at 64 KiB. Cookie or Bearer auth; not delegable.", + parameters: [idempotencyKeyParameter], + requestBody: { + required: true, + content: { + "application/json": { schema: insightsFeedbackRequest }, + }, + }, + responses: { + ...idempotentWrite(), + "201": { + description: "The rating was recorded.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + id: z.string(), + createdAt: z.iso.datetime({ offset: true }), + }), + "InsightsFeedbackCreatedEnvelope", + ), + }, + }, + }, + "422": { + description: + "The body failed validation. A single-message envelope (`Invalid feedback payload`) with no issue list — this route does not use the multi-issue form.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: + "More than 60 ratings in the trailing hour for this account.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "401": stdResponses["401"], + }, + }, + }, + "/api/insights/coach-read": { + get: { + tags: ["Insights"], + summary: "The two-line Coach read for one metric", + description: + "The own-baseline placement and the single strongest lagged association whose outcome is the named metric — the two server-authoritative lines a metric sub-page renders above its chart. Pure compute over the baseline and correlation engines: no provider call and no cache table, so web and native decode the same DTO rather than each deriving one. Line two arrives as a finished sentence in the reader's language and is printed verbatim, so the resolved locale decides the row; a correlation failure degrades it to null and never sinks line one. Module-gated on `insights` and gated on the Coach assistant surface, because the strip is the ambient Coach presence on that page. Shared analytics-read budget. Delegable at MANAGE level over the whole record: it is computed across the record with no provider on the path. Cookie or Bearer auth.", + requestParams: { query: coachReadQuery }, + responses: { + ...recordRefusal(MODULE_DISABLED_DESCRIPTION), + "200": { + description: "The strip.", + content: { + "application/json": { + schema: dataEnvelope( + coachReadStripResponse, + "CoachReadStripEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/coach/seeded-question": { + get: { + tags: ["Insights"], + summary: "Today's suggested Coach opener", + description: + "Resolves today's single most notable derived wellness signal into a tappable opener for the Coach's blank-chat hero, using the same confidence-gated detector the daily briefing uses. The selection happens SERVER-side and the client renders it rather than recomputing. `signal: null` is the ordinary answer and means the hero keeps its neutral greeting — never a fabricated opener. Two different causes produce that null and are indistinguishable on the wire: nothing crossed the notability gate, or the account has turned proactive suggestions off, in which case the detector does not run at all. Module-gated on `insights` and on the Coach assistant surface. Shared analytics-read budget. Cookie or Bearer auth; not delegable.", + responses: { + "200": { + description: "The opener, or the neutral null.", + content: { + "application/json": { + schema: dataEnvelope( + coachSeededQuestionResponse, + "CoachSeededQuestionEnvelope", + ), + }, + }, + }, + ...moduleDisabledResponse, + ...stdResponses, + }, + }, + }, + "/api/insights/narrative": { + get: { + tags: ["Insights"], + summary: "The latest week or month retrospective", + description: + "The last generated period summary for the record. Read-only by construction: it never blocks on a provider, serves the last good row immediately, and warms out of band when the row is stale or missing. A provider-less account is not left empty — the generator falls back to deterministic, non-causal prose, so even the no-key demo gets a retrospective on the next read. `revalidating` reports whether this read enqueued a warm, and is always FALSE on a delegated request: the route warms unconditionally rather than on a miss, so without that suppression a manager's first navigation here would be an egress of the owner's record the owner never asked for. Rows are keyed per locale, so `locale` selects a different stored row rather than translating one. Module-gated on `insights` and on the `insightStatus` assistant surface. Delegable at MANAGE level over the whole record. Cookie or Bearer auth.", + requestParams: { query: narrativeQuery }, + responses: { + ...recordRefusal(MODULE_DISABLED_DESCRIPTION), + "200": { + description: + "The retrospective, or `narrative: null` when none is on file yet. Always 200 — absence is a normal answer.", + content: { + "application/json": { + schema: dataEnvelope(narrativeResponse, "NarrativeEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/glp1-timeline": { + get: { + tags: ["Insights"], + summary: "GLP-1 therapy timeline", + description: + "A chronological merge of every GLP-1-relevant event the record holds: titration steps, injections with their sites, legacy stock-ledger movements, and side-effect-tagged mood days from the trailing 90 days. `hasGlp1: false` with no entries when the record holds no medication classified GLP-1, so the surface hides cleanly. Two limits worth knowing before reading the output as complete: inventory entries come from the LEGACY running-sum ledger only, so a medication tracked with per-container inventory shows none, and the side-effect scan matches a FIXED bilingual list of English and German tag strings, so a tag written in another language contributes nothing. `limit` is forgiving — an out-of-range or non-numeric value falls back to the default rather than 422-ing. Delegable at MANAGE level over the whole record: it spans medications and mood, with no provider on the path. Cookie or Bearer auth.", + requestParams: { query: glp1TimelineQuery }, + responses: { + ...recordRefusal(), + "200": { + description: "The timeline, newest first.", + content: { + "application/json": { + schema: dataEnvelope( + glp1TimelineResponse, + "Glp1TimelineEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/pulse/intraday": { + get: { + tags: ["Insights"], + summary: "One day's intraday heart-rate shape", + description: + "The day's ten-minute mean heart-rate series plus, when every confidence gate holds, at most ONE cautious elevated-at-rest window. Computed from raw samples through the read-swap pattern rather than persisted as ten-minute rollups for all history, so a day outside the dense-retention window reads back at the coarser hourly grain instead of empty — `resolution` says which, and `tension` is always null on an hourly day because that read needs per-sample resolution. The bar for a tension window is deliberately high; under-flagging is the intended failure mode, and it is awareness, never a diagnosis. `date` is STRICT here, unlike the forgiving date filters elsewhere on the surface: anything but a `YYYY-MM-DD` literal is a 422. Module-gated on `insights`. Delegable at MANAGE level over the whole record. Cookie or Bearer auth.", + requestParams: { query: intradayPulseQuery }, + responses: { + ...recordRefusal(MODULE_DISABLED_DESCRIPTION), + "200": { + description: + "The day's series. `Cache-Control` is the bfcache-friendly `private, max-age=0, must-revalidate` rather than `no-store`.", + content: { + "application/json": { + schema: dataEnvelope( + intradayPulseResponse, + "IntradayPulseEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/insights/settings": { + get: { + tags: ["Insights"], + summary: "Read the account's AI-provider settings", + description: + "Returns the account's own provider connection state and privacy mode alongside PRESENCE-ONLY flags for the operator's shared key and shared ChatGPT connection. No key, token or account id is ever on this wire. Cookie or Bearer auth; the caller is always resolved as themselves, so this read cannot be delegated to a shared record.", + responses: { + "200": { + description: "The settings.", + content: { + "application/json": { + schema: dataEnvelope( + insightsSettingsResponse, + "InsightsSettingsEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Insights"], + summary: "Update the account's AI privacy mode", + description: + "Writes `privacyMode` and nothing else. Changing it clears the cached insight text and its timestamp so the next generation runs under the new mode. The body is inspected key by key rather than Zod-parsed: an unknown key is ignored rather than refused, and a body that recognises nothing is refused with 422 `No changes` rather than answered as a no-op — so a client cannot tell a typo'd field name from a rejected value except by reading the message. Body capped at 64 KiB. Cookie or Bearer auth; not delegable.", + requestBody: { + required: true, + content: { + "application/json": { schema: insightsSettingsPutRequest }, + }, + }, + responses: { + "200": { + description: "Saved.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ updated: z.literal(true) }), + "InsightsSettingsUpdatedEnvelope", + ), + }, + }, + }, + "422": { + description: + "`Invalid privacy mode` when `privacyMode` was a string outside the two accepted values, or `No changes` when the body carried nothing this endpoint writes. Single-message envelope with no issue list — this route validates by hand rather than through Zod.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "401": stdResponses["401"], + "429": stdResponses["429"], + }, + }, + }, + "/api/insights/targets": { + get: { + tags: ["Insights"], + summary: "Per-metric target tiles + consistency strips", + description: + "The whole targets page in one read: a tile per metric with its band, its current and 30-day-average values, and the seven-day consistency strip behind it, plus the page summary, the diastolic companion figures and the profile facts every band was resolved from. Pure compute over the record's own data — no provider anywhere on the path. Stale-while-revalidate: past the fresh window the prior body serves immediately while one background rebuild warms the cell, and a write hard-evicts the bucket so the account's own action lands on the next read. Delegable at MANAGE level over the whole record: the walk spans vitals, sleep, medications, mood and glucose, so a section-scoped grant is refused rather than served a filtered page. Not module-gated as a whole; a tile whose data does not exist is simply absent. Cookie or Bearer auth.", + responses: { + ...recordRefusal(), + "200": { + description: "The target tiles and their page summary.", + content: { + "application/json": { + schema: dataEnvelope(targetsResponse, "TargetsEnvelope"), + }, + }, + }, + ...stdResponses, + }, + }, + }, "/api/analytics/range": { get: { tags: ["Analytics"], diff --git a/src/lib/openapi/routes/insights/schemas.ts b/src/lib/openapi/routes/insights/schemas.ts index a40ba2a24..5d7dabf8e 100644 --- a/src/lib/openapi/routes/insights/schemas.ts +++ b/src/lib/openapi/routes/insights/schemas.ts @@ -17,6 +17,11 @@ import { SAME_TIME_BASELINE_TYPES, } from "@/lib/insights/derived/registry"; import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta"; +import { PROVIDER_CHAIN_TYPES } from "@/lib/ai/provider-chain"; +import { PERIOD_DAYS } from "@/lib/insights/narrative/period-narrative"; + +/** The retrospective periods the narrative route accepts, read off the engine. */ +const NARRATIVE_PERIOD_VALUES = Object.keys(PERIOD_DAYS); export const insightsComprehensiveResponse = z .object({ @@ -1598,3 +1603,1102 @@ export const rhythmEventsResponse = z description: "Timeline of device-flagged EVENT rows (irregular-rhythm / high-HR / low-HR / walking-steadiness / breathing-disturbance) the user's wearable (Apple Watch / Withings ScanWatch) already produced and synced. AWARENESS / SCREENING of the DEVICE's own decision — HealthLog stores and reflects the classification result verbatim, never a raw waveform, and never re-classifies. Read-only; no LLM call.", }); + +// ── The analytics envelope (`GET /api/analytics`) ───────────────────── +// +// The oldest composite read in the application, and it was never in the +// registry: `openapi:check` compares the registry against the YAML and never +// the ROUTE TREE against the registry, so a route registered nowhere drifted +// with nothing going red. +// +// Two response shapes behind one path. `?slice=summaries` answers the slim +// slice (per-type summaries only, resolved from two SQL passes); every other +// request — including a `slice` value the route does not recognise — answers +// the thick default. They share `summaries` / `lastSeenByType` / +// `sleepSourceDiscrepancy` and agree on nothing else, so both are described +// rather than merged into an all-optional shape that would describe neither. + +export const trendSlope = z + .object({ + slope: z.number().describe("Least-squares slope in units per day."), + direction: z.enum(["up", "down", "stable"]), + confidence: z.number().describe("R² of the fit, 0..1."), + }) + .meta({ id: "TrendSlope" }); + +export const dataSummary = z + .object({ + count: z.number().int(), + latest: z.number().nullable(), + min: z.number().nullable(), + max: z.number().nullable(), + mean: z.number().nullable(), + median: z + .number() + .nullable() + .describe( + "50th percentile over the trailing 90 days on this route (the SQL path fixes that window). Linear-interpolated midpoint. Null on an empty series.", + ), + avg7: z.number().nullable(), + avg30: z.number().nullable(), + slope7: trendSlope.nullable(), + slope30: trendSlope + .nullable() + .describe( + "Regression over the trailing window, anchored on NOW rather than on the newest reading — a series that stopped weeks ago reports null rather than a stale slope.", + ), + avg30LastMonth: z + .number() + .nullable() + .optional() + .describe( + "Mean over the 30 days ending 30 days ago — the prior period a tile's delta caption compares against.", + ), + avg30LastYear: z + .number() + .nullable() + .optional() + .describe( + "Mean over the 30 days ending 365 days ago. Populated only for types whose WEEK/MONTH/YEAR rollup tier carries the year-ago window.", + ), + }) + .meta({ + id: "DataSummary", + description: + "Per-measurement-type summary. Every statistic is null on an empty series rather than 0, so a client never charts a zero it was never given. `anomalyCount` and `slope90` were computed here historically and left this wire in v1.37.19 — they are not fields of this shape.", + }); + +const lastSeenSlot = z + .object({ + lastSeenAt: z.iso.datetime({ offset: true }), + daysAgo: z + .number() + .int() + .describe( + "Whole days since `lastSeenAt`, re-derived per request rather than served from the cache, so the staleness caption stays correct across a day boundary.", + ), + }) + .nullable(); + +const sleepSourceDiscrepancy = z + .object({ + deltaMinutes: z.number().int().nonnegative(), + sources: z.array( + z.object({ + source: z.string(), + deviceType: z.string().nullable(), + asleepMinutes: z.number().int().nonnegative(), + }), + ), + }) + .nullable() + .describe( + "Non-null when two writer buckets reported clearly different asleep totals for the night behind `summaries.SLEEP_DURATION.latest`. Observational only — the served summary stays the winning writer's totals.", + ); + +export const analyticsSummariesSliceResponse = z + .object({ + summaries: z + .record(z.string(), dataSummary) + .describe("Keyed by MeasurementType. Types with no data are omitted."), + bmi: z + .null() + .describe( + "Always null on this slice — BMI is derived on the default slice from `summaries.WEIGHT.latest` and the profile height. The key is present so the two slices decode into one type.", + ), + lastSeenByType: z.record(z.string(), lastSeenSlot), + sleepSourceDiscrepancy, + }) + .meta({ + id: "AnalyticsSummariesSlice", + description: + "The slim `?slice=summaries` answer: the per-type summaries the dashboard tile strip needs, resolved from two SQL passes over the rollup tier instead of the default slice's thirty-query chain. Drops correlations, the health score, the BP-in-target block, per-context glucose and the sleep-stage breakdown.", + }); + +const analyticsCorrelationOk = z.object({ + kind: z.string().describe("Hypothesis identity."), + status: z.literal("ok"), + statistic: z + .number() + .describe( + "Pearson r for the BP-compliance and mood-pulse hypotheses; eta-squared for the weight-weekday one.", + ), + n: z.number().int(), + pValue: z.number().describe("Two-sided. Below 0.05 to surface at all."), + confidenceBand: z.unknown().describe("95 % confidence band for the UI chip."), + interpretation: z + .string() + .describe( + "One already-localised conservative sentence, rendered verbatim. Never claims causation. Localised per reader, which is why the cached envelope is keyed by locale.", + ), + points: z.array(z.object({ x: z.number(), y: z.number() })), + xLabel: z.string(), + yLabel: z.string(), + patternId: z.string().optional(), + canonicalKey: z.string().optional(), + dismissed: z.boolean().optional(), +}); + +const analyticsCorrelationInsufficient = z.object({ + kind: z.string(), + status: z.literal("insufficient"), + n: z.number().int().describe("What was counted, even below the threshold."), + reason: z.enum(["too_few_pairs", "not_significant", "no_variance"]), + points: z + .array(z.object({ x: z.number(), y: z.number() })) + .describe("Preview points the empty state can hint progress with."), +}); + +const analyticsCorrelation = z + .union([analyticsCorrelationOk, analyticsCorrelationInsufficient]) + .describe( + "Discriminated on `status`. The `ok` arm carries the statistic and its narration; the `insufficient` arm carries neither and says why. The pattern fields (`patternId` / `canonicalKey` / `dismissed`) ride the `ok` arm only, and only once the hypothesis has been persisted.", + ); + +const healthScoreDerivedInsufficient = z.object({ + status: z.literal("insufficient"), + coverage: derivedCoverage, + provenance: derivedProvenance, + reason: z.string(), +}); + +const healthScoreComposite = z + .union([ + z.object({ + status: z.literal("ok"), + value: z.object({ + score: z.number(), + band: z.enum(["green", "yellow", "red"]), + bandSetter: z + .string() + .nullable() + .describe( + "The pillar that lowered the mean score's band; null when no pillar did.", + ), + composition: z + .array(z.string()) + .describe( + "Registry-ordered eligible pillar ids. Part of the number's identity: two scores with different compositions are not comparable.", + ), + configured: z + .boolean() + .describe( + "True when the account's own recipe narrows the composition below what its defaults would resolve to today. The configuration blob itself is never on this wire.", + ), + noiseFloor: z.number(), + scoreVersion: z.number().int(), + }), + coverage: derivedCoverage, + confidence: derivedConfidence, + provenance: derivedProvenance, + }), + healthScoreDerivedInsufficient, + ]) + .describe( + "Discriminated on `status`. The `insufficient` arm carries NO `value` and NO `confidence` key at all — they are absent, not null — which is how a composite reads before three distinct domains are eligible.", + ); + +const healthScorePillar = z.object({ + id: z.string().describe("Pillar id."), + domain: z + .enum(["cardiometabolic", "activity", "sleep", "adiposity", "wellbeing"]) + .describe( + "Which area the pillar speaks to. Three of the seven pillars share `cardiometabolic`, so a selection of four pillars can still fail the three-distinct-domains rule.", + ), + result: z + .union([ + z.object({ + status: z.literal("ok"), + value: z.object({ + score: z.number(), + observed: z.object({ + value: z.number(), + unit: z.string(), + label: z + .string() + .describe("Complete display value, paired values included."), + asOf: z.string(), + sources: z.array(z.string()), + }), + reference: z.object({ + kind: z.enum([ + "clinical-threshold", + "population-percentile", + "guideline-band", + ]), + low: z.number().nullable(), + high: z.number().nullable(), + label: z.string(), + source: z.string(), + }), + personalReference: z + .unknown() + .optional() + .describe( + "The user's own yardstick, same shape as `reference`, shown beside the scored one. Absent when there is none — the pillar is always GRADED against the reference, never against this.", + ), + scoreBasis: z + .unknown() + .optional() + .describe( + "Which input set the score and by how much. Blood pressure only today: it is the one pillar that scores the WORSE of two axes, so the number alone cannot say which one it came from. A pillar with nothing of the kind to say omits the key.", + ), + noiseFloor: z.number(), + deltaEligible: z + .boolean() + .describe( + "False for slow markers, which contribute to the level but never to the weekly delta.", + ), + deltaIdentity: z.string(), + }), + coverage: derivedCoverage, + confidence: derivedConfidence, + provenance: derivedProvenance, + }), + healthScoreDerivedInsufficient, + ]) + .describe("Same `Derived` union as the composite."), +}); + +export const healthScoreReport = z + .object({ + composite: healthScoreComposite, + pillars: z + .array(healthScorePillar) + .describe("Every pillar the scorer evaluated, eligible or not."), + delta: z + .number() + .nullable() + .describe( + "Week-over-week move; null whenever `deltaReason` explains why there is none.", + ), + deltaReason: z + .enum([ + "algorithm_changed", + "config_changed", + "composition_changed", + "first_eligibility_window", + "below_noise_floor", + "no_previous_window", + "no_current_score", + ]) + .nullable() + .describe( + "Why the delta was suppressed. `config_changed` is the one that is easy to misread as a health event: the person changed their own recipe inside the comparison window, so both windows computed under the new recipe and the arithmetic only looks comparable.", + ), + scoreVersion: z + .number() + .int() + .describe( + "The scoring METHOD's identity, not the schema version. It moves when the rules move, which is what lets a stored day say which rules produced it.", + ), + weightGoal: z + .union([ + z.object({ + status: z.literal("ok"), + value: z.object({ + currentKg: z.number(), + target: z.object({ min: z.number(), max: z.number() }), + distanceKg: z.number(), + deltaKg: z + .number() + .nullable() + .describe("Positive means the distance to the band narrowed."), + asOf: z.string(), + source: z.string(), + }), + coverage: derivedCoverage, + confidence: derivedConfidence, + provenance: derivedProvenance, + }), + healthScoreDerivedInsufficient, + ]) + .describe("Same `Derived` union again."), + algorithmNotice: z + .object({ itemKey: z.string(), dismissed: z.boolean() }) + .nullable() + .describe( + "The once-per-account notice that the scoring rules moved; null when there is nothing to say.", + ), + restMode: z + .object({ + active: z.literal(true), + since: z.string().nullable(), + episodeCount: z.number().int(), + }) + .nullable() + .optional() + .describe( + "Set on every response this route produces (null when no illness episode is active). Rest Mode FRAMES the score and never changes it: the number above is left exactly as computed and this attaches the value-free context. Resolved as of the scored window, not a fresh now.", + ), + }) + .meta({ + id: "HealthScoreReport", + description: + "The versioned cardiometabolic reference score: the composite, every pillar with its own observation and reference band, the week-over-week delta with the reason it may be absent, the weight-goal derivation and the algorithm notice. Recorded to the score history as a side effect of this read.", + }); + +const sleepStageComposition = z + .object({ + windowDays: z.number().int(), + nights: z + .number() + .int() + .describe("Nights carrying at least one stage entry in the window."), + totalMinutes: z + .number() + .describe("Sum of every value in `stages` — nights only, naps excluded."), + stages: z + .record(z.string(), z.number()) + .describe("Window totals per stage, naps excluded."), + perNight: z.array( + z.object({ + dayKey: z + .string() + .describe("Wake-day key `YYYY-MM-DD` in the account's timezone."), + stages: z + .record(z.string(), z.number()) + .describe( + "Per-stage minutes of the MAIN night only. `IN_BED` is the session envelope and is carried as such.", + ), + napMinutes: z + .number() + .optional() + .describe( + "Time asleep across the day's naps. ABSENT on a day without one — there is no zero to render and nothing to caption.", + ), + napCount: z + .number() + .int() + .optional() + .describe("Absent alongside `napMinutes`."), + }), + ), + }) + .nullable() + .describe( + "Trailing-30-day per-stage breakdown, null when no session in the window carries stage data. Sessions are reconstructed and collapsed to one canonical source per night before the sum, so a dual-source night is not double-counted and a midnight-spanning night is not split across two days.", + ); + +export const analyticsDefaultSliceResponse = z + .object({ + summaries: z.record(z.string(), dataSummary), + bmi: z + .number() + .nullable() + .describe( + "Derived from the latest weight and the profile height, one decimal. Null without a height or without a weight reading.", + ), + bpInTargetPct: z + .number() + .nullable() + .describe( + "Trailing-90-day in-target percentage — the headline window. Null without personalised targets (which need a date of birth) or without readings.", + ), + bpInTargetPct7d: z.number().nullable(), + bpInTargetPct30d: z.number().nullable(), + bpInTargetPctAllTime: z.number().nullable(), + bpInTargetPctPriorMonth: z.number().nullable(), + bpInTargetPctPriorYear: z + .number() + .nullable() + .describe( + "Period-aligned prior windows, so a comparison caption's arithmetic matches its label.", + ), + bpInTargetCount90: z + .number() + .int() + .nullable() + .describe("Readings behind the 90-day figure, for the thin-data gate."), + bpInTargetSpanDays90: z + .number() + .int() + .nullable() + .describe("Effective span the 90-day figure actually covers."), + glucoseByContext: z + .record(z.string(), dataSummary) + .nullable() + .describe( + "Per-context summaries (`FASTING` / `POSTPRANDIAL` / `RANDOM` / `BEDTIME`) over the trailing 30 days, canonical mg/dL. A context with no readings is omitted. NULL — not an empty object — when the glucose module is off.", + ), + glucoseClinical: glucoseClinicalSchema + .nullable() + .describe( + "The clinical panel over the same 30-day window, always populated when the module is on (even with zero readings, so the client renders the calm still-learning state from an object rather than a missing key). Null when the glucose module is off.", + ), + correlations: z + .object({ + bpCompliance: analyticsCorrelation, + moodPulse: analyticsCorrelation, + weightWeekday: analyticsCorrelation, + degraded: z + .boolean() + .describe( + "Reserved for a load-shedding branch. Pinned false by both current paths — do not branch on it yet.", + ), + windowDays: z + .number() + .int() + .describe("The window actually scanned, in days (28 today)."), + path: z + .enum(["rollup", "live"]) + .describe( + "Which read served the measurement side. Falls to `live` for an account more than three hours from UTC, where the rollup table's UTC-midnight day key would slip a calendar day against the mood and intake streams.", + ), + }) + .describe( + "The three pre-defined hypotheses over a 28-day window. Descriptive, never causal.", + ), + healthScore: healthScoreReport, + sleepStages: sleepStageComposition, + sleepSourceDiscrepancy, + lastSeenByType: z.record(z.string(), lastSeenSlot), + }) + .meta({ + id: "AnalyticsDefaultSlice", + description: + "The thick default answer: per-type summaries plus BMI, the blood-pressure in-target windows, the glucose blocks, the three correlation hypotheses, the full health-score report, the sleep-stage breakdown and the freshness map. Every block is deterministic — no LLM is reachable from this path.", + }); + +export const analyticsQuery = z + .object({ + slice: z + .literal("summaries") + .optional() + .describe( + "Send `summaries` for the slim slice. Any OTHER value — including a misspelling — falls through to the thick default rather than 422-ing, so a typo costs latency and not an error.", + ), + }) + .meta({ id: "AnalyticsQuery" }); + +export const analyticsResponse = z + .union([analyticsDefaultSliceResponse, analyticsSummariesSliceResponse]) + .describe( + "Which shape arrives is decided by `slice`, not by the data: `?slice=summaries` answers `AnalyticsSummariesSlice`, everything else answers `AnalyticsDefaultSlice`.", + ); + +// ── Insights provider settings (`/api/insights/settings`) ───────────── + +export const insightsSettingsResponse = z + .object({ + codexStatus: z + .string() + .describe( + "The account's own ChatGPT-OAuth connection state; `disconnected` when the account row carries none.", + ), + codexConnectedAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe("When that connection was made; null when never connected."), + hasAdminKey: z + .boolean() + .describe( + "Presence only: whether the operator has stored a shared AI key. The key itself never leaves the server.", + ), + codexOauthConfigured: z + .boolean() + .describe( + "Whether the operator configured an OAuth client id. False means the connect flow is dead end-to-end, so hide the button rather than sending the user to a login they can never complete.", + ), + centralCodexAvailable: z + .boolean() + .describe( + "Presence only: whether the operator has connected the shared central ChatGPT account AND all three of its stored credentials are present. The encrypted credentials themselves are NEVER returned to a non-admin client.", + ), + useCentralCodex: z + .boolean() + .describe( + "The account's own opt-in to that shared connection. Off by default everywhere.", + ), + privacyMode: z + .enum(["aggregated", "raw"]) + .describe( + "What leaves for the provider: aggregated figures or raw readings. `aggregated` when the account has never chosen.", + ), + lastInsightAt: z.iso + .datetime({ offset: true }) + .nullable() + .describe("When the cached insight text was last written."), + }) + .meta({ + id: "InsightsSettingsResponse", + description: + "The account's AI-provider settings as the settings surface reads them. Every operator-side field is presence-only — no key, token or account id is ever on this wire.", + }); + +export const insightsSettingsPutRequest = z + .object({ + privacyMode: z + .enum(["aggregated", "raw"]) + .optional() + .describe( + "The only field this endpoint writes. Changing it CLEARS the cached insight text and its timestamp, so the next read regenerates under the new mode rather than serving text produced under the old one.", + ), + }) + .meta({ + id: "InsightsSettingsPutRequest", + description: + "Partial settings update. The body is read key by key rather than parsed as a whole: unknown keys are IGNORED, not refused, and a body that changes nothing recognised is refused with 422 rather than answered as a no-op. `privacyMode` is the only writable field today.", + }); + +// ── Target tiles (`GET /api/insights/targets`) ──────────────────────── + +const targetItem = z + .object({ + type: z + .string() + .describe( + "Target identity. A MeasurementType token for the vital tiles, and a domain token for the derived ones (sleep, medication compliance, mood, the glucose tiles).", + ), + label: z.string().describe("Already-resolved display label."), + current: z.number().nullable(), + average30: z.number().nullable(), + trend: z + .enum(["up", "down", "stable"]) + .nullable() + .describe("Null when there is not enough series to call one."), + unit: z.string(), + range: z + .object({ min: z.number(), max: z.number() }) + .nullable() + .describe( + "The band the tile grades against, resolved from the account's own profile facts and any personal override. Null when no band can be resolved.", + ), + classification: z + .object({ category: z.string(), color: z.string() }) + .nullable() + .describe("Where the current value sits in the band; null without one."), + source: z + .string() + .describe("Provenance token for the 'where this comes from' link."), + daysInRange7d: z.number().int(), + daysLogged7d: z.number().int(), + daysInRange30d: z.number().int(), + daysLogged30d: z.number().int(), + lastMetGoalAt: z.string().nullable(), + streakDays: z.number().int(), + insufficientData: z + .boolean() + .describe( + "True when the consistency figures are below their floor. Render the calm learning state instead of the strip.", + ), + consistency7d: z + .array(z.enum(["in", "near", "out"]).nullable()) + .describe( + "Seven day bands, oldest first. A null entry is an UNLOGGED day, which is not the same as an out-of-range one.", + ), + details: z + .object({ + medications: z + .array( + z.object({ + name: z.string(), + compliance7: z.number(), + compliance30: z.number(), + }), + ) + .optional(), + }) + .optional() + .describe( + "Per-tile extras. Only the medication-compliance tile carries any today; every other tile omits the key.", + ), + }) + .meta({ + id: "TargetItem", + description: + "One target tile: the current value against its band, plus the consistency strip behind it. The tile array's membership varies per account — a medication tile appears only with active medications, and the glucose tiles only with readings.", + }); + +export const targetsResponse = z + .object({ + targets: z + .array(targetItem) + .describe( + "Vital tiles in registry order with the sleep tile spliced in after pulse, then the medication tile when one applies, then the mood tiles, then the glucose tiles.", + ), + pageSummary: z.object({ + targetsMetThisWeek: z.number().int(), + totalTargets: z.number().int(), + streakHighlight: z + .object({ metric: z.string(), days: z.number().int() }) + .nullable(), + }), + bpDiastolic: z + .object({ + current: z.number().nullable(), + average30: z.number().nullable(), + range: z.object({ min: z.number(), max: z.number() }).nullable(), + }) + .describe( + "The diastolic half, carried beside the tiles rather than as one: the blood-pressure tile grades on systolic, and this is what the card needs to render the pair.", + ), + profile: z + .object({ + heightCm: z.number().nullable(), + age: z.number().int().nullable(), + gender: z.enum(["MALE", "FEMALE", "OTHER"]).nullable(), + glucoseUnit: z.string(), + }) + .describe( + "The profile facts the bands were resolved from, echoed so a client can explain a band instead of guessing at it.", + ), + }) + .meta({ + id: "TargetsResponse", + description: + "Per-metric target tiles with their consistency strips, the page-level summary, the diastolic companion figures and the profile facts every band was resolved from. Pure compute over the record's own data; no provider anywhere on the path.", + }); + +// ── The AI-provider chain (`/api/insights/provider-chain`) ──────────── +// +// Nothing on either wire carries a key, a token or an account id. The GET +// answers provider TYPES and boolean state; the PUT accepts the same. Worth +// stating explicitly because the surface sits next to the credential settings +// and the obvious assumption about it is wrong. + +export const providerChainResponse = z + .object({ + activeProvider: z + .string() + .nullable() + .describe( + "The provider type the runner would reach first — the head of the RESOLVED chain, which drops disabled entries. Null when the account has none configured.", + ), + cachedActiveProvider: z + .string() + .nullable() + .describe( + "The last provider that actually worked, from the in-process cache. Present when the runner has rerouted to a fallback, so the settings surface can say `active: codex (cached: openai)` instead of leaving the reroute in the logs. In-process, so it resets on restart and can differ between instances.", + ), + configuredChain: z + .array( + z.object({ + providerType: z.string(), + enabled: z + .boolean() + .describe("Whether the runner may reach this entry."), + available: z + .boolean() + .describe( + "Always `true` today. Reserved for rendering an unconfigured slot with a needs-setup pill; do not branch on it as if it reported credential presence.", + ), + }), + ) + .describe( + "The PERSISTED chain in priority order, disabled entries INCLUDED — that is the difference from `activeProvider`, and it is deliberate: reading the resolved list here once made a disabled entry vanish from the settings list along with its toggle position.", + ), + }) + .meta({ + id: "ProviderChainResponse", + description: + "The account's AI-provider chain as the settings surface reads it. Provider TYPES and boolean state only — no key, token or account id is ever on this wire.", + }); + +export const providerChainPutRequest = z + .object({ + chain: z + .array( + z.object({ + providerType: z + .enum(PROVIDER_CHAIN_TYPES as unknown as [string, ...string[]]) + .describe( + "Closed allow-list. The mock provider is excluded from it structurally, which is what keeps production from reaching one.", + ), + priority: z + .number() + .int() + .optional() + .describe( + "ACCEPTED AND IGNORED. Priority is recomputed from the array's insertion order, so a stale client cannot persist a chain whose displayed order disagrees with its stored one. Send the order you want as the order of the array.", + ), + enabled: z.boolean(), + }), + ) + .min(1) + .describe( + "The whole chain, in the order it should be walked. At least one entry and at most one per known provider type; a repeated type is refused.", + ), + }) + .meta({ + id: "ProviderChainPutRequest", + description: + "Replaces the account's provider chain wholesale. No credential is written or read here — only which provider types exist, in what order, and which are enabled.", + }); + +// ── Read-only advisor + generation (`/api/insights/generate`) ───────── + +export const insightsGenerateReadResponse = z + .object({ + insights: z + .record(z.string(), z.unknown()) + .nullable() + .describe( + "The cached insight payload, verbatim from the account's cache row. Null when nothing is cached or the stored row failed to parse — the two are indistinguishable here, and the second also silently enqueues a repair.", + ), + cached: z + .boolean() + .describe("True when a payload was served from the cache."), + cachedAt: z.iso + .datetime({ offset: true }) + .nullable() + .optional() + .describe( + "When that payload was written. Present only on the cached branch — the empty branch omits the key rather than sending null.", + ), + legacyPayload: z + .boolean() + .describe( + "True when the cached payload predates the rationale fields, so the client can offer a regenerate. Never auto-regenerated: that would burn a rate-limit token on a cache hit without the user asking.", + ), + revalidating: z + .boolean() + .describe( + "True when this read enqueued an out-of-band warm because the cache was stale or missing AND a provider exists. Poll (bounded) until it clears rather than sitting on the stale payload.", + ), + hasProvider: z + .boolean() + .describe( + "False when no AI provider is configured anywhere. The served briefing can then NEVER refresh — no warm pass will fill it — so pair its age with a connect-a-provider affordance and stop polling.", + ), + generationFailed: z + .boolean() + .describe( + "True when the most recent generation attempt failed after the last successful one. The last good text is still served, so this is the only honest signal that what is shown is held rather than fresh.", + ), + generationFailureClass: z + .string() + .nullable() + .describe( + "Coarse class of that failure, so the empty state can point at the right lever (raise the response timeout versus re-check the provider). Null when the last attempt succeeded.", + ), + briefingOmittedReason: z + .literal("ungrounded") + .nullable() + .describe( + "Non-null when a generation SUCCEEDED but the grounding gate withheld the briefing — a different state from `generationFailed`, and the reason the card can say 'withheld' instead of showing a silent empty tile.", + ), + }) + .meta({ + id: "InsightsGenerateReadResponse", + description: + "The read-only advisor payload. This route NEVER calls a provider: it serves the cache and, when the cache is stale and a provider exists, enqueues a warm out of band.", + }); + +export const insightsGeneratePostRequest = z + .object({ + force: z + .boolean() + .optional() + .describe( + 'Send `true` to bypass the 24-hour cache and generate now. Anything other than the boolean `true` — including the string `"true"` — is treated as absent. Forcing also means an exhausted hourly quota answers an honest 429 instead of degrading to the cached payload.', + ), + }) + .meta({ + id: "InsightsGeneratePostRequest", + description: + "Optional body for the generating POST. Capped at 16 KiB. `userId` is taken from the session or the Bearer token and is never a body field.", + }); + +export const insightsGeneratePostResponse = z + .object({ + insights: z + .record(z.string(), z.unknown()) + .describe("The insight payload, fresh or cached depending on `cached`."), + cached: z.boolean(), + cachedAt: z.iso + .datetime({ offset: true }) + .optional() + .describe("Present on the cached branch only."), + legacyPayload: z.boolean(), + briefingOmittedReason: z + .literal("ungrounded") + .nullable() + .optional() + .describe( + "Non-null when the grounding gate stripped the briefing from THIS generation. Present on the freshly-generated branch.", + ), + }) + .meta({ + id: "InsightsGeneratePostResponse", + description: + "The generating POST's answer. A 200 does not mean a provider was called — a cache hit inside the 24-hour window, and an exhausted hourly quota on a briefingless cache, both answer 200 with `cached: true`.", + }); + +// ── Coach read strip (`/api/insights/coach-read`) ───────────────────── + +export const coachReadQuery = z + .object({ + metric: measurementTypeEnum.describe( + "The metric the strip is about. Closed enum: an unknown value 422s.", + ), + }) + .meta({ id: "CoachReadQuery" }); + +export const coachReadStripResponse = z + .object({ + baseline: z + .object({ + low: z.number().describe("Robust lower edge of the personal range."), + high: z.number().describe("Robust upper edge."), + latest: z.number().describe("Today's latest reading, same units."), + placement: z.enum(["within", "above", "below"]), + sampleDays: z + .number() + .int() + .describe("Distinct days behind the band, for transparency."), + }) + .nullable() + .describe( + "Line one. Null when the band could not be established or there is no reading to place — and then `learning` is true. Mutually exclusive with it.", + ), + learning: z + .boolean() + .describe( + "True when history is below the engine's seven-day floor or there are no readings. Render 'still learning your range' rather than inventing one.", + ), + driver: z + .object({ + note: z + .string() + .describe( + "An already-localised, conservative, descriptive sentence the client prints VERBATIM. Never causal. Written in the reader's language server-side, which is why it is not a key.", + ), + behaviour: z.string().describe("Lower-cased behaviour label."), + outcome: z.string().describe("Lower-cased outcome label."), + }) + .nullable() + .describe( + "Line two: the single strongest lagged association whose outcome is this metric. Null when none clears the effect-size floor, and null when the correlation read failed — the failure is deliberately swallowed so a hiccup there never sinks line one.", + ), + }) + .meta({ + id: "CoachReadStrip", + description: + "The two server-authoritative lines a metric sub-page renders above its chart. Pure compute over the baseline and correlation engines — no provider call, no cache table — so web and native decode the same resolved DTO.", + }); + +// ── Coach seeded opener (`/api/insights/coach/seeded-question`) ─────── + +export const coachSeededQuestionResponse = z + .object({ + signal: z + .object({ + sourceMetric: z + .string() + .describe( + "Sentinel id the client keys its localised copy on (`readiness` / `recovery`).", + ), + score: z.number().describe("The latest 0..100 score."), + band: z + .string() + .describe( + "`yellow` or `red`. Green never surfaces — a good day is not notable.", + ), + }) + .nullable() + .describe( + "Null whenever nothing crossed the detector's confidence and notability gate, AND whenever the account has turned proactive suggestions off — the two are indistinguishable on the wire, and both mean the hero keeps its neutral greeting rather than showing a fabricated opener.", + ), + }) + .meta({ + id: "CoachSeededQuestion", + description: + "Today's single most notable derived wellness signal, resolved server-side into a tappable opener for the Coach's blank-chat hero. The client renders the resolved DTO and never recomputes the selection.", + }); + +// ── Period narrative (`/api/insights/narrative`) ────────────────────── + +export const narrativeQuery = z + .object({ + period: z + .enum(NARRATIVE_PERIOD_VALUES as [string, ...string[]]) + .describe("Which retrospective to read. Closed enum: unknown 422s."), + locale: z + .string() + .optional() + .describe( + "Override the reader's resolved locale. The narrative row is keyed per locale, so this selects a different stored row rather than translating one; an unrecognised value falls back to the app default instead of 422-ing.", + ), + }) + .meta({ id: "NarrativeQuery" }); + +export const narrativeResponse = z + .object({ + period: z.string().describe("Echoes the requested period."), + locale: z.string().describe("The locale the row was read for."), + narrative: z + .object({ + text: z.string().describe("The retrospective prose, rendered as-is."), + provenance: z + .string() + .describe( + "Where the text came from — provider prose or the deterministic non-causal fallback a provider-less account gets.", + ), + updatedAt: z.iso.datetime({ offset: true }), + }) + .nullable() + .describe("Null when no row exists for this period and locale yet."), + revalidating: z + .boolean() + .describe( + "True when this read enqueued a warm. Always FALSE on a delegated request even when the row is stale: this route warms unconditionally rather than on a miss, so a manager's first navigation here would otherwise be an egress of the owner's record the owner never asked for. The stale row is still served; only the warm is withheld.", + ), + }) + .meta({ + id: "NarrativeResponse", + description: + "The latest generated period retrospective. Read-only by construction — it never blocks on a provider, serves the last good row immediately, and warms out of band.", + }); + +// ── GLP-1 therapy timeline (`/api/insights/glp1-timeline`) ──────────── + +export const glp1TimelineQuery = z + .object({ + limit: z.coerce + .number() + .int() + .min(1) + .max(200) + .optional() + .default(60) + .describe( + "Entry cap, 1..200, default 60. A non-integer, out-of-range or non-numeric value falls back to the default rather than 422-ing.", + ), + }) + .meta({ id: "Glp1TimelineQuery" }); + +export const glp1TimelineResponse = z + .object({ + hasGlp1: z + .boolean() + .describe( + "False when the record holds no medication classified GLP-1. `entries` is then empty and the surface hides cleanly.", + ), + entries: z + .array( + z.object({ + date: z.iso + .datetime({ offset: true }) + .describe( + "When it happened. A `side-effect` entry is a DAY rather than an instant, and is stamped at 12:00 UTC of that day so it sorts — do not read it as a capture time.", + ), + kind: z.enum([ + "dose-change", + "injection", + "inventory", + "side-effect", + ]), + medicationName: z + .string() + .optional() + .describe("Absent on a `side-effect` entry."), + doseValue: z.number().optional(), + doseUnit: z.string().optional(), + doseDelta: z + .enum(["up", "down"]) + .nullable() + .optional() + .describe("Titration direction against the previous step."), + note: z + .string() + .nullable() + .optional() + .describe("The titration note, decrypted."), + injectionSite: z.string().nullable().optional(), + inventoryDelta: z.number().int().optional(), + reason: z.string().optional(), + tags: z + .array(z.string()) + .optional() + .describe( + "The recognised side-effect tags picked up that day. Matched against a FIXED bilingual list of English and German tag strings, so a tag written in any other language — or spelled differently — does not appear here at all.", + ), + }), + ) + .describe( + "Newest first, capped at `limit`. A heterogeneous union: which optional keys are present depends on `kind`, and no entry carries all of them.", + ), + }) + .meta({ + id: "Glp1TimelineResponse", + description: + "A chronological merge of the GLP-1-relevant events the record holds: titration steps, injections with their sites, legacy stock-ledger movements, and side-effect-tagged mood days from the trailing 90 days. Inventory entries come from the legacy running-sum ledger only, so a medication tracked with per-container inventory shows none.", + }); + +// ── Intraday pulse (`/api/insights/pulse/intraday`) ─────────────────── + +export const intradayPulseQuery = z + .object({ + date: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional() + .describe( + "The local day to read. Defaults to the record's local today. Anything but a `YYYY-MM-DD` literal is a 422 — this parameter is strict where most of the surface's date filters are forgiving.", + ), + }) + .meta({ id: "IntradayPulseQuery" }); + +export const intradayPulseResponse = z + .object({ + dateKey: z.string().describe("The local day that was read."), + timezone: z.string().describe("The zone the day was anchored to."), + bucketMinutes: z.number().int().describe("Width of one bucket."), + series: z + .array( + z.object({ + startMinute: z + .number() + .int() + .describe("Minutes since local midnight at the bucket start."), + mean: z.number(), + count: z + .number() + .int() + .describe( + "Raw samples behind the mean. A bucket below the density floor is a GAP that breaks a candidate tension run, not a thin data point — a lone reading is a point, not a bucket.", + ), + min: z + .number() + .optional() + .describe("Bucket low, when the source carries a spread."), + max: z.number().optional().describe("Bucket high, same condition."), + }), + ) + .describe("The day's shape, ascending."), + baseline: z + .number() + .nullable() + .describe("The resting baseline the day was judged against."), + baselineSource: z + .enum(["resting", "proxy", "none"]) + .describe( + "`resting` from recorded resting heart rate, `proxy` when those rows are stale or absent and the pulse history stood in, `none` when no baseline could be formed.", + ), + tension: z + .object({ + startMinute: z.number().int(), + endMinute: z.number().int(), + partOfDay: z.enum(["morning", "afternoon", "evening", "night"]), + meanHr: z.number(), + baseline: z.number(), + hrvConfirmed: z + .boolean() + .describe("True when intraday HRV independently confirmed it."), + }) + .nullable() + .describe( + "At most ONE cautious elevated-at-rest window, and only when every confidence gate holds — the bar is set high and under-flagging is the intended failure mode. ALWAYS null on an `hourly` day, because a tension read needs per-sample resolution.", + ), + resolution: z + .enum(["tenMin", "hourly"]) + .describe( + "`tenMin` when the series was folded from live raw samples, `hourly` when the day fell outside the dense-retention window and the coarser folded tier answered instead. An older day reads back coarser rather than empty.", + ), + }) + .meta({ + id: "IntradayPulseResponse", + description: + "One local day's intraday heart-rate shape, plus at most one cautious elevated-at-rest window. Computed from raw samples through the read-swap pattern rather than persisted as ten-minute rollups for all history. Awareness only, never a diagnosis.", + }); diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index e59584feb..36877fb65 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -525,7 +525,11 @@ const averageSleepResource = z.object({ .describe("Scorable nights still needed before the average is asserted."), }); -const sleepRhythmResponse = z +// Exported because `GET /api/dashboard/summary` carries the identical DTO on +// its `sleepRhythm` key — the route builds it from the same +// `computeSleepRhythmFromNights` call `/api/sleep/rhythm` uses. Two copies of +// the shape would be two things to keep in step. +export const sleepRhythmResponse = z .object({ sleepDebt: sleepDebtResource, chronotype: chronotypeResource, @@ -537,6 +541,81 @@ const sleepRhythmResponse = z "Server-authoritative sleep-rhythm read: the outstanding sleep-debt balance over the rolling window + MCTQ chronotype (MSF/MSFsc band, social jetlag). Free vs work nights default to weekend = free in the user's timezone (no work calendar). A view over existing per-stage SLEEP_DURATION rows — no schema, no new type.", }); +// ── Personal records ───────────────────────────────────────────────── +// +// `GET /api/personal-records` shipped as a schema-only contract so the native +// client could build its query path against a stable shape before the +// detection worker landed. It answers the stored rows verbatim — including +// `userId` and the provenance foreign key — because the handler passes the +// Prisma result straight to `apiSuccess` with no projection. + +const personalRecordQuery = z + .object({ + metricType: measurementTypeEnum + .optional() + .describe( + "Restrict to one measurement type. A value outside the enum is DROPPED rather than refused — the read then covers every metric, which is indistinguishable from omitting the parameter.", + ), + limit: z.coerce + .number() + .int() + .min(1) + .max(500) + .optional() + .default(100) + .describe( + "Row cap, 1..500, default 100. A non-integer, zero, negative or non-numeric value falls back to the default and a value above the ceiling clamps to 500 — none of them 422.", + ), + }) + .meta({ id: "PersonalRecordQuery" }); + +const personalRecordResource = z + .object({ + id: z.string(), + userId: z + .string() + .describe( + "The RECORD owner's id, which is not the caller's id on a delegated read.", + ), + metricType: measurementTypeEnum, + metricSlot: z + .string() + .nullable() + .describe( + "Per-sport-type bucket for workout-driven records (e.g. `running_5km_time`). Null for a plain measurement-driven record, where the metric type alone is the dimension.", + ), + direction: z + .enum(["MAX", "MIN"]) + .describe( + "Which way is better for this metric: `MAX` for steps / VO2 max / HRV / distance / daylight, `MIN` for resting heart rate / body fat / audio exposure.", + ), + value: z.number(), + unit: z.string(), + achievedAt: z.iso + .datetime({ offset: true }) + .describe( + "When the record was set. The list orders on this, newest first.", + ), + sourceMeasurementId: z + .string() + .nullable() + .describe( + "The measurement row that achieved the record. Nulled rather than cascaded when that measurement is deleted, so the historical fact survives its source.", + ), + source: measurementSourceEnum, + externalId: z.string().nullable(), + createdAt: z.iso + .datetime({ offset: true }) + .describe( + "When the detection worker wrote the row — distinct from `achievedAt`, which is when the reading happened.", + ), + }) + .meta({ + id: "PersonalRecord", + description: + "One personal record: the best value a metric (optionally within a per-sport slot) has reached, in the direction that counts as better for it.", + }); + // ── Time-series adapter (iOS chart source) ─────────────────────────── // // v1.16.16 — documents the long-shipped `GET /api/measurements/series` @@ -1057,4 +1136,28 @@ export const measurementPaths: NonNullable = { }, }, }, + "/api/personal-records": { + get: { + tags: ["Measurements"], + summary: "List the record's personal records", + description: + "Returns the account's personal-record rows, `achievedAt` descending. Both query parameters are FORGIVING rather than strict: an unrecognised `metricType` is dropped (the read falls back to every metric) and an unusable `limit` clamps to the default instead of 422-ing, so a loosely-typed client filter cannot take the page down. Rows are the stored entities verbatim, including `userId` and the provenance `sourceMeasurementId`. Delegable at READ level over the `measurements` section. Not module-gated: the list spans metrics from several modules and is filtered by the caller's `metricType`, not by the account's module map — a record achieved under a metric whose module is now off is still returned. Cookie or Bearer auth.", + requestParams: { query: personalRecordQuery }, + responses: { + ...recordRefusal(), + "200": { + description: "The personal-record rows (possibly empty).", + content: { + "application/json": { + schema: dataEnvelope( + z.array(personalRecordResource), + "PersonalRecordListEnvelope", + ), + }, + }, + }, + ...stdResponses, + }, + }, + }, }; diff --git a/src/lib/openapi/routes/medications/paths.ts b/src/lib/openapi/routes/medications/paths.ts index a6fc63ef5..9d214a7fb 100644 --- a/src/lib/openapi/routes/medications/paths.ts +++ b/src/lib/openapi/routes/medications/paths.ts @@ -13,6 +13,11 @@ import { createInventoryItemSchema, updateInventoryItemSchema, injectionSiteEnum, + intakeAggregateQuerySchema, + intakeStatusUpdateSchema, + glp1PostBodySchema, + bulkDeleteIntakeEventsSchema, + updateIntakeEventSchema, } from "@/lib/validations/medication"; import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt"; import { @@ -63,7 +68,46 @@ import { doseHistoryResponse, medicationIntakeImportJobStatusResponse, medicationDoseHistoryImportResponse, + todayIntakeEntry, + complianceDayBucket, + glp1DetailResponse, + glp1DoseChange, + glp1InventoryEvent, + medicationApiEndpointState, + medicationApiEndpointEnabled, + medicationApiEndpointDisabled, + reminderPhaseConfig, + medicationSideEffect, } from "./schemas"; +import { phaseConfigSchema } from "@/lib/validations/phase-config"; +import { + createSideEffectSchema, + listSideEffectsSchema, +} from "@/lib/medications/side-effects/validators"; + +const phaseConfigPutRequest = phaseConfigSchema.meta({ + id: "ReminderPhaseConfigPutRequest", + description: + "The complete phase configuration; every field is required, so this replaces rather than merges. Values are 0..1440 and each carries its own mode.", +}); + +const createSideEffectRequest = createSideEffectSchema.meta({ + id: "CreateMedicationSideEffectRequest", + description: + "Log one side effect. `category` is NOT a field — the server derives it from `entry` through the authoritative taxonomy, and a category sent anyway is dropped rather than refused, so an older client cannot stamp a row with one that contradicts its entry. `occurredAt` defaults to now. `notes` is capped at 280 characters and is encrypted at rest.", +}); + +const listSideEffectsQuery = listSideEffectsSchema.meta({ + id: "ListMedicationSideEffectsQuery", + description: + "Window and cap for the side-effect list. `from` is inclusive and `to` is EXCLUSIVE — an asymmetry worth reading before paging on it. `limit` is 1..200, default 50.", +}); + +const bulkDeleteIntakeRequest = bulkDeleteIntakeEventsSchema.meta({ + id: "BulkDeleteMedicationIntakeRequest", + description: + "The intake events to tombstone, 1..500 ids. Ids that do not belong to this medication AND this record are silently DROPPED rather than refused, so a partial match succeeds with a lower `deleted` count than the ids sent.", +}); // ── Bulk intake backfill (iOS SyncMode) — mirrors the route's // `bulkPayloadSchema` / `bulkEntrySchema` and the batch-envelope response. @@ -171,6 +215,649 @@ const medicationIntakeImportJobStatusEnvelope = dataEnvelope( ); export const medicationPaths: NonNullable = { + "/api/medications/intake": { + get: { + tags: ["Medications"], + summary: "Today's doses, or per-day compliance", + description: + "Two reads behind one path, chosen by `scope`. `today` lists every dose slot for the current local day across all medications, ordered by slot. `compliance` returns per-day scheduled-versus-taken buckets over the trailing `days`. The `today` scope WRITES before it reads: it projects the pending intake rows for every active schedule whose window opens today and idempotently backfills the missing ones, because a daily schedule has no row to read until the reminder worker reaches the end of its dose window — so a plain read would return an empty morning. The `compliance` scope is cached per (record, days, timezone). Delegable at READ level over the `medications` section. Not module-gated. Cookie or Bearer auth.", + requestParams: { query: intakeAggregateQuerySchema }, + responses: { + ...recordRefusal(), + "200": { + description: + "`TodayIntakeEntry[]` for `scope=today`, `MedicationComplianceDayBucket[]` for `scope=compliance`. The array is the whole payload — there is no wrapper object inside `data`.", + content: { + "application/json": { + schema: dataEnvelope( + z.union([ + z.array(todayIntakeEntry), + z.array(complianceDayBucket), + ]), + "MedicationIntakeAggregateEnvelope", + ), + }, + }, + }, + "422": { + description: + "The query failed validation — a missing or unknown `scope`, or a `days` outside 1..365. Multi-issue envelope; an audit breadcrumb is filed under the RECORD as `medications.intake.list.validation-failed` with the issue messages stripped of echoed input.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "401": stdResponses["401"], + "429": stdResponses["429"], + }, + }, + post: { + tags: ["Medications"], + summary: "Mark a dose taken, skipped or snoozed", + description: + "Updates one intake event by id and returns the full row. A `taken` toggle re-runs slot attribution, so an off-window or edited take re-binds to the right slot instead of leaving `scheduledFor` stale — and when it re-binds to a DIFFERENT slot the source row is tombstoned and the dose converges onto the target slot, which means the returned row's `id` can differ from the `intakeId` that was sent. Inventory is consumed exactly once on a genuine transition into taken and refunded on the way out. A `snoozed` toggle writes the deferral onto the MEDICATION, deferring every pending dose of it. Two transitions are refused for a DELEGATE and stay open to the owner: snoozing, because the field is unbounded and every surface that exists to make delegation visible would describe years of silenced reminders as `marked a dose`; and flipping an already-resolved event, because marking an open dose is a contribution while changing a decision the owner already recorded is an edit. Delegable at WRITE level over the `medications` section, and the owner is notified when somebody else marks their dose. Cookie or Bearer auth.", + requestBody: { + required: true, + content: { + "application/json": { schema: intakeStatusUpdateSchema }, + }, + }, + responses: { + ...recordRefusal( + "Refused: a delegate tried to snooze, or to change a dose the owner had already recorded as taken or skipped (`meta.errorCode` = `sharing.not_permitted`). Both stay open to the owner, and the attempt is filed on the owner's activity trail.", + ), + "200": { + description: + "The updated intake event. On a slot move this is the CONVERGED row, whose id may differ from the requested one.", + content: { + "application/json": { + schema: dataEnvelope( + medicationIntakeEventResource, + "MedicationIntakeStatusEnvelope", + ), + }, + }, + }, + "404": { + description: + "No live intake event with that id for this record — an unknown id, a tombstoned one, and one belonging to another record are indistinguishable.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "422": { + description: + "Body validation failed (multi-issue envelope, breadcrumbed as `medications.intake.update.validation-failed`), or `meta.errorCode` = `medications.intake.injection_site.disallowed` when the site is outside the medication's effective allowed set, or `medications.intake.force_slot.invalid` when `forceSlotInstant` is not a real slot of the medication. The latter two carry no issue list — branch on the code.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "401": stdResponses["401"], + "429": stdResponses["429"], + }, + }, + }, + "/api/medications/{id}/glp1": { + get: { + tags: ["Medications"], + summary: "GLP-1 detail — titration, recent injections, supply", + description: + "Returns the extras the GLP-1 card variant, the dashboard tile and the doctor-report section read: the full dose-change history, the last twelve injections with their sites, and the running supply math carrying the same low-stock verdict the daily notification uses. Delegable at READ level over the `medications` section, the level `/inventory`, `/side-effects` and `/cadence` ask for the same medication's data — and strictly narrower than the MANAGE-level write the POST on this path already permits. Cookie or Bearer auth.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + ...recordRefusal(), + "200": { + description: "The GLP-1 detail block.", + content: { + "application/json": { + schema: dataEnvelope( + glp1DetailResponse, + "Glp1DetailResponseEnvelope", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another account).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + post: { + tags: ["Medications"], + summary: "Record a titration step or a legacy stock correction", + description: + "The body carries exactly one of `doseChange` or `inventory`; carrying both or neither is refused. `doseChange` writes a titration step with its note encrypted at rest and evicts the medication caches so the card's runway reflects the new daily-dose estimate. `inventory` is DEPRECATED: it appends to the legacy running-sum pen ledger, which reads back only while the medication has no per-container inventory items — new callers register containers through the inventory endpoints instead. Rate-limited 30/min keyed on the ACTOR, so a delegate burns their own allowance and cannot collect a fresh one by switching records. Audits both branches as `medication.glp1.update`. Delegable at MANAGE level over the `medications` section: a titration step is record data and the dose is what the server then acts on.", + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: true, + content: { "application/json": { schema: glp1PostBodySchema } }, + }, + responses: { + ...recordRefusal(), + "201": { + description: + "Created. The body carries `doseChange` OR `inventory` — whichever branch the request took — and never both.", + content: { + "application/json": { + schema: dataEnvelope( + z.union([ + z.object({ doseChange: glp1DoseChange }), + z.object({ inventory: glp1InventoryEvent }), + ]), + "Glp1PostResponseEnvelope", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another account).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/{id}/api-endpoint": { + get: { + tags: ["Medications"], + summary: "Is the per-medication ingest endpoint enabled?", + description: + "Presence only: whether any live token carries this medication's ingest scope, and how many. No token, hash or prefix is on this wire — tokens are stored as an HMAC and there is no path back to the plaintext. Refused with 403 when the operator has switched the API off globally, which is checked BEFORE ownership, so that refusal is visible even for a medication the caller does not hold. NOT delegable: the caller is always resolved as themselves, which is right for a credential surface. Cookie or Bearer auth.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "200": { + description: "Whether the endpoint is on.", + content: { + "application/json": { + schema: dataEnvelope( + medicationApiEndpointState, + "MedicationApiEndpointStateEnvelope", + ), + }, + }, + }, + "403": { + description: "The operator has disabled the API globally.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "404": { + description: "Medication not found (or owned by another account).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Medications"], + summary: "Enable or disable the per-medication ingest endpoint", + description: + "Enabling mints an API token scoped to `medication::ingest` and returns the RAW token — the one and only time it is ever returned, because the stored value is an HMAC and cannot be reversed. Read `created` rather than inferring from the token: enabling an already-enabled endpoint answers 200 with `token: null` and `created: false` and mints nothing, so a lost token is recovered by disabling and enabling again, not by re-reading. Disabling revokes every live token carrying the scope and answers a DIFFERENT shape — `revokedTokenCount` instead of `activeTokenCount` — so one decoder for both outcomes will find the key missing. The body is read by hand rather than parsed: `enabled` is true only for the literal boolean, so any other value disables. Minted tokens carry no expiry. Both branches audit. Refused with 403 when the operator has switched the API off globally. NOT delegable. Cookie or Bearer auth.", + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: true, + content: { + "application/json": { + schema: z + .object({ + enabled: z + .boolean() + .describe( + '`true` — and only the literal boolean true — enables. Anything else, including the string `"true"` and an absent key, disables.', + ), + }) + .meta({ id: "MedicationApiEndpointPutRequest" }), + }, + }, + }, + responses: { + "200": { + description: + "Either the endpoint was disabled (`MedicationApiEndpointDisabled`), or it was already enabled and nothing was minted (`MedicationApiEndpointEnabled` with `token: null`).", + content: { + "application/json": { + schema: dataEnvelope( + z.union([ + medicationApiEndpointDisabled, + medicationApiEndpointEnabled, + ]), + "MedicationApiEndpointPutEnvelope", + ), + }, + }, + }, + "201": { + description: + "A token was minted. `token` carries the raw `hlk_` value — store it now, it is never shown again.", + content: { + "application/json": { + schema: dataEnvelope( + medicationApiEndpointEnabled, + "MedicationApiEndpointCreatedEnvelope", + ), + }, + }, + }, + "403": { + description: "The operator has disabled the API globally.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "404": { + description: "Medication not found (or owned by another account).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "413": { + description: "Request body exceeds 64 KiB.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "422": { + description: + "The body was not valid JSON. A single-message envelope with no issue list.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "401": stdResponses["401"], + "429": stdResponses["429"], + }, + }, + }, + "/api/medications/{id}/phase-config": { + get: { + tags: ["Medications"], + summary: "Read a medication's reminder phase configuration", + description: + "How long each reminder phase runs for this medication. When no row is stored the DEFAULTS are returned rather than a 404 or a null — and the way to tell the two apart is that the defaults carry no `id` and no `medicationId`. Delegable at READ level over the `medications` section. Cookie or Bearer auth.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + ...recordRefusal(), + "200": { + description: "The stored configuration, or the defaults.", + content: { + "application/json": { + schema: dataEnvelope( + reminderPhaseConfig, + "ReminderPhaseConfigEnvelope", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another account).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Medications"], + summary: "Replace a medication's reminder phase configuration", + description: + "Upserts the whole configuration — every field is required, so this replaces rather than merges. The stored row is built field by field from the parsed body rather than spread, so a future schema field cannot land here unnoticed. NOT delegable, unlike the GET beside it: the write resolves the caller as themselves, so a manager can read a shared record's phase configuration and cannot change it. Body capped at 64 KiB. Cookie or Bearer auth.", + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: true, + content: { "application/json": { schema: phaseConfigPutRequest } }, + }, + responses: { + "200": { + description: "The stored configuration, with its row id.", + content: { + "application/json": { + schema: dataEnvelope( + reminderPhaseConfig, + "ReminderPhaseConfigSavedEnvelope", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another account).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Medications"], + summary: "Reset a medication's reminder phases to the defaults", + description: + "Drops the stored row so the GET falls back to the defaults. Idempotent — resetting a medication that has no row succeeds and reports the same thing. NOT delegable, matching the PUT. Cookie or Bearer auth.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "200": { + description: + "Reset. The response confirms the write and does not echo the defaults.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ reset: z.literal(true) }), + "ReminderPhaseConfigResetEnvelope", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another account).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/{id}/side-effects": { + get: { + tags: ["Medications"], + summary: "List a medication's side-effect log", + description: + "The record's side-effect entries for this medication, newest first, optionally bounded by a window. `from` is inclusive and `to` is EXCLUSIVE. Notes are decrypted on read and the ciphertext column never leaves the server. Delegable at READ level over the `medications` section. Cookie or Bearer auth.", + requestParams: { + path: z.object({ id: z.string() }), + query: listSideEffectsQuery, + }, + responses: { + ...recordRefusal(), + "200": { + description: + "The entries, plus a `meta.total` that counts THIS PAGE rather than the whole log — it is the returned array's length, so it can never exceed `limit`.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + items: z.array(medicationSideEffect), + meta: z.object({ total: z.number().int().nonnegative() }), + }), + "ListMedicationSideEffectsEnvelope", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another account).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + post: { + tags: ["Medications"], + summary: "Log a side effect", + description: + "Records one entry against the medication. The category is derived server-side from `entry`, so a client cannot stamp a row with one that contradicts it. The note is encrypted at rest. Rate-limited 30/min keyed on the ACTOR, which is the condition this verb was admitted for delegation on: a delegate burns their own allowance rather than locking the owner out of their own log, and cannot collect a fresh one by switching records. Delegable at WRITE level over the `medications` section. Body capped at 64 KiB. Cookie or Bearer auth.", + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: true, + content: { "application/json": { schema: createSideEffectRequest } }, + }, + responses: { + ...recordRefusal(), + "201": { + description: "The created entry, with its note decrypted.", + content: { + "application/json": { + schema: dataEnvelope( + medicationSideEffect, + "CreateMedicationSideEffectEnvelope", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another account).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: + "More than 30 side-effect writes in the trailing minute for this ACTOR.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "401": stdResponses["401"], + "422": stdResponses["422"], + }, + }, + }, + "/api/medications/{id}/side-effects/{logId}": { + delete: { + tags: ["Medications"], + summary: "Remove one side-effect entry", + description: + "A HARD delete — the row is removed, not tombstoned, and nothing on the sync feed carries the removal. It is allowed at any time rather than inside a retraction window, deliberately: a side-effect entry is not a clinical record, the account owns it, and a stale mis-entry should never be undeletable. What survives is the audit row, which carries the entry, the severity and the date it happened, so the deletion is reconstructable from the trail even though the row is gone. Delegable at MANAGE level over the `medications` section. Cookie or Bearer auth.", + requestParams: { + path: z.object({ id: z.string(), logId: z.string() }), + }, + responses: { + ...recordRefusal(), + "200": { + description: "The entry was deleted.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ id: z.string(), deleted: z.literal(true) }), + "DeleteMedicationSideEffectEnvelope", + ), + }, + }, + }, + "404": { + description: + "No such entry for this medication and this record. An unknown id, another medication's entry and another record's entry are indistinguishable.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/{id}/intake/{eventId}": { + put: { + tags: ["Medications"], + summary: "Correct one dose", + description: + "Edits a single intake event. Whenever `takenAt` or `skipped` changes, slot attribution is re-run and `scheduledFor` snaps to the matched window — or to the take's own time when it falls in none. `forceSlotInstant` overrides that with a named real slot of this medication; an explicit `null` UNPINS and re-attributes by band, resetting the binding provenance to automatic. A correction that lands on a DIFFERENT slot is not an in-place edit: the original row is tombstoned and the dose is re-created on the corrected slot, so the row id in the response can differ from the `eventId` in the path, and the tombstone rides the sync feed. A `takenAt` before the medication's start date is refused. Tombstoned events 404 rather than resurrect. Delegable at MANAGE level over the `medications` section. Body capped at 64 KiB. Cookie or Bearer auth.", + requestParams: { + path: z.object({ id: z.string(), eventId: z.string() }), + }, + requestBody: { + required: true, + content: { "application/json": { schema: updateIntakeEventSchema } }, + }, + responses: { + ...recordRefusal(), + "200": { + description: + "The corrected event. On a slot move this is the RE-CREATED row, whose id differs from the one in the path.", + content: { + "application/json": { + schema: dataEnvelope( + medicationIntakeEventResource, + "UpdateMedicationIntakeEventEnvelope", + ), + }, + }, + }, + "404": { + description: + "No live event with that id under this medication and record. An unknown id and an already-tombstoned one are indistinguishable.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "422": { + description: + "Body validation failed (multi-issue), or one of three coded refusals: `medications.intake.taken_at.before_start` when the edited take predates the medication's start day in the record's timezone, `medications.intake.force_slot.invalid` when the pinned instant is not a real slot, and `medications.intake.force_slot.occupied` when it is a real slot that another live dose already holds.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "401": stdResponses["401"], + "429": stdResponses["429"], + }, + }, + delete: { + tags: ["Medications"], + summary: "Undo one dose", + description: + "A SOFT delete: the row stays, `deletedAt` is stamped and the sync counter bumps, so a client that was offline when it happened receives a tombstone keyed on the server id rather than silently keeping the dose forever. Every today, compliance and list read filters tombstones out, so it is invisible from here on. The inventory stamp is REFUNDED — undoing a dose puts the units back in the container — and the day's compliance rollup is recomputed, dropping the row entirely when the day now holds nothing. Undoing the single live dose of a one-shot medication reactivates that medication so the lists and the worker pick it back up. Re-deleting is harmless. Delegable at MANAGE level over the `medications` section. Cookie or Bearer auth.", + requestParams: { + path: z.object({ id: z.string(), eventId: z.string() }), + }, + responses: { + ...recordRefusal(), + "200": { + description: "The dose was tombstoned and its inventory refunded.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ deleted: z.literal(true) }), + "DeleteMedicationIntakeEventEnvelope", + ), + }, + }, + }, + "404": { + description: + "No event with that id under this medication and record.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/{id}/intake/bulk-delete": { + post: { + tags: ["Medications"], + summary: "Undo several doses at once", + description: + "Tombstones up to 500 named intake events in one call, so a multi-select in the history preview does not fire N concurrent deletes. Ids are matched against THIS medication and THIS record; anything else is silently dropped rather than refused, so a partial match succeeds with a lower `deleted` count than the number of ids sent, and an id leaked from another medication's history deletes nothing. Every stamped row's inventory consumption is refunded before the sweep, and the compliance rollup is recomputed once per affected day rather than once per event. A POST rather than a DELETE because the ids ride a body. Re-posting already-tombstoned ids counts zero. Rate-limited 30/min keyed on the ACTOR. Delegable at MANAGE level over the `medications` section. Body capped at 256 KiB. Cookie or Bearer auth.", + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: true, + content: { "application/json": { schema: bulkDeleteIntakeRequest } }, + }, + responses: { + ...recordRefusal(), + "200": { + description: + "`deleted` is the REAL tombstone count, which can be lower than the ids sent — and is 0 when none matched, which is a success rather than a 404.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ deleted: z.number().int().nonnegative() }), + "BulkDeleteMedicationIntakeEnvelope", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another account).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: + "More than 30 bulk deletes in the trailing minute for this ACTOR.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "401": stdResponses["401"], + "422": stdResponses["422"], + }, + }, + }, + "/api/medications/{id}/intake/purge": { + delete: { + tags: ["Medications"], + summary: "Clear a medication's whole intake history", + description: + "Tombstones EVERY live intake event for this medication in one call, and deletes its compliance rollup rows outright. The rollups are the only hard deletion and they are recomputable, so nothing that cannot be rebuilt is destroyed: the intake rows stay with `deletedAt` set, which is what lets the sync feed emit a tombstone per row to a client that was offline. That was not always true — this was the one destructive verb in the family with nothing to reconstruct from, and a purge fired by mistake was final and invisible to sync. Deliberately does NOT refund inventory, unlike the single and bulk deletes: those mean `I did not take that dose` and put the units back, while a purge wipes a history as bookkeeping, and refunding every dose ever taken would inflate the current container count instead. A second purge counts zero. There is no confirmation step on the wire. Delegable at MANAGE level over the `medications` section. Cookie or Bearer auth.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + ...recordRefusal(), + "200": { + description: "`count` is how many live events were tombstoned.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + purged: z.literal(true), + count: z.number().int().nonnegative(), + }), + "PurgeMedicationIntakeEnvelope", + ), + }, + }, + }, + "404": { + description: "Medication not found (or owned by another account).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/medications/{id}/intake/import": { + post: { + tags: ["Medications"], + summary: "Import a dose history for one medication", + description: + "Accepts 1..1000 `{ datum, uhrzeit }` rows and enqueues a background import, answering 202 with a job id and the URL to poll. The body may be the bare array or an object carrying one — the first array-valued property is taken. Identity comes from the INSTANT, which is also the grain the database enforces, so a re-import of the same file dedups. It did not always: an optional third field decided the replay key while presenting itself as a quantity, and a file that repeated one value down that column collapsed a month of history onto a single key. The field is gone from the contract rather than accepted-and-ignored, and a payload still carrying it imports unchanged. Timestamps are bounded on the FUTURE side only — importing years of history is the point, so the past side is open back to 1900. Only one import may run per medication at a time. Rate-limited 60/min keyed on the ACTOR. Delegable at MANAGE level over the `medications` section: the import is additive with duplicates skipped, every written row tombstones, and the `IMPORT` provenance it stamps stays true under delegation because it describes where the data came from and not who typed it. Body capped at 1 MiB. Cookie or Bearer auth.", + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: true, + content: { + "application/json": { + schema: z + .array( + z.object({ + datum: z + .string() + .describe("`YYYY-MM-DD`. Strict; no other form parses."), + uhrzeit: z + .string() + .describe("`HH:MM:SS`. Strict; no other form parses."), + }), + ) + .min(1) + .max(1000) + .meta({ + id: "MedicationIntakeImportRequest", + description: + "The rows to import. May also be sent wrapped in an object, in which case the first array-valued property is used. Unrecognised per-row keys are dropped rather than refused. The pair must be a real calendar instant — 31 February is refused — and must not be in the future.", + }), + }, + }, + }, + responses: { + ...recordRefusal(), + "202": { + description: + "Accepted and enqueued. Nothing is imported yet: poll `statusUrl` for progress and the per-row skip reasons.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ + jobId: z.string(), + status: z.literal("queued"), + statusUrl: z.string(), + }), + "MedicationIntakeImportAcceptedEnvelope", + ), + }, + }, + }, + "409": { + description: + "An import for this medication is already running. Wait for it rather than retrying.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "422": { + description: + "The payload did not parse. Multi-issue envelope with `meta.errorCode` = `medication.intake.import.invalid_format`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "429": { + description: + "More than 60 import submissions in the trailing minute for this ACTOR.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "503": { + description: + "The background worker is not available, so nothing was enqueued. Retry later.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "404": { + description: "Medication not found (or owned by another account).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "401": stdResponses["401"], + }, + }, + }, "/api/medications/intake/bulk": { post: { parameters: [idempotencyKeyParameter], diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index cb54ebddf..de32a1a80 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -1289,3 +1289,272 @@ export const medicationDoseHistoryImportFatalReasonEnum = z description: "Whole-file refusal reason on the 422 `errorCode`. empty_file — no data rows. missing_required_columns — the CSV header is missing a column the parser needs. unreadable_json — the body did not parse as JSON. json_not_an_array — parsed JSON is not an array (and no nested array was found under any key). json_carries_no_intake_time — well-formed JSON export input, refused by design (see above).", }); + +// ── The top-level intake aggregator (`/api/medications/intake`) ─────── +// +// Two scopes behind one GET plus the status toggle every dose surface posts +// to. Never registered until now: `openapi:check` compares the registry +// against the YAML and never the route tree against the registry. + +export const todayIntakeEntry = z + .object({ + id: z.string().describe("Intake-event id."), + medicationId: z.string(), + scheduledAt: z.iso + .datetime({ offset: true }) + .describe( + "The slot the dose belongs to — the event's `scheduledFor`, renamed on this wire only.", + ), + takenAt: z.iso.datetime({ offset: true }).nullable(), + status: z + .enum(["skipped", "taken", "missed", "pending", "snoozed"]) + .describe( + "Resolved in that precedence order. `missed` is TERMINAL — the nightly cron closed a never-acted slot — and is not a `pending` that can still be answered. `snoozed` is not a property of this event at all: it reflects the MEDICATION's deferral stamp still lying in the future, so every pending dose of that medication reads snoozed at once.", + ), + snoozedUntil: z.iso + .datetime({ offset: true }) + .nullable() + .describe( + "The medication's deferral stamp, repeated on each of its events. Null when the medication is not deferred.", + ), + }) + .meta({ + id: "TodayIntakeEntry", + description: + "One of today's dose slots, flattened for the dose sheet and the dashboard tile. A deliberately narrow projection — not the full intake-event row.", + }); + +export const complianceDayBucket = z + .object({ + date: z.string().describe("`YYYY-MM-DD` in the account's timezone."), + scheduled: z + .number() + .int() + .describe( + "Doses the SCHEDULE expected that day, from the recurrence engine — not the count of logged rows. That distinction is the point of the field: counting rows made every window read ~100 % because each logged dose was both numerator and denominator.", + ), + taken: z.number().int().describe("Doses actually taken that day."), + }) + .meta({ + id: "MedicationComplianceDayBucket", + description: + "One day of schedule-anchored compliance. Paused days drop out of the denominator, and archived schedule eras are honoured, so `scheduled` reflects what was actually expected at the time.", + }); + +// ── GLP-1 detail (`/api/medications/{id}/glp1`) ─────────────────────── + +export const glp1DoseChange = z + .object({ + id: z.string(), + effectiveFrom: z.iso.datetime({ offset: true }), + doseValue: z.number(), + doseUnit: z.string(), + note: z + .string() + .nullable() + .describe( + "Titration note, decrypted on read. Null when none was recorded.", + ), + }) + .meta({ + id: "Glp1DoseChange", + description: + "One titration step, oldest first. The note is encrypted at rest and the plaintext column is left null on every write this endpoint makes.", + }); + +export const glp1Inventory = z + .object({ + pensRemaining: z + .number() + .int() + .nullable() + .describe("Usable containers (ACTIVE or IN_USE with units left)."), + dosesRemaining: z + .number() + .int() + .nullable() + .describe( + "Pooled units divided by the medication's `unitsPerDose`, floored — consumption spills across containers.", + ), + weeksOfSupply: z + .number() + .int() + .nullable() + .describe( + "Equal to `dosesRemaining`. It is the weekly-cadence approximation the canonical GLP-1 case makes, and it is NOT re-derived for a medication on any other cadence.", + ), + lowStock: z + .boolean() + .describe( + "The same reorder-lead-aware runway evaluation the daily low-stock notification runs, so the card and the notification cannot disagree. False whenever the low-stock alert is switched off, and false when no schedule consumes the medication (no consumption means no runway, which is not the same as plenty).", + ), + }) + .meta({ + id: "Glp1Inventory", + description: + "Running supply math. Computed over the per-container entities when any exist; otherwise, and only then, over the legacy running-sum ledger. Containers always win — the ledger never overrides them.", + }); + +export const glp1DetailResponse = z + .object({ + doseChanges: z + .array(glp1DoseChange) + .describe("Full titration history, `effectiveFrom` ascending."), + recentIntakes: z + .array( + z.object({ + takenAt: z.iso.datetime({ offset: true }).nullable(), + injectionSite: z + .string() + .nullable() + .describe("Recorded site, or null when none was captured."), + }), + ) + .describe( + "The last twelve taken doses, newest first, for the site-rotation view.", + ), + inventory: glp1Inventory + .nullable() + .describe( + "Null when the medication has neither containers nor a usable legacy ledger — an unknown supply, which is not zero supply.", + ), + }) + .meta({ + id: "Glp1DetailResponse", + description: + "The GLP-1 card's extras: titration history, recent injections with their sites, and the running supply math.", + }); + +export const glp1InventoryEvent = z + .object({ + id: z.string(), + medicationId: z.string(), + delta: z.number().int().describe("Positive added, negative consumed."), + reason: z.string(), + occurredAt: z.iso.datetime({ offset: true }), + }) + .meta({ + id: "Glp1InventoryEvent", + description: + "A row of the DEPRECATED running-sum inventory ledger. Register containers through the inventory endpoints instead; reads fall back to this ledger only while a medication has no containers at all.", + }); + +// ── Per-medication ingest endpoint (`/api/medications/{id}/api-endpoint`) ── +// +// The one place in this module where a raw credential can leave the server, +// and it leaves EXACTLY once: on the PUT that mints it. The GET answers +// presence and a count and nothing else, and a re-enable of an already-enabled +// endpoint answers `token: null` rather than re-issuing — the plaintext is +// hashed at rest and cannot be recovered, so a lost token means minting a new +// one by disabling and enabling again. + +export const medicationApiEndpointState = z + .object({ + enabled: z + .boolean() + .describe("True when at least one live token carries this scope."), + activeTokenCount: z + .number() + .int() + .describe( + "Live, unrevoked, unexpired tokens scoped to this medication. Normally 0 or 1.", + ), + }) + .meta({ + id: "MedicationApiEndpointState", + description: + "Presence only. No token, hash or prefix is on this wire — the stored value is an HMAC and there is no path back to the plaintext.", + }); + +export const medicationApiEndpointEnabled = z + .object({ + enabled: z.literal(true), + activeTokenCount: z.number().int(), + token: z + .string() + .nullable() + .describe( + "The raw `hlk_<64 hex>` token, and the ONLY time it is ever returned. Null when the endpoint was already enabled and nothing was minted — check `created` rather than inferring from this. Store it on receipt; it is hashed at rest and cannot be shown again.", + ), + created: z + .boolean() + .describe("True when this call minted a token, false when one existed."), + }) + .meta({ + id: "MedicationApiEndpointEnabled", + description: + "The answer to enabling the endpoint. A fresh mint is 201 with the token; an already-enabled endpoint is 200 with `token: null` and `created: false`.", + }); + +export const medicationApiEndpointDisabled = z + .object({ + enabled: z.literal(false), + revokedTokenCount: z + .number() + .int() + .describe("How many live tokens this call revoked."), + }) + .meta({ + id: "MedicationApiEndpointDisabled", + description: + "The answer to disabling. Note the asymmetry: this shape carries `revokedTokenCount` and NOT `activeTokenCount`, so a client decoding one shape for both PUT outcomes will find the key missing.", + }); + +// ── Reminder phase config (`/api/medications/{id}/phase-config`) ────── + +export const reminderPhaseConfig = z + .object({ + id: z + .string() + .optional() + .describe( + "Row id. ABSENT when the read fell through to defaults, because no row exists — which is how a client tells a stored configuration from the fallback.", + ), + medicationId: z.string().optional().describe("Absent on the defaults."), + greenValue: z.number().int(), + greenMode: z.enum(["MINUTES", "PERCENT"]), + yellowValue: z.number().int(), + yellowMode: z.enum(["MINUTES", "PERCENT"]), + orangeValue: z.number().int(), + orangeMode: z.enum(["MINUTES", "PERCENT"]), + redValue: z.number().int(), + redMode: z.enum(["MINUTES", "PERCENT"]), + }) + .meta({ + id: "ReminderPhaseConfig", + description: + "How long each reminder phase runs for one medication. `MINUTES` is an absolute offset; `PERCENT` is a share of the dose window. Values are 0..1440.", + }); + +// ── Side effects (`/api/medications/{id}/side-effects`) ─────────────── + +export const medicationSideEffect = z + .object({ + id: z.string(), + userId: z + .string() + .describe( + "The RECORD owner, which is not the actor on a delegated write.", + ), + medicationId: z.string(), + occurredAt: z.iso.datetime({ offset: true }), + category: z + .string() + .describe( + "Derived SERVER-side from `entry` through the authoritative taxonomy. Never accepted from a client — a category sent on the wire is dropped, so a row cannot be stamped with one that contradicts its entry.", + ), + entry: z.string().describe("The catalogue entry. Closed Prisma enum."), + severity: z.number().int().min(1).max(5), + notes: z + .string() + .nullable() + .describe( + "The free-text note, decrypted on read. Stored encrypted; the ciphertext column is stripped before the row leaves the server.", + ), + createdAt: z.iso.datetime({ offset: true }), + }) + .meta({ + id: "MedicationSideEffect", + description: + "One logged side effect. Not a clinical record — the account owns it, and it stays deletable at any time rather than locking after a retraction window.", + }); diff --git a/src/lib/openapi/routes/mood.ts b/src/lib/openapi/routes/mood.ts index a557f7c15..9d8b60c87 100644 --- a/src/lib/openapi/routes/mood.ts +++ b/src/lib/openapi/routes/mood.ts @@ -54,6 +54,7 @@ import { idempotencyKeyParameter, idempotentWrite, invalidBaseTokenResponse, + moduleDisabledResponse, recordRefusal, stdResponses, updatedAtTokenField, @@ -736,7 +737,530 @@ const moodPrognosisResponse = z "Two separate readings of one day: what the person recorded, and what a comparison with their own past days would have expected. They are never merged — the distance between them is the finding, and a single blended figure destroys it. The comparison is a regularised regression over the account's own history: observational only, never causal. Any copy built on it must state the value as a counterfactual (a value around X would have been expected), must carry `n`, and must not say that anything caused anything.", }); +// ── Mood insights aggregates (v1.8.5) ──────────────────────────────── +// +// `GET /api/mood/insights` is the pre-computed aggregate bundle behind the +// Mood Insights page. The browser never receives raw mood rows from it — the +// compute runs server-side and only pre-shaped blocks leave. Every block is +// observational: with-versus-without comparisons and correlations, never a +// cause and never a verdict. + +const moodInfluenceConfidence = z + .enum(["low", "medium", "high"]) + .meta({ id: "MoodInfluenceConfidence" }); + +const moodPairingMode = z + .enum(["sameDay", "nextDay"]) + .describe( + "Pairing lag the row was computed at. `nextDay` is the D → D+1 join used for overnight-recovery channels; caption it, because a next-day row says something different from a same-day one.", + ); + +const moodDailyCell = z.object({ + date: z.string().describe("`YYYY-MM-DD` day key."), + score: z.number().describe("Daily mean pleasantness for the day."), + samples: z.number().int().describe("Entries that fed the mean."), +}); + +const moodDimensionSummary = z + .object({ + key: z + .enum(["a1", "a2", "a3", "a4", "a5"]) + .describe("Level-A dimension key."), + present: z + .boolean() + .describe( + "False when nobody answered this dimension in the window. The series is then empty and the surface says so rather than drawing a line through the middle of the scale.", + ), + inverse: z + .boolean() + .describe( + "True when higher is worse for this dimension. The analytics layer flips through this flag rather than inline, so a client must read it before comparing dimensions.", + ), + min: z.number().describe("Scale floor for the dimension."), + max: z.number().describe("Scale ceiling for the dimension."), + count: z.number().int().describe("Entries carrying a value."), + avg7: z.number().nullable(), + avg30: z.number().nullable(), + avg90: z + .number() + .nullable() + .describe("Trailing-window means; null when that window is empty."), + latest: z.number().nullable(), + latestDate: z.string().nullable().describe("`YYYY-MM-DD` of `latest`."), + newestDaysAgo: z.number().int().nullable(), + series: z + .array( + z.object({ + date: z.string(), + value: z.number(), + samples: z.number().int(), + }), + ) + .describe("Daily means, oldest first, over the longest window."), + }) + .meta({ id: "MoodDimensionSummary" }); + +const moodTagInfluenceRow = z + .object({ + tag: z + .string() + .describe("Stable tag key. For a flat tag this is the free text itself."), + labelKey: z + .string() + .nullable() + .describe("i18n label key for a structured tag; null for a flat one."), + label: z + .string() + .nullable() + .optional() + .describe( + "Decrypted custom-tag label. Absent for catalogue and flat tags — the key is absent, not null.", + ), + categoryKey: z.string().nullable(), + icon: z.string().nullable(), + withDays: z.number().int(), + withoutDays: z.number().int(), + withAvg: z.number().describe("Mean of daily means on tagged days."), + withoutAvg: z + .number() + .describe("Mean of daily means on the other days (the counterfactual)."), + delta: z + .number() + .describe("`withAvg − withoutAvg`, rounded. Signed, never causal."), + pooledSd: z + .number() + .nullable() + .describe( + "Pooled mood SD across the two groups (the Cohen's-d denominator); null when neither group has testable spread. Ranking input only — not rendered.", + ), + pValue: z.number().describe("Welch two-sided p-value."), + confidence: moodInfluenceConfidence, + }) + .meta({ id: "MoodTagInfluenceRow" }); + +const moodAggregatesResponse = z + .object({ + summary: z + .object({ + points: z.number().int().describe("Daily points behind the figures."), + mean: z.number().nullable(), + min: z.number().nullable(), + max: z.number().nullable(), + latest: z.number().nullable(), + delta: z + .number() + .nullable() + .describe("Latest daily mean minus the previous daily mean."), + inTargetPct: z.number().nullable(), + totalEntries: z.number().int(), + totalSpanDays: z.number().int(), + newestEntryDaysAgo: z.number().int().nullable(), + }) + .describe( + "Headline figures. Every statistic is null on an empty window.", + ), + heatmap: z.object({ + windowDays: z + .union([z.literal(30), z.literal(90), z.literal(365)]) + .describe("Adaptive window the cells were cut to."), + cells: z.array(moodDailyCell), + }), + distribution: z + .array( + z.object({ + score: z.number().describe("Rounded mood level, 1..5."), + count: z.number().int(), + }), + ) + .describe("Entry counts per rounded level."), + dimensions: z + .array(moodDimensionSummary) + .describe( + "One summary per level-A dimension. A dimension nobody answered is still listed, with `present: false`.", + ), + weekday: z + .array( + z.object({ + weekday: z.number().int().describe("0 = Monday … 6 = Sunday."), + avgScore: z.number().nullable(), + count: z.number().int(), + }), + ) + .describe("Per-weekday means; an unlogged weekday carries a null mean."), + timeOfDay: z + .object({ + buckets: z.array( + z.object({ + bucket: z.enum(["morning", "afternoon", "evening", "night"]), + avgScore: z.number().nullable(), + count: z.number().int(), + }), + ), + reliable: z + .boolean() + .describe( + "False for the once-a-day logger (everything in one bucket) or a sparse history. Do not chart or narrate the pattern when this is false.", + ), + best: z + .enum(["morning", "afternoon", "evening", "night"]) + .nullable() + .describe("Null unless `reliable`."), + worst: z + .enum(["morning", "afternoon", "evening", "night"]) + .nullable() + .describe("Null unless `reliable`."), + }) + .describe( + "Average pleasantness per part of day, bucketed in each entry's OWN timezone. All four buckets are always listed, in canonical order.", + ), + stability: z + .object({ + score: z.number().describe("0..100; higher means steadier day-to-day."), + stdDev: z.number().describe("Population SD of the daily means."), + band: z.enum(["verySteady", "steady", "variable", "veryVariable"]), + days: z.number().int(), + }) + .nullable() + .describe("Null for a sparse logger below the day floor."), + tags: z + .array( + z.object({ + tag: z.string(), + count: z.number().int(), + avgScore: z.number(), + }), + ) + .describe("Flat free-text tag counts with their mean mood."), + contextComparison: z + .array( + z.object({ + field: z.string().describe("Context field, e.g. `workStatus`."), + value: z.string().describe("The value inside that field."), + withDays: z.number().int(), + withoutDays: z.number().int(), + withAvg: z.number(), + withoutAvg: z.number(), + delta: z + .number() + .describe("`withAvg − withoutAvg`. Signed, and never an effect."), + pValue: z.number(), + qValue: z.number().describe("Benjamini-Hochberg adjusted p-value."), + }), + ) + .describe( + "How the day's pleasantness sat on days carrying each context value against the days that did not. Every row carries the two counts it was computed from so a reader can weigh it.", + ), + structuredTags: z + .array( + z.object({ + key: z.string(), + categoryKey: z.string(), + labelKey: z.string(), + label: z + .string() + .nullable() + .optional() + .describe("Decrypted custom-tag label; absent for catalogue tags."), + icon: z.string().nullable(), + count: z.number().int(), + avgScore: z.number(), + }), + ) + .describe("Taxonomy-tag counts with their mean mood."), + tagInfluence: z + .object({ + flat: z.array(moodTagInfluenceRow), + structured: z.array(moodTagInfluenceRow), + }) + .describe( + "With-versus-without daily-mean delta plus a Welch confidence band per frequent tag, ranked by |delta| descending.", + ), + betterDays: z + .array( + z.object({ + source: z.enum(["tag", "metric"]), + key: z + .string() + .describe("Tag key, or the correlation channel key for a metric."), + labelKey: z.string().nullable(), + label: z.string().nullable().optional(), + categoryKey: z.string().nullable(), + icon: z.string().nullable(), + direction: z + .enum(["up", "down"]) + .describe("Associated with higher / lower mood."), + n: z.number().int(), + confidence: moodInfluenceConfidence, + effectSize: z + .number() + .describe( + "Unified 0..1 ranking strength (|r| for a metric, min(1, |delta| / 2) for a tag). Ranking only — surface `delta` or `r`, not this.", + ), + delta: z + .number() + .nullable() + .describe( + "Raw mood-point delta for a tag row; null for a metric row.", + ), + r: z + .number() + .nullable() + .describe("Raw Pearson r for a metric row; null for a tag row."), + }), + ) + .describe( + "The effect-size-ranked, confidence-gated board folding the tag deltas and the mood x metric correlations into one observational list.", + ), + tagMetricCrosstab: z + .array( + z.object({ + tag: z.string(), + labelKey: z + .string() + .describe( + "Structured tags only — this board never carries a flat tag.", + ), + label: z.string().nullable().optional(), + categoryKey: z.string(), + icon: z.string().nullable(), + metricKey: z + .string() + .describe( + "Metric channel. Currently `activeEnergy`, `sleepDuration`, `nextDayRecovery`, `nextDayRestingHeartRate` or `nextDayHeartRateVariability`; the registry is a plain map, so treat this as an open token set rather than a closed enum.", + ), + display: z + .string() + .describe( + "Display-unit hint for the client formatter (`hours` / `kcal` / `score` / `bpm` / `ms`). The values are ALREADY in that unit.", + ), + mode: moodPairingMode, + withDays: z.number().int(), + withoutDays: z.number().int(), + withAvg: z.number(), + withoutAvg: z.number(), + delta: z.number().describe("`withAvg − withoutAvg`, display unit."), + pValue: z.number(), + qValue: z.number(), + confidence: moodInfluenceConfidence, + patternId: z + .string() + .optional() + .describe( + "Persisted pattern id; absent when the row is not persisted.", + ), + canonicalKey: z.string().optional(), + dismissed: z + .boolean() + .optional() + .describe("Whether the user dismissed the persisted pattern."), + }), + ) + .describe( + "Per structured tag x health metric: the metric's mean on tag-present versus tag-absent days, Welch-tested and BH-corrected. Association, never cause.", + ), + factorCrosstab: z + .array( + z.object({ + factor: z.string().describe("Stable RATED-factor key."), + labelKey: z.string(), + categoryKey: z.string(), + icon: z.string().nullable(), + inverse: z + .boolean() + .describe( + "True for an inverse-scaled factor (stress, conflict). The split already runs on the flipped series, so a `low` row always means a worse day; the flag only changes the phrasing.", + ), + metricKey: z + .string() + .describe( + "Metric channel. Currently `sleepDuration`, `steps`, `restingHeartRate`, `heartRateVariability`, `weight` or `bloodPressureSystolic`; an open token set, as above.", + ), + display: z + .string() + .describe( + "Display-unit hint (`hours` / `score` / `steps` / `bpm` / `ms` / `kg` / `mmHg`).", + ), + mode: moodPairingMode, + lowDays: z.number().int(), + highDays: z.number().int(), + lowAvg: z.number(), + highAvg: z.number(), + delta: z + .number() + .describe( + "`lowAvg − highAvg`, display unit. Negative means the vital runs lower on low-factor days.", + ), + pValue: z.number(), + qValue: z.number(), + confidence: moodInfluenceConfidence, + patternId: z.string().optional(), + canonicalKey: z.string().optional(), + dismissed: z.boolean().optional(), + }), + ) + .describe( + "The cross-domain bridge: per rated factor x health metric, the vital's mean on the days the factor was rated LOW versus HIGH (median split), Welch-tested and BH-corrected. Association, never cause.", + ), + narratives: z + .array( + z.object({ + kind: z.enum([ + "weekday-dip", + "weekday-peak", + "trend", + "weekend", + "tag-lift", + "tag-drop", + "in-target", + "streak", + "time-of-day", + ]), + messageKey: z + .string() + .describe("i18n template key the client resolves."), + vars: z + .record(z.string(), z.string()) + .describe( + "Interpolation values, all stringified so they survive transport unchanged.", + ), + strength: z + .number() + .describe("Normalised ranking weight; higher is stronger."), + }), + ) + .describe( + "Ranked, threshold-gated takeaways — the read-this-first layer above the charts. The same array feeds the LLM snapshot so prose and feed cannot drift.", + ), + correlations: z + .record( + z.string(), + z.object({ + result: z + .object({ + r: z.number(), + strength: z + .enum(["stark", "moderat", "schwach", "keine"]) + .describe( + "Strength bucket. The tokens are GERMAN on the wire — a legacy of where the helper was written; treat them as opaque identifiers and localise client-side rather than displaying them.", + ), + n: z.number().int(), + }) + .nullable() + .describe("Null when the pairing produced no testable series."), + points: z + .array(z.object({ x: z.number(), y: z.number() })) + .describe("Mood (x) versus metric (y), paired by day."), + n: z.number().int().describe("Paired-day count."), + }), + ) + .describe( + "Mood against five health channels, keyed `sleep`, `steps`, `pulse`, `weight`, `bloodPressureSystolic`. Every key is always present; an empty channel carries `result: null` with no points.", + ), + }) + .meta({ + id: "MoodAggregates", + description: + "The pre-computed Mood Insights bundle: headline summary, heatmap, distribution, per-dimension summaries, weekday and time-of-day patterns, stability, tag boards, context and crosstab comparisons, narratives and cross-metric correlations. Everything is observational — comparisons and correlations, never a cause and never a verdict. Served through a 60 s stale-while-revalidate cache; a mood write marks the cell so the account's own entry lands on the next read.", + }); + +const moodDailySeriesResponse = z + .object({ + entries: z + .array( + z.object({ + date: z.string().describe("`YYYY-MM-DD` day key."), + score: z + .number() + .describe("The day's mean pleasantness on the 1..5 scale."), + samples: z.number().int().describe("Entries behind the mean."), + }), + ) + .describe("One entry per day that carries a mood, oldest first."), + summary: z + .object({ + count: z.number().int(), + latest: z.number().nullable(), + min: z.number().nullable(), + max: z.number().nullable(), + mean: z.number().nullable(), + median: z.number().nullable(), + avg7: z.number().nullable(), + avg30: z.number().nullable(), + slope7: z + .object({ + slope: z.number(), + direction: z.enum(["up", "down", "stable"]), + confidence: z.number(), + }) + .nullable(), + slope30: z + .object({ + slope: z.number(), + direction: z.enum(["up", "down", "stable"]), + confidence: z.number(), + }) + .nullable(), + anomalyCount: z.number().int().optional(), + avg30LastMonth: z.number().nullable().optional(), + avg30LastYear: z.number().nullable().optional(), + }) + .describe( + "The shared summary shape computed over the DAILY MEANS, not over the raw entries — so `count` is the number of days with a mood, not the number of moods logged. Every statistic is null on an empty series rather than 0.", + ), + }) + .meta({ + id: "MoodDailySeries", + description: + "The canonical daily mood series. `entryCount` and the rollup-versus-live `source` the builder also resolves are annotated for observability and deliberately NOT on this wire — the two keys below are the whole payload.", + }); + export const moodPaths: NonNullable = { + "/api/mood/analytics": { + get: { + tags: ["Mood"], + summary: "The canonical daily mood series", + description: + "One series, read by the mood page sparkline, the dashboard tile and the native client alike, so all three render the same number — the dashboard snapshot resolves it through the same builder rather than aggregating again. Answered from the mood rollup tier with a live fallback on a coverage miss; which tier answered is annotated for operators and is not on the wire. Plain read-through cache (NOT stale-while-revalidate, unlike the insights sibling). Module-gated on `mood`; a disabled module answers 403 `module.disabled` even for a valid Bearer token. Cookie or Bearer auth; the caller is always resolved as themselves, so this read cannot be delegated to a shared record.", + responses: { + "200": { + description: "The daily series and its summary.", + content: { + "application/json": { + schema: dataEnvelope( + moodDailySeriesResponse, + "MoodDailySeriesEnvelope", + ), + }, + }, + }, + ...moduleDisabledResponse, + ...stdResponses, + }, + }, + }, + "/api/mood/insights": { + get: { + tags: ["Mood"], + summary: "Pre-computed mood aggregates (v1.8.5)", + description: + "The whole Mood Insights page in one read. The compute runs server-side and only pre-shaped blocks leave — no raw mood row is on this wire, and no LLM is reachable from this path. Stale-while-revalidate: past the fresh window the prior aggregate is served immediately while one background recompute warms a new one, so an active logger never re-pays the cold compute. Module-gated on `mood`; a disabled module answers 403 `module.disabled` even for a valid Bearer token. Cookie or Bearer auth; the caller is always resolved as themselves, so this read cannot be delegated to a shared record — which makes it the one mood surface a delegate cannot reach.", + responses: { + "200": { + description: "The aggregate bundle.", + content: { + "application/json": { + schema: dataEnvelope( + moodAggregatesResponse, + "MoodAggregatesEnvelope", + ), + }, + }, + }, + ...moduleDisabledResponse, + ...stdResponses, + }, + }, + }, "/api/mood/prognosis": { get: { tags: ["Mood"], diff --git a/src/lib/validations/medication/intake.ts b/src/lib/validations/medication/intake.ts index 5a4e1fc3f..5f6eae4d2 100644 --- a/src/lib/validations/medication/intake.ts +++ b/src/lib/validations/medication/intake.ts @@ -200,3 +200,77 @@ export type BulkDeleteIntakeEventsInput = z.infer< export type IntakeInput = z.infer; export type ListIntakeEventsInput = z.infer; export type UpdateIntakeEventInput = z.infer; + +/** + * Query for the top-level intake aggregator (`GET /api/medications/intake`). + * + * Lived inline in the route until the endpoint was published: the contract is + * generated from these schemas, so a shape that only exists inside a handler + * is a shape the spec has to restate — and a restatement drifts. `days` is + * read only by the `compliance` scope; the `today` scope resolves its window + * from the account's display timezone and ignores it. + */ +export const intakeAggregateQuerySchema = z.object({ + scope: z + .enum(["today", "compliance"]) + .describe( + "`today` lists the account's intake events for the current local day across every medication; `compliance` returns per-day scheduled-versus-taken buckets.", + ), + days: z.coerce + .number() + .int() + .min(1) + .max(365) + .optional() + .default(30) + .describe( + "Trailing window for the `compliance` scope, 1..365, default 30. Ignored by the `today` scope.", + ), +}); + +/** + * Body for the top-level intake status toggle (`POST /api/medications/intake`). + * + * The event is named by id rather than by medication, and the medication is + * resolved from it — so there is no `medicationId` field and no `userId` one + * either; ownership is narrowed from the resolved record. + */ +export const intakeStatusUpdateSchema = z.object({ + intakeId: z.string().min(1).describe("The intake event to act on."), + status: z + .enum(["taken", "skipped", "snoozed"]) + .describe( + "`snoozed` writes the deferral onto the MEDICATION row rather than the event, so it defers every pending dose of that medication and not just this one.", + ), + takenAt: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .optional() + .describe( + "When the dose was taken. Defaults to now. Ignored unless the status is `taken`.", + ), + snoozedUntil: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .optional() + .describe( + "How long to defer. Defaults to 30 minutes from now. Deliberately UNBOUNDED — nothing here caps how far out it may sit. Ignored unless the status is `snoozed`.", + ), + injectionSite: injectionSiteEnum + .optional() + .describe( + "Site recorded alongside a `taken` toggle, validated server-side against the medication's effective allowed set. Dropped for a non-injection medication, for one with site tracking off, and for a skip or snooze.", + ), + forceSlotInstant: z.iso + .datetime({ offset: true }) + .transform((s) => new Date(s)) + .optional() + .describe( + "Pins a late take onto a named real slot of the medication instead of letting dose-window membership decide. Must BE one of that medication's slots — anything else is refused rather than approximated. Ignored for a skip or snooze.", + ), +}); + +export type IntakeAggregateQueryInput = z.infer< + typeof intakeAggregateQuerySchema +>; +export type IntakeStatusUpdateInput = z.infer; diff --git a/tests/integration/sharing-delegable-routes.test.ts b/tests/integration/sharing-delegable-routes.test.ts index 1c4e95ee7..fab994537 100644 --- a/tests/integration/sharing-delegable-routes.test.ts +++ b/tests/integration/sharing-delegable-routes.test.ts @@ -1560,6 +1560,112 @@ describe("GET /api/medications/intake", () => { }); }); +/** + * The GLP-1 read, against the section that opens it. + * + * Its own block because the asymmetry it closes was invisible to every other + * leg in this file: the POST on this path has resolved the record since + * v1.37.0 and the GET beside it resolved the CALLER, so a manager could add a + * titration step to a medication whose history the same request path refused + * them. May write, may not read — a shape no reading of the sharing model + * produces, and one nothing here would have caught, because a route that + * refuses every switched caller looks exactly like a route nobody classified. + * + * Both legs are evidence rather than verdict. The positive one asserts the + * OWNER's dose value comes back and the delegate's own does not, so a handler + * that returned everybody's rows fails it; the negative one asserts the + * refusal code and not merely a 403, so the pre-fix `sharing.not_permitted` + * (which is also a 403) fails it too. + */ +describe("GET /api/medications/[id]/glp1 — the read follows the section", () => { + /** A live accepted grant scoped to the named sections, minted the shipped way. */ + async function switchIntoScoped( + ownerId: string, + delegateId: string, + scope: ("medications" | "labs")[], + ) { + const { inviteGrant, acceptGrant } = await import("@/lib/sharing/grants"); + const invited = await inviteGrant({ + grantorId: ownerId, + granteeId: delegateId, + access: "READ", + scope, + }); + await acceptGrant({ grantId: invited.id, granteeId: delegateId }); + const session = await signIn(delegateId); + await switchSessionTo(session.id, ownerId); + } + + async function seedTitration(userId: string, doseValue: number) { + const medication = await getPrismaClient().medication.create({ + data: { userId, name: `glp1-${doseValue}`, dose: `${doseValue}mg` }, + }); + await getPrismaClient().medicationDoseChange.create({ + data: { + medicationId: medication.id, + effectiveFrom: new Date("2026-07-14T00:00:00Z"), + doseValue, + doseUnit: "mg", + }, + }); + return medication; + } + + async function readGlp1(medicationId: string) { + const { GET } = await import("@/app/api/medications/[id]/glp1/route"); + return call(GET as Handler, `/api/medications/${medicationId}/glp1`, { + id: medicationId, + }); + } + + it("serves the owner's titration history to a delegate scoped to medications", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const ownerMed = await seedTitration(owner.id, 1.5); + const delegateMed = await seedTitration(delegate.id, 7.5); + + await switchIntoScoped(owner.id, delegate.id, ["medications"]); + + const response = await readGlp1(ownerMed.id); + expect(response.status).toBe(200); + const payload = (await body(response)).data as { + doseChanges: { doseValue: number }[]; + }; + expect(payload.doseChanges.map((c) => c.doseValue)).toEqual([1.5]); + + // The delegate's own medication is seeded and must stay out of reach: + // the ownership guard resolves against the RECORD, not the caller, so + // their own row is the one that 404s while they are switched in. + expect((await readGlp1(delegateMed.id)).status).toBe(404); + }); + + it("refuses the same delegate when the grant does not name medications", async () => { + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + const ownerMed = await seedTitration(owner.id, 1.5); + + await switchIntoScoped(owner.id, delegate.id, ["labs"]); + + // The code, not the status: before the read resolved the record it also + // answered 403 here, with `sharing.not_permitted`, and a status-only + // assertion would have passed against the defect. + await expectDenied(await readGlp1(ownerMed.id)); + }); + + it("is unchanged for a caller who has not switched", async () => { + const plain = await makeUser("plain"); + const med = await seedTitration(plain.id, 2.5); + await signIn(plain.id); + + const response = await readGlp1(med.id); + expect(response.status).toBe(200); + const payload = (await body(response)).data as { + doseChanges: { doseValue: number }[]; + }; + expect(payload.doseChanges.map((c) => c.doseValue)).toEqual([2.5]); + }); +}); + describe("the per-medication reads", () => { it("resolve the ownership guard against the record", async () => { const owner = await makeUser("owner"); @@ -1584,6 +1690,7 @@ describe("the per-medication reads", () => { const phaseConfig = await import("@/app/api/medications/[id]/phase-config/route"); const intake = await import("@/app/api/medications/[id]/intake/route"); + const glp1 = await import("@/app/api/medications/[id]/glp1/route"); const { grant } = await switchInto(owner.id, delegate.id); @@ -1603,6 +1710,7 @@ describe("the per-medication reads", () => { inventory.GET, phaseConfig.GET, intake.GET, + glp1.GET, ]) { const response = await call( route as Handler, @@ -1622,6 +1730,7 @@ describe("the per-medication reads", () => { inventory.GET, phaseConfig.GET, intake.GET, + glp1.GET, ]) { const response = await call( route as Handler, @@ -1632,7 +1741,7 @@ describe("the per-medication reads", () => { } await revokeGrant(grant.id); - for (const route of [detail.GET, cadence.GET, intake.GET]) { + for (const route of [detail.GET, cadence.GET, intake.GET, glp1.GET]) { await expectDenied( await call(route as Handler, `/api/medications/${ownerMed.id}/sub`, { id: ownerMed.id,