mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
Register the rest of the export, insights and medication surface
Twenty-five path entries across nine domains, all of them served and none of them described. Same cause as the first batch: `openapi:check` compares the registry against the YAML and never the route tree against the registry, so a route registered nowhere drifts with every gate green. Four groups where the contract had to say more than the shape does. The full backup is the download that carries a whole record in plain text, so what it contains is a safety question. It has two modes and this route reaches only one: called with no options, it always emits the PORTABLE form — decrypted columns, no tombstones, no row ids — while the disaster-recovery form belongs to the worker and the admin path. Three things it leaves out say so in the payload's own manifest: document bytes, workout GPS and per-sample series, and the sensitive pair, where the screener answers include the PHQ-9 self-harm item and a consent receipt restored elsewhere would assert an agreement that operator never obtained. The undeclared gap is named too, since roughly thirty models the plan marks as backed up still have no reader or restore branch. The four intake mutations differ in ways a caller has to know before firing one. The by-id delete, the bulk delete and the purge all tombstone, so the sync feed carries each removal; only the purge's compliance rollups are dropped outright, and those recompute. The first two refund the inventory stamp because not taking a dose puts the units back; the purge deliberately does not, because refunding a whole history would inflate the current container count instead. The bulk delete drops unmatched ids silently, so a partial match succeeds with a lower count than the ids sent. The two provider-configuration surfaces are documented by what they do NOT return. The chain answers provider types and boolean state, never a credential. The per-medication ingest endpoint returns a raw token exactly once, on the PUT that mints it, and a re-enable answers a null token with `created: false` rather than re-issuing — the stored value is an HMAC and there is no path back. Insight generation costs money, so both gates are published with the refusals they produce: the hourly per-user limit, and the day's token ceiling, which is refused at reservation time before any provider is contacted and carries its own code so a client can tell "too often" from "the day's spend is gone". A 200 there does not mean a provider ran. Two things the code said that were no longer true. The seeded-question route's docblock declared it deliberately unregistered; it is registered now, and a comment contradicting the registry is worse than none. And the idempotency guard went red on `/api/insights/feedback`, whose exemption said the path could not be published — which is exactly what that leg exists to catch, so the entry is gone rather than the assertion. `docs/api/openapi.yaml` is regenerated separately.
This commit is contained in:
1 parent
e39e2b60c2
commit
edccc17a4d
11 files changed
+1947
-20
No files matched your search
@@ -107,20 +107,24 @@ function idempotentOperationsFromContract(): Set<string> {
|
||||
* Idempotent operations that cannot be published, because the PATH is not in
|
||||
* the document at all. A header cannot hang on a path that does not exist.
|
||||
*
|
||||
* Two entries, and the list was four until the test below refused it. The first
|
||||
* One entry, and the list was four until the test below refused it. The first
|
||||
* draft claimed the whole `/api/medications` family was missing, on the
|
||||
* strength of a grep for path literals in `src/lib/openapi/routes/` that came
|
||||
* back empty. It came back empty because those paths are not written as
|
||||
* searchable literals there — the emitted document carries 19 of them,
|
||||
* including every one the iOS client actually argues about. Measure the
|
||||
* artefact, not the source that builds it.
|
||||
* searchable literals there — the emitted document carries them, including
|
||||
* every one the iOS client actually argues about. Measure the artefact, not
|
||||
* the source that builds it.
|
||||
*
|
||||
* `/api/insights/feedback` left this list when the route joined the route
|
||||
* table, and the leg below is what made that a diff rather than a permanent
|
||||
* exemption: the moment the path appeared in the document, the entry claiming
|
||||
* it could not be published became false and the run went red. An exemption
|
||||
* nobody removes is how a gap becomes furniture.
|
||||
*
|
||||
* Listed rather than filtered silently, so the exclusion stays visible in the
|
||||
* file that would otherwise claim full coverage.
|
||||
*/
|
||||
const UNPUBLISHED_PATHS: Readonly<Record<string, string>> = {
|
||||
"/api/insights/feedback":
|
||||
"Coach feedback write; never added to the route table. A real gap, small.",
|
||||
"/api/admin/backups/{id}/restore":
|
||||
"Admin surface: cookie-only by construction, so arguably out of scope for a client contract.",
|
||||
};
|
||||
|
||||
@@ -9,9 +9,11 @@
|
||||
* keeps its neutral greeting — never a fabricated opener.
|
||||
*
|
||||
* The signal is selected SERVER-SIDE; the client renders the resolved DTO
|
||||
* and never recomputes it (server-authoritative parity). This is an
|
||||
* INTERNAL same-origin route — it is deliberately NOT registered in the
|
||||
* OpenAPI contract the iOS client consumes.
|
||||
* and never recomputes it (server-authoritative parity). It IS registered in
|
||||
* the OpenAPI contract. This comment said the opposite until the route was
|
||||
* published — it shipped as a same-origin web surface and the sentence was
|
||||
* never revisited. A comment that contradicts the registry is worse than no
|
||||
* comment, because the next reader believes it instead of checking.
|
||||
*
|
||||
* Mirrors the sibling derived routes: `apiHandler` wrapper, `requireAuth`
|
||||
* (userId narrowed from the session/Bearer, never a body/query field),
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ACCEPTED_INSIGHTS_TILE_IDS,
|
||||
INSIGHTS_SECTION_IDS,
|
||||
} from "@/lib/insights-layout";
|
||||
import { COACH_CONVERSATION_TITLE_MAX } from "@/lib/ai/coach/types";
|
||||
import { COACH_FACT_CATEGORIES } from "@/lib/ai/coach/facts";
|
||||
import { COACH_PLAN_STATUSES } from "@/lib/ai/coach/plans";
|
||||
import {
|
||||
@@ -678,6 +679,22 @@ const coachConversationDetailSchema = z
|
||||
"Full conversation with every message decrypted server-side. The client renders the bodies directly; no decryption key is involved.",
|
||||
});
|
||||
|
||||
const renameCoachConversationRequest = z
|
||||
.object({
|
||||
title: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(COACH_CONVERSATION_TITLE_MAX)
|
||||
.describe(
|
||||
"The new title. Trimmed before validation, so whitespace alone is refused as empty. 1..80 characters.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "RenameCoachConversationRequest",
|
||||
description:
|
||||
"Rename body. Strict — an unrecognised key is refused rather than dropped, which is not how most partial updates on this surface behave.",
|
||||
});
|
||||
|
||||
const coachConversationDeletedResponse = z.object({
|
||||
deleted: z
|
||||
.literal(true)
|
||||
@@ -838,6 +855,52 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
patch: {
|
||||
tags: ["Insights"],
|
||||
summary: "Rename one Coach conversation",
|
||||
description:
|
||||
"Sets the conversation's title. The only field this verb writes — the body is `.strict()`, so an unrecognised key is REFUSED rather than ignored, unlike most partial updates on this surface. The title is trimmed and must be 1..80 characters after trimming. A foreign or unknown id maps to 404 (never 403), so the existence channel does not leak across accounts. Coach-gated. Auth via cookie or Bearer; the caller is always resolved as themselves, so a conversation in a shared record is not reachable here.",
|
||||
parameters: [
|
||||
{
|
||||
name: "id",
|
||||
in: "path",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description: "Conversation id.",
|
||||
},
|
||||
],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: renameCoachConversationRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"The new id and title. Deliberately NOT the full conversation — nothing else changed, and re-sending the messages on a rename would be a large body for no new information.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ id: z.string(), title: z.string() }),
|
||||
"CoachConversationRenamed",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Conversation not found or not owned by the caller.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"422": {
|
||||
description:
|
||||
"The body failed validation — an empty or over-long title, or an unrecognised key. Multi-issue envelope with `meta.errorCode` = `coach.conversation.invalidTitle`.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"401": stdResponses["401"],
|
||||
"429": stdResponses["429"],
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Insights"],
|
||||
summary: "Delete one Coach conversation",
|
||||
|
||||
@@ -24,7 +24,10 @@ import {
|
||||
cyclePeriodSchema,
|
||||
cyclePrefsSchema,
|
||||
} from "@/lib/validations/cycle";
|
||||
import { createCustomSymptomSchema } from "@/lib/cycle/custom-symptoms-shared";
|
||||
import {
|
||||
createCustomSymptomSchema,
|
||||
updateCustomSymptomSchema,
|
||||
} from "@/lib/cycle/custom-symptoms-shared";
|
||||
import {
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
@@ -441,12 +444,22 @@ const cycleDisabledOnADelegableRoute = recordRefusal(
|
||||
// rest and never reaches a wide event or an audit excerpt — only the icon and
|
||||
// the minted key do.
|
||||
|
||||
createCustomSymptomSchema.meta({
|
||||
// `.meta()` RETURNS a new schema in Zod 4 rather than annotating in place, so
|
||||
// the result has to be captured and referenced. A bare `schema.meta({...})`
|
||||
// statement registers nothing, and the component id it names never reaches
|
||||
// the document.
|
||||
const createCycleCustomSymptomRequest = createCustomSymptomSchema.meta({
|
||||
id: "CreateCycleCustomSymptomRequest",
|
||||
description:
|
||||
"Create-a-custom-symptom body. `label` is trimmed and must be 1..40 characters. `icon` is optional and must name one of the twenty allow-listed Lucide icons (unknown names 422 rather than falling back). `categoryKey` is reserved for a future per-symptom category choice and today accepts only the literal `custom`. The minted key, the sort order and the owning record all come from the server.",
|
||||
});
|
||||
|
||||
const updateCycleCustomSymptomRequest = updateCustomSymptomSchema.meta({
|
||||
id: "UpdateCycleCustomSymptomRequest",
|
||||
description:
|
||||
"Partial custom-symptom edit; at least one of `label` / `icon` / `isActive` is required. `isActive: false` hides the symptom while every day log that references it stays intact; `isActive: true` brings it back, and the per-record cap is re-checked on the way in so hiding and re-enabling cannot be used to exceed it.",
|
||||
});
|
||||
|
||||
const cycleCustomSymptom = z
|
||||
.object({
|
||||
key: z
|
||||
@@ -509,7 +522,7 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: createCustomSymptomSchema },
|
||||
"application/json": { schema: createCycleCustomSymptomRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
@@ -539,6 +552,105 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/cycle/symptoms/custom/{key}": {
|
||||
patch: {
|
||||
tags: ["Cycle"],
|
||||
summary: "Rename, re-icon or hide a custom cycle symptom (v1.15.1)",
|
||||
description:
|
||||
"Edits one custom symptom the CALLER owns. The key must carry the `custom:` prefix — a catalogue key is refused as not-found before anything is read — and it is resolved against the caller's own rows only, so another account's key is a 404 and never a 403. A re-activation re-checks the 50-symptom cap, because a hidden row does not count toward it and skipping the check would make hide-then-enable a way around the limit. The audit row records which FIELDS the write touched and the resulting active state, never the decrypted label. Rate-limited 30/min. Gated: `cycle.disabled` 403 when cycle tracking is off. NOT delegable — unlike the collection this hangs off, both verbs here resolve the caller as themselves, so a delegate can create a symptom in a shared record and cannot then rename or hide it. Cookie or Bearer auth.",
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
key: z
|
||||
.string()
|
||||
.describe("The `custom:<uuid>` key. A catalogue key 404s."),
|
||||
}),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: updateCycleCustomSymptomRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"The edited symptom, with the stored label decrypted and the resulting `isActive` — a field the create response does not carry.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
cycleCustomSymptom.extend({ isActive: z.boolean() }),
|
||||
"CycleCustomSymptomUpdatedEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"The key is not a `custom:` key, or names no symptom this account owns. The two are indistinguishable.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...cycleDisabledResponse,
|
||||
...stdResponses,
|
||||
"422": {
|
||||
description:
|
||||
"Either the body failed validation — the multi-issue envelope with `meta.errorCode` = `cycle.symptom.custom.invalid` — or re-activating would exceed the 50 active custom symptoms, in which case `meta.errorCode` = `cycle.symptom.custom.limit` and there is no issue list.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"429": {
|
||||
description:
|
||||
"More than 30 edits or deletes in the trailing minute for this account (`cycle:symptom:custom:<userId>`, shared with the DELETE below — and note this bucket keys on the USER, where the create on the collection keys on the ACTOR).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Cycle"],
|
||||
summary: "Hide or purge a custom cycle symptom (v1.15.1)",
|
||||
description:
|
||||
"Two different operations behind one verb, chosen by `purge`. By DEFAULT this is not a delete at all: the row is deactivated, so it leaves the chip grid while every day log that recorded it keeps its reference and its history reads unchanged. With `purge=true` the row is HARD-deleted and the foreign-key cascade takes every `cycle_symptom_link` with it — the days that recorded the symptom lose that fact, and nothing reconstructs it. There is no confirmation step on the wire and no undo. The response reports which of the two happened in `purged`. The key must carry the `custom:` prefix and resolves against the caller's own rows only. The audit row records the mode and nothing else — never the decrypted label. Rate-limited 30/min. Gated: `cycle.disabled` 403. NOT delegable. Cookie or Bearer auth.",
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
key: z
|
||||
.string()
|
||||
.describe("The `custom:<uuid>` key. A catalogue key 404s."),
|
||||
}),
|
||||
query: z.object({
|
||||
purge: z
|
||||
.literal("true")
|
||||
.optional()
|
||||
.describe(
|
||||
"Send exactly `true` for the hard delete with its cascade. Any other value, and omitting it, deactivates instead — the test is an equality against the string, not a boolean parse.",
|
||||
),
|
||||
}),
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"Done. `purged: true` means the row and its day-log links are gone; `false` means it was deactivated and its history is intact.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ key: z.string(), purged: z.boolean() }),
|
||||
"CycleCustomSymptomDeletedEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"The key is not a `custom:` key, or names no symptom this account owns.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...cycleDisabledResponse,
|
||||
...stdResponses,
|
||||
"429": {
|
||||
description:
|
||||
"More than 30 edits or deletes in the trailing minute for this account.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/cycle/day-logs": {
|
||||
get: {
|
||||
tags: ["Cycle"],
|
||||
|
||||
@@ -283,7 +283,67 @@ const dashboardSummaryResponse = z
|
||||
"The native client's dashboard aggregator: greeting, logging streak, today's dose tally, the per-metric tiles, and the sleep-rhythm DTO. Served through a 60 s fresh / 1 h stale read-through cache, so `greeting.date` and `lastUpdated` can be up to an hour old on a stale-window serve; writes mark the bucket so the account's own action lands on the very next read. No LLM is reachable from this path.",
|
||||
});
|
||||
|
||||
const chartOverlayPrefsPutBody = z
|
||||
.object({
|
||||
chartKey: z
|
||||
.enum(CHART_OVERLAY_KEYS)
|
||||
.describe("Which chart's overlays are being written. Closed enum."),
|
||||
prefs: z
|
||||
.object({
|
||||
showTrendIndicator: z.boolean(),
|
||||
showTrendArrow: z.boolean(),
|
||||
showTargetRange: z.boolean(),
|
||||
comparisonBaseline: z
|
||||
.enum(["none", "lastMonth", "lastYear"])
|
||||
.optional()
|
||||
.describe("Defaults to `none` when omitted."),
|
||||
rangePoints: z
|
||||
.union([z.literal(0), z.literal(7), z.literal(30), z.literal(90)])
|
||||
.optional()
|
||||
.describe(
|
||||
"Persisted range-tab selection. Optional so a client on an older bundle that never sends it does not 422; omitting it stores the slot WITHOUT the field rather than storing a default.",
|
||||
),
|
||||
})
|
||||
.describe(
|
||||
"The complete overlay state for that chart. The three booleans are REQUIRED — this replaces the chart's slot wholesale rather than merging into it, so a body that names only the toggle being flipped silently clears the other two.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "ChartOverlayPrefsPutBody",
|
||||
description:
|
||||
"One chart's overlay preferences. Partial across CHARTS (only the named `chartKey` is touched, every other chart's slot is preserved) and total WITHIN a chart (the named slot is replaced).",
|
||||
});
|
||||
|
||||
export const dashboardWidgetPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/dashboard/chart-overlay-prefs": {
|
||||
put: {
|
||||
tags: ["Dashboard"],
|
||||
summary: "Save one chart's overlay preferences",
|
||||
description:
|
||||
"Writes the overlay toggles for a single chart into the same `dashboardWidgetsJson` blob `GET /api/dashboard/widgets` reads, without the client having to round-trip the whole layout — which is what it exists for: a chart wrapper knows the chart it is flipping and nothing else, and making it re-send the entire widget array would be both wasteful and racy across several charts at once. The read-modify-write runs inside a SERIALIZABLE transaction, so two tabs toggling different charts cannot clobber one another. Note the granularity: partial across charts, TOTAL within one — the named slot is replaced, so a body carrying only the toggle being changed clears the other two. This route accepts NO optimistic-concurrency token, unlike its `/api/dashboard/widgets` sibling; the transaction is what protects it instead. A malformed body is refused with the multi-issue 422 and an audit breadcrumb (`dashboard.chart-overlay.validation-failed`, deduplicated to one row per account per minute so a looping client cannot flood the ledger). Body capped at 64 KiB. Cookie or Bearer auth; the caller is always resolved as themselves, so these are never another record's preferences — the same choice the sibling PUT and DELETE make.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: chartOverlayPrefsPutBody },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"Saved. The response confirms the write and does NOT echo the resolved layout — re-read the widget layout if the new state is needed.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ saved: z.literal(true) }),
|
||||
"ChartOverlayPrefsSavedEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/dashboard/summary": {
|
||||
get: {
|
||||
tags: ["Dashboard"],
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
/**
|
||||
* OpenAPI route table — generic data export (the legacy plaintext download +
|
||||
* the passphrase-encrypted variant).
|
||||
* OpenAPI route table — the whole export surface: the mixed plaintext
|
||||
* download, the three per-type CSV downloads, the restore-compatible full
|
||||
* backup, and the passphrase-encrypted archive.
|
||||
*
|
||||
* `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.
|
||||
* All of it predated the registry, and none of it was caught, because
|
||||
* `pnpm openapi:check` compares the registry against the YAML and never the
|
||||
* ROUTE TREE against the registry: a route registered nowhere drifts with
|
||||
* every gate green.
|
||||
*
|
||||
* One property is worth reading before any of the operations below: NONE of
|
||||
* these responses is the standard `{ data, error }` envelope. Each route
|
||||
* builds its own `NextResponse` so it can stream and set a filename, so a
|
||||
* client decoding these with the shared envelope decoder will fail on all of
|
||||
* them. Every one of them shares the `export:<userId>` rate bucket, so ten
|
||||
* downloads an hour is the budget across the whole family and not per route.
|
||||
*
|
||||
* Part of the OpenAPI route table; aggregated in `./index.ts`.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
import type { ZodOpenApiObject } from "zod-openapi";
|
||||
import { backupPayloadSchema } from "@/lib/validations/backup";
|
||||
import { errorEnvelope, stdResponses } from "./shared";
|
||||
|
||||
const exportQuery = z
|
||||
@@ -32,6 +39,54 @@ const exportQuery = z
|
||||
})
|
||||
.meta({ id: "ExportQuery" });
|
||||
|
||||
const forgivingSince = z.iso
|
||||
.datetime({ offset: true })
|
||||
.optional()
|
||||
.describe(
|
||||
"Inclusive lower bound. Parsed with the plain Date constructor, so an ISO date (`2026-07-14`) works as well as a full instant — and an UNPARSEABLE value is silently DROPPED from the filter rather than refused, which widens the export instead of erroring. Validate on your side.",
|
||||
);
|
||||
|
||||
const forgivingUntil = z.iso
|
||||
.datetime({ offset: true })
|
||||
.optional()
|
||||
.describe("Inclusive upper bound, with the same forgiving parse as `since`.");
|
||||
|
||||
const exportRangeQuery = z
|
||||
.object({ since: forgivingSince, until: forgivingUntil })
|
||||
.meta({ id: "ExportRangeQuery" });
|
||||
|
||||
const exportMeasurementsQuery = z
|
||||
.object({
|
||||
since: forgivingSince,
|
||||
until: forgivingUntil,
|
||||
granularity: z
|
||||
.literal("raw")
|
||||
.optional()
|
||||
.describe(
|
||||
"Send `raw` for one row per stored sample, per-sleep-stage rows included. Anything else — including a misspelling — falls through to the default night aggregation rather than 422-ing.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "ExportMeasurementsQuery" });
|
||||
|
||||
const exportMedicationsQuery = z
|
||||
.object({
|
||||
intake: z
|
||||
.literal("false")
|
||||
.optional()
|
||||
.describe(
|
||||
"Send exactly `false` to omit the intake-history section. Any other value, and omitting it, leaves the history IN — the test is an equality against the string, not a boolean parse.",
|
||||
),
|
||||
medicationId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Scope both sections to one medication. Always narrowed by the account, so an id this account does not hold answers 404.",
|
||||
),
|
||||
since: forgivingSince,
|
||||
until: forgivingUntil,
|
||||
})
|
||||
.meta({ id: "ExportMedicationsQuery" });
|
||||
|
||||
const exportJsonBody = z
|
||||
.object({
|
||||
data: z
|
||||
@@ -80,6 +135,29 @@ const encryptedExportRequest = z
|
||||
})
|
||||
.meta({ id: "EncryptedExportRequest" });
|
||||
|
||||
// ── The restore-compatible full backup ───────────────────────────────
|
||||
//
|
||||
// The payload is described by the canonical `backupPayloadSchema` — the same
|
||||
// object the admin restore parses the uploaded file with — rather than by a
|
||||
// second declaration here. A restatement would be a second thing to keep in
|
||||
// step with a 130-section wire format, and the previous prose beside the
|
||||
// route is exactly what went wrong last time: it named six domains as
|
||||
// export-only that the restore has re-created for releases.
|
||||
//
|
||||
// One correction the prose has to carry, because the shape does not: the
|
||||
// builder has TWO modes and this route only ever produces one of them. The
|
||||
// route calls the builder with no options, so `purpose` is undefined and the
|
||||
// PORTABLE mode is what a user downloads. The disaster-recovery mode — row
|
||||
// ids, tombstones, ciphertext verbatim, operator settings, the mental-health
|
||||
// screeners and the consent receipts — is reached only by the weekly worker
|
||||
// and the admin path. Describing this route as if it could produce either
|
||||
// would be describing an option no caller has.
|
||||
const fullBackupPayload = backupPayloadSchema.meta({
|
||||
id: "FullBackupPayload",
|
||||
description:
|
||||
"The canonical restore-compatible backup, in its PORTABLE form. Every section a user's own record holds rides it, and the `manifest` key declares what does not. Portable specifics: soft-deleted rows are dropped rather than carried as tombstones; encrypted columns arrive DECRYPTED as plain text, so the file is readable by whoever holds it and readable by an instance that does not hold this one's key; row ids are omitted for the sections that only carry them in a disaster-recovery file. Three things are omitted outright and say so in `manifest`: original uploaded document BYTES (metadata and any generated summary ride, the files do not — download them individually), workout GPS routes and per-sample heart-rate / pace series (summary records ride), and the sensitive pair — completed WHO-5 / PHQ / GAD administrations, whose encrypted per-item answers include the PHQ-9 self-harm item, and consent receipts, which are given to ONE operator and would assert elsewhere an agreement that operator never obtained. Both of the sensitive sections arrive as empty arrays with their `manifest` entry set to `omitted`, so their absence is stated rather than inferred. Beyond the declared omissions there is a real, undeclared gap: roughly thirty of the models the backup plan marks as backed up have neither a reader here nor a restore branch yet, and `src/lib/export/backup-plan.ts` is the enumeration of which. `schemaVersion` is `2` on everything written today; the parser still accepts `1`.",
|
||||
});
|
||||
|
||||
export const exportPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/export": {
|
||||
get: {
|
||||
@@ -111,6 +189,100 @@ export const exportPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/export/full-backup": {
|
||||
get: {
|
||||
tags: ["Export"],
|
||||
summary: "Download the restore-compatible full backup",
|
||||
description:
|
||||
"The disaster-recovery-shaped JSON dump, in its PORTABLE form. It matches the canonical backup payload byte for byte with the weekly worker's file, so a user can hand this to an operator and the admin restore accepts it without conversion. Two things the shape cannot state: this route ALWAYS produces the portable mode — it calls the builder with no options, so there is no parameter that reaches the disaster-recovery mode, which is the worker and admin path alone — and the file is NOT the standard response envelope but the raw payload, because the restore expects the payload and not `{ data: … }`. Encrypted columns arrive decrypted, so the file is as sensitive as the record it came from; the `manifest` key inside it declares what was deliberately left out. `Content-Disposition: attachment` with `healthlog-backup-<userId>-<YYYY-MM-DD>.json`, `Cache-Control: no-store`. Shares the `export:<userId>` bucket (10 per hour) with every other export route. Audits as `user.export.full-backup` with the row counts. Cookie or Bearer auth; the caller is always resolved as themselves, so this download cannot be delegated to a shared record.",
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"The backup payload. `application/json; charset=utf-8`, served as an attachment.",
|
||||
content: {
|
||||
"application/json": { schema: fullBackupPayload },
|
||||
},
|
||||
},
|
||||
"429": {
|
||||
description:
|
||||
"More than 10 exports in the trailing hour for this account (`export:<userId>`, shared across the export family).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"401": stdResponses["401"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/export/measurements": {
|
||||
get: {
|
||||
tags: ["Export"],
|
||||
summary: "Download measurements as CSV",
|
||||
description:
|
||||
"Streams the account's measurements as a single-table CSV, page by page, so a multi-year history never buffers whole. Soft-deleted rows are excluded, so an undone entry or a pending-sync tombstone never reaches the file. Sleep is night-aggregated by default; `granularity=raw` emits the per-stage rows instead. Glucose is written in the account's display unit rather than the canonical storage unit — which is the one way this file differs from the mixed `/api/export` download, whose measurement section pins mg/dL. `since` and `until` are FORGIVING: an unparseable date is dropped from the filter rather than refused, so a typo widens the export silently instead of erroring. Errors from authentication and the first page are returned normally; a failure after the first page arrives mid-stream, with a 200 already sent. `Content-Disposition: attachment` with `healthlog-measurements-<userId>-<YYYY-MM-DD>.csv`. Shares the `export:<userId>` bucket (10 per hour). Audits as `user.export.measurements`. Cookie or Bearer auth; not delegable.",
|
||||
requestParams: { query: exportMeasurementsQuery },
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"The CSV. `text/csv; charset=utf-8`, served as an attachment, `Cache-Control: no-store`.",
|
||||
content: { "text/csv": { schema: z.string() } },
|
||||
},
|
||||
"429": {
|
||||
description:
|
||||
"More than 10 exports in the trailing hour for this account.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"401": stdResponses["401"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/export/medications": {
|
||||
get: {
|
||||
tags: ["Export"],
|
||||
summary: "Download medications (and their intake history) as CSV",
|
||||
description:
|
||||
"The medication list, plus the intake history unless it is switched off. The two ride one file as SEPARATE tables under `# Medications` and `# Intake history` headings, so a single-table CSV parser will choke on it. `medicationId` scopes both sections to one medication — the id is always narrowed by the account, so a foreign or unknown id resolves to no medication and answers 404 rather than an empty file. `since` / `until` bound the intake section only, on `scheduledFor`, and are FORGIVING: an unparseable date is dropped from the filter rather than refused. Tombstoned intake rows are excluded. Note the default: `intake` is on unless the caller sends exactly `false`, so any other value — including `0` or `no` — leaves the history in. `Content-Disposition: attachment` with `healthlog-medications-<userId>-<YYYY-MM-DD>.csv`. Shares the `export:<userId>` bucket (10 per hour). Audits as `user.export.medications`. Cookie or Bearer auth; not delegable.",
|
||||
requestParams: { query: exportMedicationsQuery },
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"The CSV. `text/csv; charset=utf-8`, served as an attachment, `Cache-Control: no-store`.",
|
||||
content: { "text/csv": { schema: z.string() } },
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"`medicationId` named a medication this account does not hold. An unknown id and another account's id are indistinguishable.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"429": {
|
||||
description:
|
||||
"More than 10 exports in the trailing hour for this account.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"401": stdResponses["401"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/export/mood": {
|
||||
get: {
|
||||
tags: ["Export"],
|
||||
summary: "Download mood entries as CSV",
|
||||
description:
|
||||
"The account's mood entries as a single-table CSV, newest first, with the note DECRYPTED into the file — the whole point of a portable export, and the reason the file is as sensitive as the entries themselves. Tombstoned rows are excluded. `since` / `until` bound `moodLoggedAt` and are FORGIVING: an unparseable date is dropped from the filter rather than refused. Assembled in full before it is sent, unlike the measurement download. `Content-Disposition: attachment` with `healthlog-mood-<userId>-<YYYY-MM-DD>.csv`. Shares the `export:<userId>` bucket (10 per hour). Audits as `user.export.mood`. NOT module-gated — a switched-off mood module still exports its entries, because this is the account's own data leaving on request. Cookie or Bearer auth; not delegable.",
|
||||
requestParams: { query: exportRangeQuery },
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"The CSV. `text/csv; charset=utf-8`, served as an attachment, `Cache-Control: no-store`.",
|
||||
content: { "text/csv": { schema: z.string() } },
|
||||
},
|
||||
"429": {
|
||||
description:
|
||||
"More than 10 exports in the trailing hour for this account.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"401": stdResponses["401"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/export/encrypted": {
|
||||
post: {
|
||||
tags: ["Export"],
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
MODULE_DISABLED_DESCRIPTION,
|
||||
dataEnvelope,
|
||||
errorEnvelope,
|
||||
idempotencyKeyParameter,
|
||||
idempotentWrite,
|
||||
moduleDisabledResponse,
|
||||
recordRefusal,
|
||||
stdResponses,
|
||||
@@ -49,7 +51,28 @@ import {
|
||||
insightsSettingsResponse,
|
||||
insightsSettingsPutRequest,
|
||||
targetsResponse,
|
||||
providerChainResponse,
|
||||
providerChainPutRequest,
|
||||
insightsGenerateReadResponse,
|
||||
insightsGeneratePostRequest,
|
||||
insightsGeneratePostResponse,
|
||||
coachReadQuery,
|
||||
coachReadStripResponse,
|
||||
coachSeededQuestionResponse,
|
||||
narrativeQuery,
|
||||
narrativeResponse,
|
||||
glp1TimelineQuery,
|
||||
glp1TimelineResponse,
|
||||
intradayPulseQuery,
|
||||
intradayPulseResponse,
|
||||
} from "./schemas";
|
||||
import { recommendationFeedbackRequestSchema } from "@/lib/validations/recommendation-feedback";
|
||||
|
||||
const insightsFeedbackRequest = recommendationFeedbackRequestSchema.meta({
|
||||
id: "InsightsFeedbackRequest",
|
||||
description:
|
||||
"A thumbs-up / thumbs-down on one recommendation. The body carries the rec id AND a snapshot of the text as rendered, because deduplication is on the pair: a regeneration that rewrites the same id with new text is a genuinely different rating, which is the signal the aggregator wants. `providerType` and `promptVersion` are NOT accepted — the server fills both from the account's own attribution, so a client cannot tamper with the slice the quality dashboard reports on.",
|
||||
});
|
||||
|
||||
export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/dashboard/snapshot": {
|
||||
@@ -171,6 +194,303 @@ export const insightsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/generate": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Read the cached advisor briefing (never generates)",
|
||||
description:
|
||||
'The read-only advisor. It serves the cached payload and NEVER calls a provider — the surfaces that mount on page load used to POST here, which blocked first paint on the whole provider chain. When the cache is stale or missing AND a provider exists it enqueues an out-of-band warm and says so in `revalidating`; the next read reflects it. User-initiated regeneration is the POST. Four honesty signals ride the payload and are the whole point of it: `hasProvider: false` means no warm pass can ever refresh what is shown, so stop polling and offer to connect one; `generationFailed` means the last attempt failed and the text is HELD rather than fresh; `generationFailureClass` says which lever to point at; and `briefingOmittedReason: "ungrounded"` means a generation SUCCEEDED and the grounding gate withheld the briefing, which is a different state from a failure. Gated on the Coach assistant surface — an operator who turns Coach off empties every advisor consumer. Cookie or Bearer auth; the caller is always resolved as themselves, so this read cannot be delegated to a shared record.',
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"The cached payload, or the empty shape when nothing is cached. Always 200 — an absent briefing is a normal answer here, not an error.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
insightsGenerateReadResponse,
|
||||
"InsightsGenerateReadEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"The operator has switched the Coach assistant surface off (`meta.errorCode` = `assistant.disabled.coach`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"401": stdResponses["401"],
|
||||
"429": stdResponses["429"],
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Insights"],
|
||||
summary: "Generate the advisor briefing inline (costs tokens)",
|
||||
description:
|
||||
"The only path in the advisor family that calls a provider, and it does so INLINE. Two gates bound it, and they bound different things. The RATE gate is `insights:<userId>`, ten generations per hour by default and configurable by the operator through `INSIGHTS_RATE_LIMIT_PER_HOUR` (a value below 1 falls back to the default); it is checked AFTER the cache short-circuit, so a cache hit never spends a token. The BUDGET gate is the day's token ceiling, refused at reservation time before any provider is contacted — so it is not a provider failure, records no failure marker, and answers 429 with `meta.errorCode` = `insights.generate.budgetExceeded`, which is how a client tells 'you asked too often' from 'the day's spend is gone'. Two more things a caller should know before integrating: a plain 200 does NOT mean a provider ran — a fresh cache inside 24 hours answers `cached: true`, and so does an exhausted quota when the fresh cache merely lacked a briefing — and a request that TIMES OUT client-side may still complete on the server, writing the briefing to the cache or a dated failure marker, which is exactly the pair the read GET reports so a client can settle an aborted regenerate by polling rather than guessing. Gated on the Coach assistant surface, consent-gated when the resolved chain egresses through the operator's server-managed key. Cookie or Bearer auth; not delegable.",
|
||||
requestBody: {
|
||||
required: false,
|
||||
content: {
|
||||
"application/json": { schema: insightsGeneratePostRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"A payload — freshly generated, served from the 24-hour cache, or served from a briefingless cache after the hourly quota refused the regeneration. `cached` tells the three apart.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
insightsGeneratePostResponse,
|
||||
"InsightsGeneratePostEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description:
|
||||
"The Coach assistant surface is off (`assistant.disabled.coach`), or the resolved chain egresses through the operator's key and no consent receipt is on file (`consent.ai.required`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"409": {
|
||||
description:
|
||||
"The account's privacy mode, exclude list, locale or profile changed while the generation was running, so the finished text describes a scope that no longer applies and is discarded rather than written. `meta.errorCode` = `insights.generate.scopeChanged` or `insights.generate.profileScopeChanged`. Retry.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"422": {
|
||||
description:
|
||||
"No AI provider is configured anywhere for this account, or the provider answered something that was not valid JSON (`meta.errorCode` = `ai_response_truncated` when the answer was cut off mid-stream rather than malformed), or the finished prose was withheld by the outbound safety screen (`insights.generate.outboundScreened`).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"429": {
|
||||
description:
|
||||
"Three different refusals share this status and are told apart by `meta.errorCode`. No code: more than the hourly limit of generations for this account. `insights.generate.budgetExceeded`: the DAY's token ceiling refused the reservation before any provider was contacted — retry tomorrow, not in an hour. No code, with provider wording: the upstream provider rate-limited every hop of the chain.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"503": {
|
||||
description:
|
||||
"Every provider in the chain failed for a reason that is not a rate limit — auth, timeout or an upstream 5xx. A dated failure marker is written, so the read GET reports the briefing as held rather than fresh.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"401": stdResponses["401"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/provider-chain": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Read the account's AI-provider chain",
|
||||
description:
|
||||
"The chain as the settings surface renders it: the resolved head, the last provider that actually worked, and the PERSISTED list with disabled entries included. That last point is the one that matters — the resolved list drops disabled entries, and returning it here once made a disabled provider vanish from the settings list along with the toggle that would have brought it back. No key, token or account id is ever on this wire; the response carries provider TYPES and boolean state only. Cookie or Bearer auth; not delegable.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The chain summary.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
providerChainResponse,
|
||||
"ProviderChainEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ["Insights"],
|
||||
summary: "Replace the account's AI-provider chain",
|
||||
description:
|
||||
"Persists the chain wholesale in the order the array gives. Any `priority` a client sends is accepted and IGNORED — the server recomputes it from insertion order, so a stale tab cannot store a chain whose displayed order disagrees with its stored one. A repeated provider type is refused rather than deduplicated on read, because drift between the two representations is the kind of thing that costs an evening. No credential is written or read here. Body capped at 64 KiB. Cookie or Bearer auth; not delegable.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: providerChainPutRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"Saved. The response confirms the write and does not echo the chain — re-read it if the resolved state is needed.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ saved: z.literal(true) }),
|
||||
"ProviderChainSavedEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"422": {
|
||||
description:
|
||||
"The body was not a valid chain — an empty array, more entries than there are provider types, an unknown provider type, or the same type twice. A single-message envelope with no issue list.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"401": stdResponses["401"],
|
||||
"429": stdResponses["429"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/feedback": {
|
||||
post: {
|
||||
tags: ["Insights"],
|
||||
summary: "Rate one recommendation",
|
||||
description:
|
||||
"Records a thumbs-up or thumbs-down against one recommendation, with the rendered text snapshotted alongside the id. The server fills the provider and prompt-version attribution from the account's own state so a client cannot skew the slice the quality dashboard reports on. Idempotent: send `Idempotency-Key` and a retry replays the first response rather than writing twice. Rate-limited 60/h per account — the unique index only catches EXACT replays, so without the cap a client could distort the aggregator by varying the id or the text per request. Body capped at 64 KiB. Cookie or Bearer auth; not delegable.",
|
||||
parameters: [idempotencyKeyParameter],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": { schema: insightsFeedbackRequest },
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
...idempotentWrite(),
|
||||
"201": {
|
||||
description: "The rating was recorded.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
}),
|
||||
"InsightsFeedbackCreatedEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"422": {
|
||||
description:
|
||||
"The body failed validation. A single-message envelope (`Invalid feedback payload`) with no issue list — this route does not use the multi-issue form.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"429": {
|
||||
description:
|
||||
"More than 60 ratings in the trailing hour for this account.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"401": stdResponses["401"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/coach-read": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "The two-line Coach read for one metric",
|
||||
description:
|
||||
"The own-baseline placement and the single strongest lagged association whose outcome is the named metric — the two server-authoritative lines a metric sub-page renders above its chart. Pure compute over the baseline and correlation engines: no provider call and no cache table, so web and native decode the same DTO rather than each deriving one. Line two arrives as a finished sentence in the reader's language and is printed verbatim, so the resolved locale decides the row; a correlation failure degrades it to null and never sinks line one. Module-gated on `insights` and gated on the Coach assistant surface, because the strip is the ambient Coach presence on that page. Shared analytics-read budget. Delegable at MANAGE level over the whole record: it is computed across the record with no provider on the path. Cookie or Bearer auth.",
|
||||
requestParams: { query: coachReadQuery },
|
||||
responses: {
|
||||
...recordRefusal(MODULE_DISABLED_DESCRIPTION),
|
||||
"200": {
|
||||
description: "The strip.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
coachReadStripResponse,
|
||||
"CoachReadStripEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/coach/seeded-question": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "Today's suggested Coach opener",
|
||||
description:
|
||||
"Resolves today's single most notable derived wellness signal into a tappable opener for the Coach's blank-chat hero, using the same confidence-gated detector the daily briefing uses. The selection happens SERVER-side and the client renders it rather than recomputing. `signal: null` is the ordinary answer and means the hero keeps its neutral greeting — never a fabricated opener. Two different causes produce that null and are indistinguishable on the wire: nothing crossed the notability gate, or the account has turned proactive suggestions off, in which case the detector does not run at all. Module-gated on `insights` and on the Coach assistant surface. Shared analytics-read budget. Cookie or Bearer auth; not delegable.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The opener, or the neutral null.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
coachSeededQuestionResponse,
|
||||
"CoachSeededQuestionEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...moduleDisabledResponse,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/narrative": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "The latest week or month retrospective",
|
||||
description:
|
||||
"The last generated period summary for the record. Read-only by construction: it never blocks on a provider, serves the last good row immediately, and warms out of band when the row is stale or missing. A provider-less account is not left empty — the generator falls back to deterministic, non-causal prose, so even the no-key demo gets a retrospective on the next read. `revalidating` reports whether this read enqueued a warm, and is always FALSE on a delegated request: the route warms unconditionally rather than on a miss, so without that suppression a manager's first navigation here would be an egress of the owner's record the owner never asked for. Rows are keyed per locale, so `locale` selects a different stored row rather than translating one. Module-gated on `insights` and on the `insightStatus` assistant surface. Delegable at MANAGE level over the whole record. Cookie or Bearer auth.",
|
||||
requestParams: { query: narrativeQuery },
|
||||
responses: {
|
||||
...recordRefusal(MODULE_DISABLED_DESCRIPTION),
|
||||
"200": {
|
||||
description:
|
||||
"The retrospective, or `narrative: null` when none is on file yet. Always 200 — absence is a normal answer.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(narrativeResponse, "NarrativeEnvelope"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/glp1-timeline": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "GLP-1 therapy timeline",
|
||||
description:
|
||||
"A chronological merge of every GLP-1-relevant event the record holds: titration steps, injections with their sites, legacy stock-ledger movements, and side-effect-tagged mood days from the trailing 90 days. `hasGlp1: false` with no entries when the record holds no medication classified GLP-1, so the surface hides cleanly. Two limits worth knowing before reading the output as complete: inventory entries come from the LEGACY running-sum ledger only, so a medication tracked with per-container inventory shows none, and the side-effect scan matches a FIXED bilingual list of English and German tag strings, so a tag written in another language contributes nothing. `limit` is forgiving — an out-of-range or non-numeric value falls back to the default rather than 422-ing. Delegable at MANAGE level over the whole record: it spans medications and mood, with no provider on the path. Cookie or Bearer auth.",
|
||||
requestParams: { query: glp1TimelineQuery },
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "The timeline, newest first.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
glp1TimelineResponse,
|
||||
"Glp1TimelineEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/pulse/intraday": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "One day's intraday heart-rate shape",
|
||||
description:
|
||||
"The day's ten-minute mean heart-rate series plus, when every confidence gate holds, at most ONE cautious elevated-at-rest window. Computed from raw samples through the read-swap pattern rather than persisted as ten-minute rollups for all history, so a day outside the dense-retention window reads back at the coarser hourly grain instead of empty — `resolution` says which, and `tension` is always null on an hourly day because that read needs per-sample resolution. The bar for a tension window is deliberately high; under-flagging is the intended failure mode, and it is awareness, never a diagnosis. `date` is STRICT here, unlike the forgiving date filters elsewhere on the surface: anything but a `YYYY-MM-DD` literal is a 422. Module-gated on `insights`. Delegable at MANAGE level over the whole record. Cookie or Bearer auth.",
|
||||
requestParams: { query: intradayPulseQuery },
|
||||
responses: {
|
||||
...recordRefusal(MODULE_DISABLED_DESCRIPTION),
|
||||
"200": {
|
||||
description:
|
||||
"The day's series. `Cache-Control` is the bfcache-friendly `private, max-age=0, must-revalidate` rather than `no-store`.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
intradayPulseResponse,
|
||||
"IntradayPulseEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/settings": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
|
||||
@@ -17,6 +17,11 @@ import {
|
||||
SAME_TIME_BASELINE_TYPES,
|
||||
} from "@/lib/insights/derived/registry";
|
||||
import { ANALYTICS_RANGES } from "@/lib/analytics/range-delta";
|
||||
import { PROVIDER_CHAIN_TYPES } from "@/lib/ai/provider-chain";
|
||||
import { PERIOD_DAYS } from "@/lib/insights/narrative/period-narrative";
|
||||
|
||||
/** The retrospective periods the narrative route accepts, read off the engine. */
|
||||
const NARRATIVE_PERIOD_VALUES = Object.keys(PERIOD_DAYS);
|
||||
|
||||
export const insightsComprehensiveResponse = z
|
||||
.object({
|
||||
@@ -2244,3 +2249,456 @@ export const targetsResponse = z
|
||||
description:
|
||||
"Per-metric target tiles with their consistency strips, the page-level summary, the diastolic companion figures and the profile facts every band was resolved from. Pure compute over the record's own data; no provider anywhere on the path.",
|
||||
});
|
||||
|
||||
// ── The AI-provider chain (`/api/insights/provider-chain`) ────────────
|
||||
//
|
||||
// Nothing on either wire carries a key, a token or an account id. The GET
|
||||
// answers provider TYPES and boolean state; the PUT accepts the same. Worth
|
||||
// stating explicitly because the surface sits next to the credential settings
|
||||
// and the obvious assumption about it is wrong.
|
||||
|
||||
export const providerChainResponse = z
|
||||
.object({
|
||||
activeProvider: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The provider type the runner would reach first — the head of the RESOLVED chain, which drops disabled entries. Null when the account has none configured.",
|
||||
),
|
||||
cachedActiveProvider: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The last provider that actually worked, from the in-process cache. Present when the runner has rerouted to a fallback, so the settings surface can say `active: codex (cached: openai)` instead of leaving the reroute in the logs. In-process, so it resets on restart and can differ between instances.",
|
||||
),
|
||||
configuredChain: z
|
||||
.array(
|
||||
z.object({
|
||||
providerType: z.string(),
|
||||
enabled: z
|
||||
.boolean()
|
||||
.describe("Whether the runner may reach this entry."),
|
||||
available: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"Always `true` today. Reserved for rendering an unconfigured slot with a needs-setup pill; do not branch on it as if it reported credential presence.",
|
||||
),
|
||||
}),
|
||||
)
|
||||
.describe(
|
||||
"The PERSISTED chain in priority order, disabled entries INCLUDED — that is the difference from `activeProvider`, and it is deliberate: reading the resolved list here once made a disabled entry vanish from the settings list along with its toggle position.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "ProviderChainResponse",
|
||||
description:
|
||||
"The account's AI-provider chain as the settings surface reads it. Provider TYPES and boolean state only — no key, token or account id is ever on this wire.",
|
||||
});
|
||||
|
||||
export const providerChainPutRequest = z
|
||||
.object({
|
||||
chain: z
|
||||
.array(
|
||||
z.object({
|
||||
providerType: z
|
||||
.enum(PROVIDER_CHAIN_TYPES as unknown as [string, ...string[]])
|
||||
.describe(
|
||||
"Closed allow-list. The mock provider is excluded from it structurally, which is what keeps production from reaching one.",
|
||||
),
|
||||
priority: z
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.describe(
|
||||
"ACCEPTED AND IGNORED. Priority is recomputed from the array's insertion order, so a stale client cannot persist a chain whose displayed order disagrees with its stored one. Send the order you want as the order of the array.",
|
||||
),
|
||||
enabled: z.boolean(),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.describe(
|
||||
"The whole chain, in the order it should be walked. At least one entry and at most one per known provider type; a repeated type is refused.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "ProviderChainPutRequest",
|
||||
description:
|
||||
"Replaces the account's provider chain wholesale. No credential is written or read here — only which provider types exist, in what order, and which are enabled.",
|
||||
});
|
||||
|
||||
// ── Read-only advisor + generation (`/api/insights/generate`) ─────────
|
||||
|
||||
export const insightsGenerateReadResponse = z
|
||||
.object({
|
||||
insights: z
|
||||
.record(z.string(), z.unknown())
|
||||
.nullable()
|
||||
.describe(
|
||||
"The cached insight payload, verbatim from the account's cache row. Null when nothing is cached or the stored row failed to parse — the two are indistinguishable here, and the second also silently enqueues a repair.",
|
||||
),
|
||||
cached: z
|
||||
.boolean()
|
||||
.describe("True when a payload was served from the cache."),
|
||||
cachedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"When that payload was written. Present only on the cached branch — the empty branch omits the key rather than sending null.",
|
||||
),
|
||||
legacyPayload: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"True when the cached payload predates the rationale fields, so the client can offer a regenerate. Never auto-regenerated: that would burn a rate-limit token on a cache hit without the user asking.",
|
||||
),
|
||||
revalidating: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"True when this read enqueued an out-of-band warm because the cache was stale or missing AND a provider exists. Poll (bounded) until it clears rather than sitting on the stale payload.",
|
||||
),
|
||||
hasProvider: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"False when no AI provider is configured anywhere. The served briefing can then NEVER refresh — no warm pass will fill it — so pair its age with a connect-a-provider affordance and stop polling.",
|
||||
),
|
||||
generationFailed: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"True when the most recent generation attempt failed after the last successful one. The last good text is still served, so this is the only honest signal that what is shown is held rather than fresh.",
|
||||
),
|
||||
generationFailureClass: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"Coarse class of that failure, so the empty state can point at the right lever (raise the response timeout versus re-check the provider). Null when the last attempt succeeded.",
|
||||
),
|
||||
briefingOmittedReason: z
|
||||
.literal("ungrounded")
|
||||
.nullable()
|
||||
.describe(
|
||||
"Non-null when a generation SUCCEEDED but the grounding gate withheld the briefing — a different state from `generationFailed`, and the reason the card can say 'withheld' instead of showing a silent empty tile.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "InsightsGenerateReadResponse",
|
||||
description:
|
||||
"The read-only advisor payload. This route NEVER calls a provider: it serves the cache and, when the cache is stale and a provider exists, enqueues a warm out of band.",
|
||||
});
|
||||
|
||||
export const insightsGeneratePostRequest = z
|
||||
.object({
|
||||
force: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
'Send `true` to bypass the 24-hour cache and generate now. Anything other than the boolean `true` — including the string `"true"` — is treated as absent. Forcing also means an exhausted hourly quota answers an honest 429 instead of degrading to the cached payload.',
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "InsightsGeneratePostRequest",
|
||||
description:
|
||||
"Optional body for the generating POST. Capped at 16 KiB. `userId` is taken from the session or the Bearer token and is never a body field.",
|
||||
});
|
||||
|
||||
export const insightsGeneratePostResponse = z
|
||||
.object({
|
||||
insights: z
|
||||
.record(z.string(), z.unknown())
|
||||
.describe("The insight payload, fresh or cached depending on `cached`."),
|
||||
cached: z.boolean(),
|
||||
cachedAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.optional()
|
||||
.describe("Present on the cached branch only."),
|
||||
legacyPayload: z.boolean(),
|
||||
briefingOmittedReason: z
|
||||
.literal("ungrounded")
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Non-null when the grounding gate stripped the briefing from THIS generation. Present on the freshly-generated branch.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "InsightsGeneratePostResponse",
|
||||
description:
|
||||
"The generating POST's answer. A 200 does not mean a provider was called — a cache hit inside the 24-hour window, and an exhausted hourly quota on a briefingless cache, both answer 200 with `cached: true`.",
|
||||
});
|
||||
|
||||
// ── Coach read strip (`/api/insights/coach-read`) ─────────────────────
|
||||
|
||||
export const coachReadQuery = z
|
||||
.object({
|
||||
metric: measurementTypeEnum.describe(
|
||||
"The metric the strip is about. Closed enum: an unknown value 422s.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "CoachReadQuery" });
|
||||
|
||||
export const coachReadStripResponse = z
|
||||
.object({
|
||||
baseline: z
|
||||
.object({
|
||||
low: z.number().describe("Robust lower edge of the personal range."),
|
||||
high: z.number().describe("Robust upper edge."),
|
||||
latest: z.number().describe("Today's latest reading, same units."),
|
||||
placement: z.enum(["within", "above", "below"]),
|
||||
sampleDays: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("Distinct days behind the band, for transparency."),
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
"Line one. Null when the band could not be established or there is no reading to place — and then `learning` is true. Mutually exclusive with it.",
|
||||
),
|
||||
learning: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"True when history is below the engine's seven-day floor or there are no readings. Render 'still learning your range' rather than inventing one.",
|
||||
),
|
||||
driver: z
|
||||
.object({
|
||||
note: z
|
||||
.string()
|
||||
.describe(
|
||||
"An already-localised, conservative, descriptive sentence the client prints VERBATIM. Never causal. Written in the reader's language server-side, which is why it is not a key.",
|
||||
),
|
||||
behaviour: z.string().describe("Lower-cased behaviour label."),
|
||||
outcome: z.string().describe("Lower-cased outcome label."),
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
"Line two: the single strongest lagged association whose outcome is this metric. Null when none clears the effect-size floor, and null when the correlation read failed — the failure is deliberately swallowed so a hiccup there never sinks line one.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachReadStrip",
|
||||
description:
|
||||
"The two server-authoritative lines a metric sub-page renders above its chart. Pure compute over the baseline and correlation engines — no provider call, no cache table — so web and native decode the same resolved DTO.",
|
||||
});
|
||||
|
||||
// ── Coach seeded opener (`/api/insights/coach/seeded-question`) ───────
|
||||
|
||||
export const coachSeededQuestionResponse = z
|
||||
.object({
|
||||
signal: z
|
||||
.object({
|
||||
sourceMetric: z
|
||||
.string()
|
||||
.describe(
|
||||
"Sentinel id the client keys its localised copy on (`readiness` / `recovery`).",
|
||||
),
|
||||
score: z.number().describe("The latest 0..100 score."),
|
||||
band: z
|
||||
.string()
|
||||
.describe(
|
||||
"`yellow` or `red`. Green never surfaces — a good day is not notable.",
|
||||
),
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
"Null whenever nothing crossed the detector's confidence and notability gate, AND whenever the account has turned proactive suggestions off — the two are indistinguishable on the wire, and both mean the hero keeps its neutral greeting rather than showing a fabricated opener.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "CoachSeededQuestion",
|
||||
description:
|
||||
"Today's single most notable derived wellness signal, resolved server-side into a tappable opener for the Coach's blank-chat hero. The client renders the resolved DTO and never recomputes the selection.",
|
||||
});
|
||||
|
||||
// ── Period narrative (`/api/insights/narrative`) ──────────────────────
|
||||
|
||||
export const narrativeQuery = z
|
||||
.object({
|
||||
period: z
|
||||
.enum(NARRATIVE_PERIOD_VALUES as [string, ...string[]])
|
||||
.describe("Which retrospective to read. Closed enum: unknown 422s."),
|
||||
locale: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Override the reader's resolved locale. The narrative row is keyed per locale, so this selects a different stored row rather than translating one; an unrecognised value falls back to the app default instead of 422-ing.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "NarrativeQuery" });
|
||||
|
||||
export const narrativeResponse = z
|
||||
.object({
|
||||
period: z.string().describe("Echoes the requested period."),
|
||||
locale: z.string().describe("The locale the row was read for."),
|
||||
narrative: z
|
||||
.object({
|
||||
text: z.string().describe("The retrospective prose, rendered as-is."),
|
||||
provenance: z
|
||||
.string()
|
||||
.describe(
|
||||
"Where the text came from — provider prose or the deterministic non-causal fallback a provider-less account gets.",
|
||||
),
|
||||
updatedAt: z.iso.datetime({ offset: true }),
|
||||
})
|
||||
.nullable()
|
||||
.describe("Null when no row exists for this period and locale yet."),
|
||||
revalidating: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"True when this read enqueued a warm. Always FALSE on a delegated request even when the row is stale: this route warms unconditionally rather than on a miss, so a manager's first navigation here would otherwise be an egress of the owner's record the owner never asked for. The stale row is still served; only the warm is withheld.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "NarrativeResponse",
|
||||
description:
|
||||
"The latest generated period retrospective. Read-only by construction — it never blocks on a provider, serves the last good row immediately, and warms out of band.",
|
||||
});
|
||||
|
||||
// ── GLP-1 therapy timeline (`/api/insights/glp1-timeline`) ────────────
|
||||
|
||||
export const glp1TimelineQuery = z
|
||||
.object({
|
||||
limit: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(200)
|
||||
.optional()
|
||||
.default(60)
|
||||
.describe(
|
||||
"Entry cap, 1..200, default 60. A non-integer, out-of-range or non-numeric value falls back to the default rather than 422-ing.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "Glp1TimelineQuery" });
|
||||
|
||||
export const glp1TimelineResponse = z
|
||||
.object({
|
||||
hasGlp1: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"False when the record holds no medication classified GLP-1. `entries` is then empty and the surface hides cleanly.",
|
||||
),
|
||||
entries: z
|
||||
.array(
|
||||
z.object({
|
||||
date: z.iso
|
||||
.datetime({ offset: true })
|
||||
.describe(
|
||||
"When it happened. A `side-effect` entry is a DAY rather than an instant, and is stamped at 12:00 UTC of that day so it sorts — do not read it as a capture time.",
|
||||
),
|
||||
kind: z.enum([
|
||||
"dose-change",
|
||||
"injection",
|
||||
"inventory",
|
||||
"side-effect",
|
||||
]),
|
||||
medicationName: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Absent on a `side-effect` entry."),
|
||||
doseValue: z.number().optional(),
|
||||
doseUnit: z.string().optional(),
|
||||
doseDelta: z
|
||||
.enum(["up", "down"])
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe("Titration direction against the previous step."),
|
||||
note: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe("The titration note, decrypted."),
|
||||
injectionSite: z.string().nullable().optional(),
|
||||
inventoryDelta: z.number().int().optional(),
|
||||
reason: z.string().optional(),
|
||||
tags: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe(
|
||||
"The recognised side-effect tags picked up that day. Matched against a FIXED bilingual list of English and German tag strings, so a tag written in any other language — or spelled differently — does not appear here at all.",
|
||||
),
|
||||
}),
|
||||
)
|
||||
.describe(
|
||||
"Newest first, capped at `limit`. A heterogeneous union: which optional keys are present depends on `kind`, and no entry carries all of them.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "Glp1TimelineResponse",
|
||||
description:
|
||||
"A chronological merge of the GLP-1-relevant events the record holds: titration steps, injections with their sites, legacy stock-ledger movements, and side-effect-tagged mood days from the trailing 90 days. Inventory entries come from the legacy running-sum ledger only, so a medication tracked with per-container inventory shows none.",
|
||||
});
|
||||
|
||||
// ── Intraday pulse (`/api/insights/pulse/intraday`) ───────────────────
|
||||
|
||||
export const intradayPulseQuery = z
|
||||
.object({
|
||||
date: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional()
|
||||
.describe(
|
||||
"The local day to read. Defaults to the record's local today. Anything but a `YYYY-MM-DD` literal is a 422 — this parameter is strict where most of the surface's date filters are forgiving.",
|
||||
),
|
||||
})
|
||||
.meta({ id: "IntradayPulseQuery" });
|
||||
|
||||
export const intradayPulseResponse = z
|
||||
.object({
|
||||
dateKey: z.string().describe("The local day that was read."),
|
||||
timezone: z.string().describe("The zone the day was anchored to."),
|
||||
bucketMinutes: z.number().int().describe("Width of one bucket."),
|
||||
series: z
|
||||
.array(
|
||||
z.object({
|
||||
startMinute: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("Minutes since local midnight at the bucket start."),
|
||||
mean: z.number(),
|
||||
count: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"Raw samples behind the mean. A bucket below the density floor is a GAP that breaks a candidate tension run, not a thin data point — a lone reading is a point, not a bucket.",
|
||||
),
|
||||
min: z
|
||||
.number()
|
||||
.optional()
|
||||
.describe("Bucket low, when the source carries a spread."),
|
||||
max: z.number().optional().describe("Bucket high, same condition."),
|
||||
}),
|
||||
)
|
||||
.describe("The day's shape, ascending."),
|
||||
baseline: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe("The resting baseline the day was judged against."),
|
||||
baselineSource: z
|
||||
.enum(["resting", "proxy", "none"])
|
||||
.describe(
|
||||
"`resting` from recorded resting heart rate, `proxy` when those rows are stale or absent and the pulse history stood in, `none` when no baseline could be formed.",
|
||||
),
|
||||
tension: z
|
||||
.object({
|
||||
startMinute: z.number().int(),
|
||||
endMinute: z.number().int(),
|
||||
partOfDay: z.enum(["morning", "afternoon", "evening", "night"]),
|
||||
meanHr: z.number(),
|
||||
baseline: z.number(),
|
||||
hrvConfirmed: z
|
||||
.boolean()
|
||||
.describe("True when intraday HRV independently confirmed it."),
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
"At most ONE cautious elevated-at-rest window, and only when every confidence gate holds — the bar is set high and under-flagging is the intended failure mode. ALWAYS null on an `hourly` day, because a tension read needs per-sample resolution.",
|
||||
),
|
||||
resolution: z
|
||||
.enum(["tenMin", "hourly"])
|
||||
.describe(
|
||||
"`tenMin` when the series was folded from live raw samples, `hourly` when the day fell outside the dense-retention window and the coarser folded tier answered instead. An older day reads back coarser rather than empty.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "IntradayPulseResponse",
|
||||
description:
|
||||
"One local day's intraday heart-rate shape, plus at most one cautious elevated-at-rest window. Computed from raw samples through the read-swap pattern rather than persisted as ten-minute rollups for all history. Awareness only, never a diagnosis.",
|
||||
});
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
intakeAggregateQuerySchema,
|
||||
intakeStatusUpdateSchema,
|
||||
glp1PostBodySchema,
|
||||
bulkDeleteIntakeEventsSchema,
|
||||
updateIntakeEventSchema,
|
||||
} from "@/lib/validations/medication";
|
||||
import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt";
|
||||
import {
|
||||
@@ -71,7 +73,41 @@ import {
|
||||
glp1DetailResponse,
|
||||
glp1DoseChange,
|
||||
glp1InventoryEvent,
|
||||
medicationApiEndpointState,
|
||||
medicationApiEndpointEnabled,
|
||||
medicationApiEndpointDisabled,
|
||||
reminderPhaseConfig,
|
||||
medicationSideEffect,
|
||||
} from "./schemas";
|
||||
import { phaseConfigSchema } from "@/lib/validations/phase-config";
|
||||
import {
|
||||
createSideEffectSchema,
|
||||
listSideEffectsSchema,
|
||||
} from "@/lib/medications/side-effects/validators";
|
||||
|
||||
const phaseConfigPutRequest = phaseConfigSchema.meta({
|
||||
id: "ReminderPhaseConfigPutRequest",
|
||||
description:
|
||||
"The complete phase configuration; every field is required, so this replaces rather than merges. Values are 0..1440 and each carries its own mode.",
|
||||
});
|
||||
|
||||
const createSideEffectRequest = createSideEffectSchema.meta({
|
||||
id: "CreateMedicationSideEffectRequest",
|
||||
description:
|
||||
"Log one side effect. `category` is NOT a field — the server derives it from `entry` through the authoritative taxonomy, and a category sent anyway is dropped rather than refused, so an older client cannot stamp a row with one that contradicts its entry. `occurredAt` defaults to now. `notes` is capped at 280 characters and is encrypted at rest.",
|
||||
});
|
||||
|
||||
const listSideEffectsQuery = listSideEffectsSchema.meta({
|
||||
id: "ListMedicationSideEffectsQuery",
|
||||
description:
|
||||
"Window and cap for the side-effect list. `from` is inclusive and `to` is EXCLUSIVE — an asymmetry worth reading before paging on it. `limit` is 1..200, default 50.",
|
||||
});
|
||||
|
||||
const bulkDeleteIntakeRequest = bulkDeleteIntakeEventsSchema.meta({
|
||||
id: "BulkDeleteMedicationIntakeRequest",
|
||||
description:
|
||||
"The intake events to tombstone, 1..500 ids. Ids that do not belong to this medication AND this record are silently DROPPED rather than refused, so a partial match succeeds with a lower `deleted` count than the ids sent.",
|
||||
});
|
||||
|
||||
// ── Bulk intake backfill (iOS SyncMode) — mirrors the route's
|
||||
// `bulkPayloadSchema` / `bulkEntrySchema` and the batch-envelope response.
|
||||
@@ -316,6 +352,512 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}/api-endpoint": {
|
||||
get: {
|
||||
tags: ["Medications"],
|
||||
summary: "Is the per-medication ingest endpoint enabled?",
|
||||
description:
|
||||
"Presence only: whether any live token carries this medication's ingest scope, and how many. No token, hash or prefix is on this wire — tokens are stored as an HMAC and there is no path back to the plaintext. Refused with 403 when the operator has switched the API off globally, which is checked BEFORE ownership, so that refusal is visible even for a medication the caller does not hold. NOT delegable: the caller is always resolved as themselves, which is right for a credential surface. Cookie or Bearer auth.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
"200": {
|
||||
description: "Whether the endpoint is on.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationApiEndpointState,
|
||||
"MedicationApiEndpointStateEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description: "The operator has disabled the API globally.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another account).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ["Medications"],
|
||||
summary: "Enable or disable the per-medication ingest endpoint",
|
||||
description:
|
||||
"Enabling mints an API token scoped to `medication:<id>:ingest` and returns the RAW token — the one and only time it is ever returned, because the stored value is an HMAC and cannot be reversed. Read `created` rather than inferring from the token: enabling an already-enabled endpoint answers 200 with `token: null` and `created: false` and mints nothing, so a lost token is recovered by disabling and enabling again, not by re-reading. Disabling revokes every live token carrying the scope and answers a DIFFERENT shape — `revokedTokenCount` instead of `activeTokenCount` — so one decoder for both outcomes will find the key missing. The body is read by hand rather than parsed: `enabled` is true only for the literal boolean, so any other value disables. Minted tokens carry no expiry. Both branches audit. Refused with 403 when the operator has switched the API off globally. NOT delegable. Cookie or Bearer auth.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z
|
||||
.object({
|
||||
enabled: z
|
||||
.boolean()
|
||||
.describe(
|
||||
'`true` — and only the literal boolean true — enables. Anything else, including the string `"true"` and an absent key, disables.',
|
||||
),
|
||||
})
|
||||
.meta({ id: "MedicationApiEndpointPutRequest" }),
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"Either the endpoint was disabled (`MedicationApiEndpointDisabled`), or it was already enabled and nothing was minted (`MedicationApiEndpointEnabled` with `token: null`).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.union([
|
||||
medicationApiEndpointDisabled,
|
||||
medicationApiEndpointEnabled,
|
||||
]),
|
||||
"MedicationApiEndpointPutEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"201": {
|
||||
description:
|
||||
"A token was minted. `token` carries the raw `hlk_` value — store it now, it is never shown again.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationApiEndpointEnabled,
|
||||
"MedicationApiEndpointCreatedEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"403": {
|
||||
description: "The operator has disabled the API globally.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another account).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"413": {
|
||||
description: "Request body exceeds 64 KiB.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"422": {
|
||||
description:
|
||||
"The body was not valid JSON. A single-message envelope with no issue list.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"401": stdResponses["401"],
|
||||
"429": stdResponses["429"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}/phase-config": {
|
||||
get: {
|
||||
tags: ["Medications"],
|
||||
summary: "Read a medication's reminder phase configuration",
|
||||
description:
|
||||
"How long each reminder phase runs for this medication. When no row is stored the DEFAULTS are returned rather than a 404 or a null — and the way to tell the two apart is that the defaults carry no `id` and no `medicationId`. Delegable at READ level over the `medications` section. Cookie or Bearer auth.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "The stored configuration, or the defaults.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
reminderPhaseConfig,
|
||||
"ReminderPhaseConfigEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another account).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ["Medications"],
|
||||
summary: "Replace a medication's reminder phase configuration",
|
||||
description:
|
||||
"Upserts the whole configuration — every field is required, so this replaces rather than merges. The stored row is built field by field from the parsed body rather than spread, so a future schema field cannot land here unnoticed. NOT delegable, unlike the GET beside it: the write resolves the caller as themselves, so a manager can read a shared record's phase configuration and cannot change it. Body capped at 64 KiB. Cookie or Bearer auth.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: phaseConfigPutRequest } },
|
||||
},
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The stored configuration, with its row id.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
reminderPhaseConfig,
|
||||
"ReminderPhaseConfigSavedEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another account).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Medications"],
|
||||
summary: "Reset a medication's reminder phases to the defaults",
|
||||
description:
|
||||
"Drops the stored row so the GET falls back to the defaults. Idempotent — resetting a medication that has no row succeeds and reports the same thing. NOT delegable, matching the PUT. Cookie or Bearer auth.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"Reset. The response confirms the write and does not echo the defaults.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ reset: z.literal(true) }),
|
||||
"ReminderPhaseConfigResetEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another account).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}/side-effects": {
|
||||
get: {
|
||||
tags: ["Medications"],
|
||||
summary: "List a medication's side-effect log",
|
||||
description:
|
||||
"The record's side-effect entries for this medication, newest first, optionally bounded by a window. `from` is inclusive and `to` is EXCLUSIVE. Notes are decrypted on read and the ciphertext column never leaves the server. Delegable at READ level over the `medications` section. Cookie or Bearer auth.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string() }),
|
||||
query: listSideEffectsQuery,
|
||||
},
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description:
|
||||
"The entries, plus a `meta.total` that counts THIS PAGE rather than the whole log — it is the returned array's length, so it can never exceed `limit`.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
items: z.array(medicationSideEffect),
|
||||
meta: z.object({ total: z.number().int().nonnegative() }),
|
||||
}),
|
||||
"ListMedicationSideEffectsEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another account).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Medications"],
|
||||
summary: "Log a side effect",
|
||||
description:
|
||||
"Records one entry against the medication. The category is derived server-side from `entry`, so a client cannot stamp a row with one that contradicts it. The note is encrypted at rest. Rate-limited 30/min keyed on the ACTOR, which is the condition this verb was admitted for delegation on: a delegate burns their own allowance rather than locking the owner out of their own log, and cannot collect a fresh one by switching records. Delegable at WRITE level over the `medications` section. Body capped at 64 KiB. Cookie or Bearer auth.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: createSideEffectRequest } },
|
||||
},
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"201": {
|
||||
description: "The created entry, with its note decrypted.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationSideEffect,
|
||||
"CreateMedicationSideEffectEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another account).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"429": {
|
||||
description:
|
||||
"More than 30 side-effect writes in the trailing minute for this ACTOR.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"401": stdResponses["401"],
|
||||
"422": stdResponses["422"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}/side-effects/{logId}": {
|
||||
delete: {
|
||||
tags: ["Medications"],
|
||||
summary: "Remove one side-effect entry",
|
||||
description:
|
||||
"A HARD delete — the row is removed, not tombstoned, and nothing on the sync feed carries the removal. It is allowed at any time rather than inside a retraction window, deliberately: a side-effect entry is not a clinical record, the account owns it, and a stale mis-entry should never be undeletable. What survives is the audit row, which carries the entry, the severity and the date it happened, so the deletion is reconstructable from the trail even though the row is gone. Delegable at MANAGE level over the `medications` section. Cookie or Bearer auth.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string(), logId: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "The entry was deleted.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ id: z.string(), deleted: z.literal(true) }),
|
||||
"DeleteMedicationSideEffectEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"No such entry for this medication and this record. An unknown id, another medication's entry and another record's entry are indistinguishable.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}/intake/{eventId}": {
|
||||
put: {
|
||||
tags: ["Medications"],
|
||||
summary: "Correct one dose",
|
||||
description:
|
||||
"Edits a single intake event. Whenever `takenAt` or `skipped` changes, slot attribution is re-run and `scheduledFor` snaps to the matched window — or to the take's own time when it falls in none. `forceSlotInstant` overrides that with a named real slot of this medication; an explicit `null` UNPINS and re-attributes by band, resetting the binding provenance to automatic. A correction that lands on a DIFFERENT slot is not an in-place edit: the original row is tombstoned and the dose is re-created on the corrected slot, so the row id in the response can differ from the `eventId` in the path, and the tombstone rides the sync feed. A `takenAt` before the medication's start date is refused. Tombstoned events 404 rather than resurrect. Delegable at MANAGE level over the `medications` section. Body capped at 64 KiB. Cookie or Bearer auth.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string(), eventId: z.string() }),
|
||||
},
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: updateIntakeEventSchema } },
|
||||
},
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description:
|
||||
"The corrected event. On a slot move this is the RE-CREATED row, whose id differs from the one in the path.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
medicationIntakeEventResource,
|
||||
"UpdateMedicationIntakeEventEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"No live event with that id under this medication and record. An unknown id and an already-tombstoned one are indistinguishable.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"422": {
|
||||
description:
|
||||
"Body validation failed (multi-issue), or one of three coded refusals: `medications.intake.taken_at.before_start` when the edited take predates the medication's start day in the record's timezone, `medications.intake.force_slot.invalid` when the pinned instant is not a real slot, and `medications.intake.force_slot.occupied` when it is a real slot that another live dose already holds.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"401": stdResponses["401"],
|
||||
"429": stdResponses["429"],
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Medications"],
|
||||
summary: "Undo one dose",
|
||||
description:
|
||||
"A SOFT delete: the row stays, `deletedAt` is stamped and the sync counter bumps, so a client that was offline when it happened receives a tombstone keyed on the server id rather than silently keeping the dose forever. Every today, compliance and list read filters tombstones out, so it is invisible from here on. The inventory stamp is REFUNDED — undoing a dose puts the units back in the container — and the day's compliance rollup is recomputed, dropping the row entirely when the day now holds nothing. Undoing the single live dose of a one-shot medication reactivates that medication so the lists and the worker pick it back up. Re-deleting is harmless. Delegable at MANAGE level over the `medications` section. Cookie or Bearer auth.",
|
||||
requestParams: {
|
||||
path: z.object({ id: z.string(), eventId: z.string() }),
|
||||
},
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "The dose was tombstoned and its inventory refunded.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ deleted: z.literal(true) }),
|
||||
"DeleteMedicationIntakeEventEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description:
|
||||
"No event with that id under this medication and record.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}/intake/bulk-delete": {
|
||||
post: {
|
||||
tags: ["Medications"],
|
||||
summary: "Undo several doses at once",
|
||||
description:
|
||||
"Tombstones up to 500 named intake events in one call, so a multi-select in the history preview does not fire N concurrent deletes. Ids are matched against THIS medication and THIS record; anything else is silently dropped rather than refused, so a partial match succeeds with a lower `deleted` count than the number of ids sent, and an id leaked from another medication's history deletes nothing. Every stamped row's inventory consumption is refunded before the sweep, and the compliance rollup is recomputed once per affected day rather than once per event. A POST rather than a DELETE because the ids ride a body. Re-posting already-tombstoned ids counts zero. Rate-limited 30/min keyed on the ACTOR. Delegable at MANAGE level over the `medications` section. Body capped at 256 KiB. Cookie or Bearer auth.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { "application/json": { schema: bulkDeleteIntakeRequest } },
|
||||
},
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description:
|
||||
"`deleted` is the REAL tombstone count, which can be lower than the ids sent — and is 0 when none matched, which is a success rather than a 404.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({ deleted: z.number().int().nonnegative() }),
|
||||
"BulkDeleteMedicationIntakeEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another account).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"429": {
|
||||
description:
|
||||
"More than 30 bulk deletes in the trailing minute for this ACTOR.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"401": stdResponses["401"],
|
||||
"422": stdResponses["422"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}/intake/purge": {
|
||||
delete: {
|
||||
tags: ["Medications"],
|
||||
summary: "Clear a medication's whole intake history",
|
||||
description:
|
||||
"Tombstones EVERY live intake event for this medication in one call, and deletes its compliance rollup rows outright. The rollups are the only hard deletion and they are recomputable, so nothing that cannot be rebuilt is destroyed: the intake rows stay with `deletedAt` set, which is what lets the sync feed emit a tombstone per row to a client that was offline. That was not always true — this was the one destructive verb in the family with nothing to reconstruct from, and a purge fired by mistake was final and invisible to sync. Deliberately does NOT refund inventory, unlike the single and bulk deletes: those mean `I did not take that dose` and put the units back, while a purge wipes a history as bookkeeping, and refunding every dose ever taken would inflate the current container count instead. A second purge counts zero. There is no confirmation step on the wire. Delegable at MANAGE level over the `medications` section. Cookie or Bearer auth.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"200": {
|
||||
description: "`count` is how many live events were tombstoned.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
purged: z.literal(true),
|
||||
count: z.number().int().nonnegative(),
|
||||
}),
|
||||
"PurgeMedicationIntakeEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another account).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/{id}/intake/import": {
|
||||
post: {
|
||||
tags: ["Medications"],
|
||||
summary: "Import a dose history for one medication",
|
||||
description:
|
||||
"Accepts 1..1000 `{ datum, uhrzeit }` rows and enqueues a background import, answering 202 with a job id and the URL to poll. The body may be the bare array or an object carrying one — the first array-valued property is taken. Identity comes from the INSTANT, which is also the grain the database enforces, so a re-import of the same file dedups. It did not always: an optional third field decided the replay key while presenting itself as a quantity, and a file that repeated one value down that column collapsed a month of history onto a single key. The field is gone from the contract rather than accepted-and-ignored, and a payload still carrying it imports unchanged. Timestamps are bounded on the FUTURE side only — importing years of history is the point, so the past side is open back to 1900. Only one import may run per medication at a time. Rate-limited 60/min keyed on the ACTOR. Delegable at MANAGE level over the `medications` section: the import is additive with duplicates skipped, every written row tombstones, and the `IMPORT` provenance it stamps stays true under delegation because it describes where the data came from and not who typed it. Body capped at 1 MiB. Cookie or Bearer auth.",
|
||||
requestParams: { path: z.object({ id: z.string() }) },
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: z
|
||||
.array(
|
||||
z.object({
|
||||
datum: z
|
||||
.string()
|
||||
.describe("`YYYY-MM-DD`. Strict; no other form parses."),
|
||||
uhrzeit: z
|
||||
.string()
|
||||
.describe("`HH:MM:SS`. Strict; no other form parses."),
|
||||
}),
|
||||
)
|
||||
.min(1)
|
||||
.max(1000)
|
||||
.meta({
|
||||
id: "MedicationIntakeImportRequest",
|
||||
description:
|
||||
"The rows to import. May also be sent wrapped in an object, in which case the first array-valued property is used. Unrecognised per-row keys are dropped rather than refused. The pair must be a real calendar instant — 31 February is refused — and must not be in the future.",
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
...recordRefusal(),
|
||||
"202": {
|
||||
description:
|
||||
"Accepted and enqueued. Nothing is imported yet: poll `statusUrl` for progress and the per-row skip reasons.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
z.object({
|
||||
jobId: z.string(),
|
||||
status: z.literal("queued"),
|
||||
statusUrl: z.string(),
|
||||
}),
|
||||
"MedicationIntakeImportAcceptedEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
"409": {
|
||||
description:
|
||||
"An import for this medication is already running. Wait for it rather than retrying.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"422": {
|
||||
description:
|
||||
"The payload did not parse. Multi-issue envelope with `meta.errorCode` = `medication.intake.import.invalid_format`.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"429": {
|
||||
description:
|
||||
"More than 60 import submissions in the trailing minute for this ACTOR.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"503": {
|
||||
description:
|
||||
"The background worker is not available, so nothing was enqueued. Retry later.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"404": {
|
||||
description: "Medication not found (or owned by another account).",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
"401": stdResponses["401"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/medications/intake/bulk": {
|
||||
post: {
|
||||
parameters: [idempotencyKeyParameter],
|
||||
|
||||
@@ -1438,3 +1438,123 @@ export const glp1InventoryEvent = z
|
||||
description:
|
||||
"A row of the DEPRECATED running-sum inventory ledger. Register containers through the inventory endpoints instead; reads fall back to this ledger only while a medication has no containers at all.",
|
||||
});
|
||||
|
||||
// ── Per-medication ingest endpoint (`/api/medications/{id}/api-endpoint`) ──
|
||||
//
|
||||
// The one place in this module where a raw credential can leave the server,
|
||||
// and it leaves EXACTLY once: on the PUT that mints it. The GET answers
|
||||
// presence and a count and nothing else, and a re-enable of an already-enabled
|
||||
// endpoint answers `token: null` rather than re-issuing — the plaintext is
|
||||
// hashed at rest and cannot be recovered, so a lost token means minting a new
|
||||
// one by disabling and enabling again.
|
||||
|
||||
export const medicationApiEndpointState = z
|
||||
.object({
|
||||
enabled: z
|
||||
.boolean()
|
||||
.describe("True when at least one live token carries this scope."),
|
||||
activeTokenCount: z
|
||||
.number()
|
||||
.int()
|
||||
.describe(
|
||||
"Live, unrevoked, unexpired tokens scoped to this medication. Normally 0 or 1.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "MedicationApiEndpointState",
|
||||
description:
|
||||
"Presence only. No token, hash or prefix is on this wire — the stored value is an HMAC and there is no path back to the plaintext.",
|
||||
});
|
||||
|
||||
export const medicationApiEndpointEnabled = z
|
||||
.object({
|
||||
enabled: z.literal(true),
|
||||
activeTokenCount: z.number().int(),
|
||||
token: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The raw `hlk_<64 hex>` token, and the ONLY time it is ever returned. Null when the endpoint was already enabled and nothing was minted — check `created` rather than inferring from this. Store it on receipt; it is hashed at rest and cannot be shown again.",
|
||||
),
|
||||
created: z
|
||||
.boolean()
|
||||
.describe("True when this call minted a token, false when one existed."),
|
||||
})
|
||||
.meta({
|
||||
id: "MedicationApiEndpointEnabled",
|
||||
description:
|
||||
"The answer to enabling the endpoint. A fresh mint is 201 with the token; an already-enabled endpoint is 200 with `token: null` and `created: false`.",
|
||||
});
|
||||
|
||||
export const medicationApiEndpointDisabled = z
|
||||
.object({
|
||||
enabled: z.literal(false),
|
||||
revokedTokenCount: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("How many live tokens this call revoked."),
|
||||
})
|
||||
.meta({
|
||||
id: "MedicationApiEndpointDisabled",
|
||||
description:
|
||||
"The answer to disabling. Note the asymmetry: this shape carries `revokedTokenCount` and NOT `activeTokenCount`, so a client decoding one shape for both PUT outcomes will find the key missing.",
|
||||
});
|
||||
|
||||
// ── Reminder phase config (`/api/medications/{id}/phase-config`) ──────
|
||||
|
||||
export const reminderPhaseConfig = z
|
||||
.object({
|
||||
id: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Row id. ABSENT when the read fell through to defaults, because no row exists — which is how a client tells a stored configuration from the fallback.",
|
||||
),
|
||||
medicationId: z.string().optional().describe("Absent on the defaults."),
|
||||
greenValue: z.number().int(),
|
||||
greenMode: z.enum(["MINUTES", "PERCENT"]),
|
||||
yellowValue: z.number().int(),
|
||||
yellowMode: z.enum(["MINUTES", "PERCENT"]),
|
||||
orangeValue: z.number().int(),
|
||||
orangeMode: z.enum(["MINUTES", "PERCENT"]),
|
||||
redValue: z.number().int(),
|
||||
redMode: z.enum(["MINUTES", "PERCENT"]),
|
||||
})
|
||||
.meta({
|
||||
id: "ReminderPhaseConfig",
|
||||
description:
|
||||
"How long each reminder phase runs for one medication. `MINUTES` is an absolute offset; `PERCENT` is a share of the dose window. Values are 0..1440.",
|
||||
});
|
||||
|
||||
// ── Side effects (`/api/medications/{id}/side-effects`) ───────────────
|
||||
|
||||
export const medicationSideEffect = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
userId: z
|
||||
.string()
|
||||
.describe(
|
||||
"The RECORD owner, which is not the actor on a delegated write.",
|
||||
),
|
||||
medicationId: z.string(),
|
||||
occurredAt: z.iso.datetime({ offset: true }),
|
||||
category: z
|
||||
.string()
|
||||
.describe(
|
||||
"Derived SERVER-side from `entry` through the authoritative taxonomy. Never accepted from a client — a category sent on the wire is dropped, so a row cannot be stamped with one that contradicts its entry.",
|
||||
),
|
||||
entry: z.string().describe("The catalogue entry. Closed Prisma enum."),
|
||||
severity: z.number().int().min(1).max(5),
|
||||
notes: z
|
||||
.string()
|
||||
.nullable()
|
||||
.describe(
|
||||
"The free-text note, decrypted on read. Stored encrypted; the ciphertext column is stripped before the row leaves the server.",
|
||||
),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
})
|
||||
.meta({
|
||||
id: "MedicationSideEffect",
|
||||
description:
|
||||
"One logged side effect. Not a clinical record — the account owns it, and it stays deletable at any time rather than locking after a retraction window.",
|
||||
});
|
||||
@@ -1163,7 +1163,81 @@ const moodAggregatesResponse = z
|
||||
"The pre-computed Mood Insights bundle: headline summary, heatmap, distribution, per-dimension summaries, weekday and time-of-day patterns, stability, tag boards, context and crosstab comparisons, narratives and cross-metric correlations. Everything is observational — comparisons and correlations, never a cause and never a verdict. Served through a 60 s stale-while-revalidate cache; a mood write marks the cell so the account's own entry lands on the next read.",
|
||||
});
|
||||
|
||||
const moodDailySeriesResponse = z
|
||||
.object({
|
||||
entries: z
|
||||
.array(
|
||||
z.object({
|
||||
date: z.string().describe("`YYYY-MM-DD` day key."),
|
||||
score: z
|
||||
.number()
|
||||
.describe("The day's mean pleasantness on the 1..5 scale."),
|
||||
samples: z.number().int().describe("Entries behind the mean."),
|
||||
}),
|
||||
)
|
||||
.describe("One entry per day that carries a mood, oldest first."),
|
||||
summary: z
|
||||
.object({
|
||||
count: z.number().int(),
|
||||
latest: z.number().nullable(),
|
||||
min: z.number().nullable(),
|
||||
max: z.number().nullable(),
|
||||
mean: z.number().nullable(),
|
||||
median: z.number().nullable(),
|
||||
avg7: z.number().nullable(),
|
||||
avg30: z.number().nullable(),
|
||||
slope7: z
|
||||
.object({
|
||||
slope: z.number(),
|
||||
direction: z.enum(["up", "down", "stable"]),
|
||||
confidence: z.number(),
|
||||
})
|
||||
.nullable(),
|
||||
slope30: z
|
||||
.object({
|
||||
slope: z.number(),
|
||||
direction: z.enum(["up", "down", "stable"]),
|
||||
confidence: z.number(),
|
||||
})
|
||||
.nullable(),
|
||||
anomalyCount: z.number().int().optional(),
|
||||
avg30LastMonth: z.number().nullable().optional(),
|
||||
avg30LastYear: z.number().nullable().optional(),
|
||||
})
|
||||
.describe(
|
||||
"The shared summary shape computed over the DAILY MEANS, not over the raw entries — so `count` is the number of days with a mood, not the number of moods logged. Every statistic is null on an empty series rather than 0.",
|
||||
),
|
||||
})
|
||||
.meta({
|
||||
id: "MoodDailySeries",
|
||||
description:
|
||||
"The canonical daily mood series. `entryCount` and the rollup-versus-live `source` the builder also resolves are annotated for observability and deliberately NOT on this wire — the two keys below are the whole payload.",
|
||||
});
|
||||
|
||||
export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"/api/mood/analytics": {
|
||||
get: {
|
||||
tags: ["Mood"],
|
||||
summary: "The canonical daily mood series",
|
||||
description:
|
||||
"One series, read by the mood page sparkline, the dashboard tile and the native client alike, so all three render the same number — the dashboard snapshot resolves it through the same builder rather than aggregating again. Answered from the mood rollup tier with a live fallback on a coverage miss; which tier answered is annotated for operators and is not on the wire. Plain read-through cache (NOT stale-while-revalidate, unlike the insights sibling). Module-gated on `mood`; a disabled module answers 403 `module.disabled` even for a valid Bearer token. Cookie or Bearer auth; the caller is always resolved as themselves, so this read cannot be delegated to a shared record.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The daily series and its summary.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
moodDailySeriesResponse,
|
||||
"MoodDailySeriesEnvelope",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...moduleDisabledResponse,
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/mood/insights": {
|
||||
get: {
|
||||
tags: ["Mood"],
|
||||
|
||||
Reference in new issue
Block a user