Register eleven served routes in the OpenAPI contract

Eleven routes the native client calls have never been in the registry, so
`docs/api/openapi.yaml` does not describe them. Nothing caught it:
`openapi:check` compares the registry against the YAML and never compares
the route tree against the registry, so a route registered nowhere drifts
with every gate green.

  GET       /api/analytics
  GET,POST  /api/cycle/symptoms/custom
  GET       /api/dashboard/summary
  GET       /api/environment
  GET       /api/export
  GET,PUT   /api/insights/settings
  GET       /api/insights/targets
  GET,POST  /api/medications/intake
  GET,POST  /api/medications/{id}/glp1
  GET       /api/mood/insights
  GET       /api/personal-records

Each contract is derived from the handler rather than from its comments:
the methods it exports, the parameters it reads with their real defaults
and bounds, the statuses it can actually produce, whether it resolves the
record fence, and which module gate it sits behind. Where the answer is
awkward it is written down rather than smoothed over — `/api/export`
answers a bare `data` key and not the standard envelope; `/api/analytics`
serves two unrelated shapes behind one query parameter and records the
health score it computes; `GET /api/medications/intake?scope=today`
projects rows before it reads them; the GLP-1 GET is not delegable while
the POST on the same path is; and `highlightInsight` on the dashboard
summary is pinned null by the handler and has never carried anything.

The intake aggregator's query and body schemas move from inside the
handler to `@/lib/validations/medication`, so the contract generates from
the same object the route parses with instead of a second copy of it. The
environmental overview gets its own route module because nothing owns that
surface. `docs/api/openapi.yaml` is regenerated separately.
This commit is contained in:
Marc-André Bombeck committed 2026-08-22 12:30:05 +02:00
1 parent a6c239d952
commit d66c4f6cdc
13 files changed
+2225 -41

No files matched your search

+8 -33
View File
@@ -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`.
+104
View File
@@ -24,6 +24,7 @@ import {
cyclePeriodSchema,
cyclePrefsSchema,
} from "@/lib/validations/cycle";
import { createCustomSymptomSchema } from "@/lib/cycle/custom-symptoms-shared";
import {
dataEnvelope,
errorEnvelope,
@@ -434,7 +435,110 @@ 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.
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 cycleCustomSymptom = z
.object({
key: z
.string()
.describe(
"The minted `custom:<uuid>` 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<ZodOpenApiObject["paths"]> = {
"/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:<uuid>` 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: createCustomSymptomSchema },
},
},
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:<actorId>`).",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
"/api/cycle/day-logs": {
get: {
tags: ["Cycle"],
+201 -1
View File
@@ -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,205 @@ 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.",
});
export const dashboardWidgetPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/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"],
+146
View File
@@ -0,0 +1,146 @@
/**
* OpenAPI route table — the environmental-context overview (`GET /api/environment`).
*
* The route has served the module's settings surface and the native client
* since the environment module shipped, and it was absent from the registry
* entirely: `pnpm openapi:check` compares the registry against the YAML and
* never compares the ROUTE TREE against the registry, so a route that was
* never registered drifted without a single check going red.
*
* Only the overview lives here. The sibling writes under `/api/environment/*`
* (home, travel, geocode, backfill) are a separate surface and stay
* unpublished until they are reviewed on their own terms.
*
* Part of the OpenAPI route table; aggregated in `./index.ts`.
*/
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { dataEnvelope, moduleDisabledResponse, stdResponses } from "./shared";
const environmentHome = z
.object({
lat: z.number().describe("Coarse home latitude."),
lon: z.number().describe("Coarse home longitude."),
label: z
.string()
.nullable()
.describe("Operator- or user-chosen place name; null when never set."),
timezone: z
.string()
.describe(
"IANA zone the home's day keys are anchored to — the stored `homeTimezone` when one exists, else the account's display timezone. Never null: the resolution falls back rather than omitting.",
),
since: z.iso
.datetime({ offset: true })
.nullable()
.describe(
"Effective-from instant of the current home. The settings surface labels the home with it and prefills the backfill start from it. Null when the home was set before the column existed.",
),
})
.meta({
id: "EnvironmentHome",
description:
"The account's coarse home location. Present only when BOTH `homeLat` and `homeLon` are stored — a half-set home reads as no home at all.",
});
const environmentTravelLocation = z
.object({
id: z.string().describe("Travel-override row id (cuid)."),
startDate: z
.string()
.describe(
"Inclusive first day of the override, `YYYY-MM-DD`. A date STRING, not a timestamp: the row is anchored to the home timezone's day key, so there is no instant to report.",
),
endDate: z
.string()
.describe("Inclusive last day of the override, `YYYY-MM-DD`."),
lat: z.number(),
lon: z.number(),
label: z.string().describe("Place name for the override; never null."),
})
.meta({
id: "EnvironmentTravelLocation",
description:
"One travel override: the days on which the environmental fetch reads a different place than the home. Newest range first.",
});
const environmentOverviewResponse = z
.object({
home: environmentHome.nullable(),
travel: z
.array(environmentTravelLocation)
.describe("Every stored travel override, `startDate` descending."),
context: z
.object({
days: z
.number()
.int()
.describe("Stored daily observations for this account."),
latestDate: z
.string()
.nullable()
.describe(
"Day key of the newest stored observation, `YYYY-MM-DD`; null when none exists.",
),
latestFetchedAt: z.iso
.datetime({ offset: true })
.nullable()
.describe(
"When that newest observation was fetched; null when none exists.",
),
})
.describe("Coverage summary over the stored daily observations."),
lastFetchFailure: z
.object({
lastFailedAt: z.iso
.datetime({ offset: true })
.describe("When the newest exhausted-retry failure gave up."),
failures: z
.number()
.int()
.describe(
"Failures for this account on the queue inside the window.",
),
})
.nullable()
.describe(
"Non-null when the account's background environmental fetch has exhausted its retries recently. A day count on its own cannot tell an empty module from a broken one, which is how a job failing on every run since the module shipped could render as `0 days recorded` and nothing else. Deliberately carries NO error text — that message is written for an operator and can name internals. Null also when there is no queue to ask.",
),
attribution: z
.string()
.describe(
"Upstream attribution string the client must render beside the data.",
),
})
.meta({
id: "EnvironmentOverviewResponse",
description:
"The environmental-context module overview: the account's coarse home, its travel overrides, how much daily observation data is stored, whether the background fetch is failing, and the upstream attribution.",
});
export const environmentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/environment": {
get: {
tags: ["Environment"],
summary: "Environmental-context module overview",
description:
"Returns the account's coarse home location, its travel overrides, a summary of the stored daily observations (count + newest day), whether the background fetch has been failing, and the upstream attribution string. Read-only; no outbound fetch runs on this path. Module-gated on `environment` — a disabled module answers 403 `module.disabled` even for a valid Bearer token, and the client hides the whole surface rather than retrying. `userId` is narrowed from the session or the Bearer token, never a body or query field. 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 module overview.",
content: {
"application/json": {
schema: dataEnvelope(
environmentOverviewResponse,
"EnvironmentOverviewEnvelope",
),
},
},
},
...moduleDisabledResponse,
...stdResponses,
},
},
},
};
+95 -6
View File
@@ -1,16 +1,75 @@
/**
* OpenAPI route table — generic data export (passphrase-encrypted variant).
* OpenAPI route table — generic data export (the legacy plaintext download +
* the passphrase-encrypted variant).
*
* 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.
* `GET /api/export/full-backup` and the per-type CSV routes still predate the
* registry and stay out of it. `GET /api/export` no longer does: it is the
* download the native client and the web settings surface both call, and it
* was absent from the contract because `pnpm openapi:check` compares the
* registry against the YAML and never compares the ROUTE TREE against the
* registry.
*
* 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 { 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 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({
@@ -22,6 +81,36 @@ const encryptedExportRequest = z
.meta({ id: "EncryptedExportRequest" });
export const exportPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/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-<YYYY-MM-DD>.<ext>`. 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:<userId>` 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:<userId>`, shared with POST /api/export/encrypted).",
content: { "application/json": { schema: errorEnvelope } },
},
"401": stdResponses["401"],
},
},
},
"/api/export/encrypted": {
post: {
tags: ["Export"],
+4
View File
@@ -49,6 +49,7 @@ import { exportPaths } from "./export";
import { familyHistoryPaths } from "./family-history";
import { healthRecordPaths } from "./health-record";
import { encounterPaths } from "./encounters";
import { environmentPaths } from "./environment";
import { vaccinationPaths } from "./vaccinations";
import { illnessPaths } from "./illness";
import { importPaths } from "./import";
@@ -128,6 +129,9 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
// v1.36.0 — account sharing: the grant lifecycle and the switch endpoint
// (appended, spread order is load-bearing).
...accountSharingPaths,
// The environmental-context overview. Its own module because nothing else
// owns the surface (appended, spread order is load-bearing).
...environmentPaths,
};
export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
+100
View File
@@ -44,6 +44,11 @@ import {
ecgDetailQuery,
ecgDetailResponse,
rhythmEventsResponse,
analyticsQuery,
analyticsResponse,
insightsSettingsResponse,
insightsSettingsPutRequest,
targetsResponse,
} from "./schemas";
export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
@@ -145,6 +150,101 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/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/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"],
+646
View File
@@ -1598,3 +1598,649 @@ 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<T>` 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<T>` 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.",
});
+104 -1
View File
@@ -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<ZodOpenApiObject["paths"]> = {
},
},
},
"/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,
},
},
},
};
+144
View File
@@ -13,6 +13,9 @@ import {
createInventoryItemSchema,
updateInventoryItemSchema,
injectionSiteEnum,
intakeAggregateQuerySchema,
intakeStatusUpdateSchema,
glp1PostBodySchema,
} from "@/lib/validations/medication";
import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt";
import {
@@ -63,6 +66,11 @@ import {
doseHistoryResponse,
medicationIntakeImportJobStatusResponse,
medicationDoseHistoryImportResponse,
todayIntakeEntry,
complianceDayBucket,
glp1DetailResponse,
glp1DoseChange,
glp1InventoryEvent,
} from "./schemas";
// ── Bulk intake backfill (iOS SyncMode) — mirrors the route's
@@ -171,6 +179,142 @@ const medicationIntakeImportJobStatusEnvelope = dataEnvelope(
);
export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/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. NOT delegable — unlike the POST on this same path, this read resolves the caller as themselves, so a manager acting inside a shared record is answered 404 here while their write on the same medication succeeds. Cookie or Bearer auth.",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"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/intake/bulk": {
post: {
parameters: [idempotencyKeyParameter],
@@ -1289,3 +1289,152 @@ 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.",
});
+450
View File
@@ -54,6 +54,7 @@ import {
idempotencyKeyParameter,
idempotentWrite,
invalidBaseTokenResponse,
moduleDisabledResponse,
recordRefusal,
stdResponses,
updatedAtTokenField,
@@ -736,7 +737,456 @@ 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.",
});
export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/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"],
+74
View File
@@ -200,3 +200,77 @@ export type BulkDeleteIntakeEventsInput = z.infer<
export type IntakeInput = z.infer<typeof intakeSchema>;
export type ListIntakeEventsInput = z.infer<typeof listIntakeEventsSchema>;
export type UpdateIntakeEventInput = z.infer<typeof updateIntakeEventSchema>;
/**
* 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<typeof intakeStatusUpdateSchema>;