Files
HealthLog/src/lib/openapi/routes/coach.ts
T

1364 lines
54 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* OpenAPI route table — insights layout, coach facts, about-me, chat message feedback.
*
* Part of the OpenAPI route table; aggregated in `./index.ts`.
* Schemas come from `src/lib/validations/*` where shared with the
* runtime request parsing, so the wire contract stays single-source.
*/
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { aboutMePutSchema } from "@/lib/validations/about-me";
import {
ACCEPTED_INSIGHTS_TILE_IDS,
INSIGHTS_SECTION_IDS,
} from "@/lib/insights-layout";
import { COACH_FACT_CATEGORIES } from "@/lib/ai/coach/facts";
import { COACH_PLAN_STATUSES } from "@/lib/ai/coach/plans";
import {
coachPlanPatchSchema,
coachPlansListQuerySchema,
} from "@/lib/validations/coach-plan";
import { coachChatRequestSchema } from "@/lib/ai/coach/types";
import { exportSelectionSchema } from "@/lib/validations/health-record-export";
import { createShareLinkSchema } from "@/lib/validations/clinician-share-link";
import { coachReminderSuggestionActionSchema } from "@/lib/validations/coach-reminder-suggestion";
import { COACH_REMINDER_STATUSES } from "@/lib/ai/coach/reminders";
import {
coachReminderCreateSchema,
coachReminderPatchSchema,
coachSuggestedActionSchema,
} from "@/lib/validations/coach-reminder";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
// ── Coach cadence suggestions (v1.18.1) ──────────────────────────────
// The action endpoint behind the one-tap reminder-suggestion card. The
// client sends ONLY the cadence id + the action; the server resolves the
// metric + schedule + course window from the closed catalog and (for
// `accept`) mints a `MeasurementReminder` with `origin: COACH`.
coachReminderSuggestionActionSchema.meta({
id: "CoachReminderSuggestionAction",
description:
"v1.18.1 — act on a Coach cadence suggestion. `cadenceId` names a closed-catalog preset (e.g. `weight_daily`, `bp_7_2_2`); the client never sends a schedule. `action`: `accept` creates a `MeasurementReminder` (origin: COACH) through the same engine the Vorsorge surface uses; `dismiss` records dismissal memory; `stop` suppresses all future cadence suggestions. Strict: unknown keys 422.",
});
const coachReminderSuggestionResultSchema = z
.object({
ok: z.literal(true),
action: z.enum(["accept", "dismiss", "stop"]),
reminder: z
.unknown()
.nullable()
.optional()
.describe(
"On `accept` of a fresh suggestion: the created MeasurementReminder DTO. Null when the action was a prefs-only dismiss/stop, or when an accept hit the structural dedup (`duplicate: true`).",
),
duplicate: z
.boolean()
.optional()
.describe(
"True when an `accept` matched an already-live COACH reminder for the same metric (idempotent no-op).",
),
})
.meta({
id: "CoachReminderSuggestionResult",
description:
"Outcome of a Coach cadence-suggestion action. `accept` returns 201 with the created reminder (or 200 + `duplicate: true` when one already exists); `dismiss`/`stop` return 200.",
});
export const coachReminderSuggestionPaths: NonNullable<
ZodOpenApiObject["paths"]
> = {
"/api/coach/reminder-suggestions": {
post: {
tags: ["Insights"],
summary: "Act on a Coach cadence suggestion",
description:
'v1.18.1 — accept / dismiss / stop a Coach-proposed measurement cadence. Coach-gated (`requireModuleEnabled("coach")`); a disabled surface 403s. `accept` resolves the cadence from the closed server-side catalog and creates a `MeasurementReminder` with `origin: COACH` (201), or returns 200 with `duplicate: true` when a live COACH reminder for that metric already exists — idempotent against a re-tapped or stale card, with a partial unique index as the structural backstop. Per-user rate-limited (429 on excess). Auth via cookie or Bearer; the owner is narrowed from the session.',
requestBody: {
required: true,
content: {
"application/json": { schema: coachReminderSuggestionActionSchema },
},
},
responses: {
"200": {
description:
"A dismiss/stop, or an accept that matched an existing reminder (`duplicate: true`).",
content: {
"application/json": {
schema: dataEnvelope(
coachReminderSuggestionResultSchema,
"CoachReminderSuggestionResultOk",
),
},
},
},
"201": {
description: "An accept that created a new reminder.",
content: {
"application/json": {
schema: dataEnvelope(
coachReminderSuggestionResultSchema,
"CoachReminderSuggestionResultCreated",
),
},
},
},
"403": {
description: "Coach surface (or the cadence's module) disabled.",
content: { "application/json": { schema: errorEnvelope } },
},
// 401 / 422 / 429 come from stdResponses.
...stdResponses,
},
},
},
};
const coachMessageFeedbackBody = z
.object({
rating: z.enum(["helpful", "unhelpful"]),
reason: z.string().min(1).max(200).optional(),
})
.meta({
id: "CoachMessageFeedbackRequest",
description:
"Per-message helpful/unhelpful feedback (v1.4.23 H7). Optional `reason` is free-form prose, capped at 200 chars.",
});
// Insights tile layout — mirrors the Zod schema in
// `src/app/api/insights/layout/route.ts`. The tile-id enum is derived
// from the same `ACCEPTED_INSIGHTS_TILE_IDS` source so the contract
// cannot drift.
//
// v1.8.0 — the canonical ids are English (`blood-pressure`, `pulse`,
// `oxygen`, `body-temperature`, `weight`, `active-energy`, `sleep`,
// `resting-pulse`, `mood`, `medications`). The endpoint still ACCEPTS
// the legacy German ids (`blutdruck`, `puls`, `sauerstoff`,
// `koerpertemperatur`, `gewicht`, `aktive-energie`, `schlaf`,
// `ruhepuls`, `stimmung`, `medikamente`) on input so existing iOS
// layouts keep validating; the server normalises them to the canonical
// English id before persisting, and GET always returns canonical ids.
// The legacy ids are accepted-but-deprecated and will be dropped from
// the accepted set in a future major.
// v1.15.11 — layout v2 adds an optional `sections` array on top of the
// per-metric `tiles` list so the overview's big semantic blocks can be
// reordered/hidden in their own right. `sections` is additive and
// optional: a client PUTting only `tiles` (the pre-v2 iOS contract) still
// validates, and a v1 blob resolves forward to a valid v2 layout with all
// sections default-visible. Section ids are English from birth — no
// legacy-alias widening. `tiles` is likewise optional now (a section-only
// PUT fills the default tile set), but must carry at least one entry when
// present.
const insightsLayoutSchema = z
.object({
// v1.15.11 QA C1 — both v1 and v2 are accepted on input. The live iOS
// client still PUTs `version: 1`; the server normalises to the canonical
// v2 blob on persist, so a v1 body never 422s on the layout-schema bump.
version: z.union([z.literal(1), z.literal(2)]),
sections: z
.array(
z.object({
id: z.enum(INSIGHTS_SECTION_IDS),
visible: z.boolean(),
order: z.number().int().min(0).max(99),
}),
)
.max(50)
.optional(),
tiles: z
.array(
z.object({
id: z.enum(ACCEPTED_INSIGHTS_TILE_IDS),
visible: z.boolean(),
order: z.number().int().min(0).max(99),
}),
)
.min(1)
.max(50)
.optional(),
})
.meta({
id: "InsightsLayoutBody",
description:
"Per-user Insights layout (v2). `tiles` is the per-metric pill list (ordered, with a visibility flag); `sections` is the additive v2 list of the overview's big semantic blocks (wellness-scores, daily-briefing, vitals, trends, period-review, cycle-summary, signals, rhythm-events), each with order + visibility. `version` is the layout schema version: BOTH 1 and 2 are accepted on input (a pre-v2 iOS client still PUTs `version: 1`); the server always normalises to the canonical v2 blob before persisting, and GET responses always carry `version: 2`. Both arrays are optional on input — a client sending only `tiles` (the pre-v2 contract) still validates, and the server fills missing defaults; a v1 blob with no `sections` resolves forward with all sections default-visible. Tile ids are a closed enum: the canonical ids are English (matching the routed `/insights/<slug>` sub-pages). The legacy German tile ids (blutdruck, puls, sauerstoff, koerpertemperatur, gewicht, aktive-energie, schlaf, ruhepuls, stimmung, medikamente) remain accepted on input for backward compatibility and are normalised to their English equivalents before persisting; GET responses always carry the canonical English ids. Section ids are English-only. The legacy tile ids are deprecated and will be removed in a future major version.",
});
// v1.7.0 — health-record export selection. Strict shape: unknown keys
// (including any attempt to smuggle a userId) 422 via returnAllZodIssues.
// v1.11.0 — clinician share-link create payload. Strict; no `userId` field
// (the owner is always narrowed from the session/Bearer). `expiresAt` is
// required and capped at SHARE_LINK_MAX_DAYS; the scope columns are frozen
// write-once at creation.
createShareLinkSchema.meta({
id: "CreateShareLinkRequest",
description:
"v1.11.0 — owner request to mint a clinician share link to their own health record. `expiresAt` is required (absolute ISO instant) and capped at 90 days. `rangeStart`/`rangeEnd` freeze the reporting window (rangeEnd null = rolling). `resourceTypes` scopes the FHIR resources the link may serve; `allowFhirApi` toggles REST reachability. Strict: unknown keys 422.",
});
exportSelectionSchema.meta({
id: "HealthRecordExportRequest",
description:
"v1.7.0 — health-record / doctor-handover export selection. `format` picks PDF, FHIR R4 document Bundle, or a combined zip package. Grouped `sections` toggles drive which domains are read (mood is opt-in, off by default). No `userId` field — the user is always narrowed from the session/Bearer. The route is strict: unknown keys 422.",
});
// ── Coach facts (v1.11.1) ────────────────────────────────────────────
// Read + delete surface for the durable facts the Coach extracts. Facts
// are server-extracted, not user-authored, so there is no create/update
// shape — only list, bulk-clear, and single-delete responses.
const coachFactItem = z.object({
id: z.string(),
category: z
.enum(COACH_FACT_CATEGORIES)
.describe(
"App-side closed category: preference | condition | goal | constraint | context.",
),
text: z.string().describe("Decrypted fact text."),
confidence: z
.number()
.int()
.describe("0..100 server-assigned extraction confidence."),
createdAt: z.iso.datetime({ offset: true }),
});
const coachFactsListResponse = z.object({
facts: z
.array(coachFactItem)
.describe(
"The caller's active facts, highest-confidence then newest first. Undecryptable rows are omitted.",
),
});
const coachFactsClearedResponse = z.object({
cleared: z
.number()
.int()
.describe("Number of active facts soft-deleted by the bulk clear."),
});
const coachFactDeletedResponse = z.object({
deleted: z
.boolean()
.describe(
"True when a fact owned by the caller was soft-deleted; false for an unknown / cross-user / already-deleted id (idempotent no-op).",
),
});
// ── Coach plans (v1.21.3 B1) ─────────────────────────────────────────
// The durable goal / if-then plans the Coach proposes and the user
// confirms. The extractor writes a plan as `proposed`; the PATCH below is
// the only path that activates it. The list/PATCH never accept the metric
// or the encrypted free text — a client can only confirm / change a plan's
// lifecycle, never inject or overwrite its prose.
const coachPlanItem = z
.object({
id: z.string(),
metric: z
.string()
.describe("The metric this plan moves (e.g. WEIGHT, SLEEP)."),
ifCue: z
.string()
.nullable()
.describe(
"Decrypted if-cue (the trigger). Null when the row's key id is no longer in the map.",
),
thenAction: z
.string()
.nullable()
.describe(
"Decrypted then-action. Null when the row's key id is no longer in the map.",
),
target: z
.string()
.nullable()
.describe("Decrypted optional target; null when none / undecryptable."),
status: z
.enum(COACH_PLAN_STATUSES)
.describe("App-side lifecycle: proposed | active | met | abandoned."),
reviewDate: z.iso
.datetime({ offset: true })
.nullable()
.describe("Optional check-in checkpoint; null when none."),
createdAt: z.iso.datetime({ offset: true }),
updatedAt: z.iso.datetime({ offset: true }),
})
.meta({
id: "CoachPlan",
description:
"One Coach goal / if-then plan, decrypted server-side. The free-text fields read null only when the encryption key id has rotated out of the map.",
});
const coachPlansListResponse = z.object({
plans: z
.array(coachPlanItem)
.describe(
"The caller's plans, newest first. Undecryptable rows are omitted from the list endpoint. No `status` filter returns the non-terminal set (proposed + active).",
),
});
const coachPlanUpdatedResponse = z.object({
plan: coachPlanItem.describe("The plan after the lifecycle update."),
});
const coachPlanDeletedResponse = z.object({
deleted: z
.boolean()
.describe(
"True when a plan owned by the caller was soft-deleted; false for an unknown / cross-user / already-deleted id (idempotent no-op).",
),
});
coachPlanPatchSchema.meta({
id: "CoachPlanPatchRequest",
description:
"v1.21.3 — confirm or update a Coach plan's lifecycle. `status` moves a `proposed` plan to `active` (confirm), or to `met` / `abandoned`. `reviewDate` pins (ISO instant) or clears (null) a check-in checkpoint. At least one field is required. Strict: unknown keys 422 — the body can never carry the metric, the encrypted text, or a userId.",
});
coachPlansListQuerySchema.meta({
id: "CoachPlansListQuery",
description:
"Optional `?status=` filter for the plans list (proposed | active | met | abandoned). Omitted returns the non-terminal set (proposed + active).",
});
// ── Coach episodic reminders (v1.22 B2/B6) ──────────────────────────────
const coachReminderItem = z
.object({
id: z.string(),
note: z
.string()
.nullable()
.describe(
"Decrypted note (the user's own framing). Null when the row's key id is no longer in the map.",
),
metric: z.string().nullable(),
triggerKind: z.enum(["date", "context"]),
dueAt: z.iso.datetime({ offset: true }).nullable(),
contextCue: z.string().nullable(),
status: z.enum(COACH_REMINDER_STATUSES),
source: z.enum(["sentinel", "extractor", "manual", "action"]),
createdAt: z.iso.datetime({ offset: true }),
updatedAt: z.iso.datetime({ offset: true }),
})
.meta({
id: "CoachReminder",
description:
"One Coach episodic reminder, decrypted server-side. The note reads null only when the encryption key id has rotated out of the map.",
});
const coachRemindersListResponse = z.object({
reminders: z
.array(coachReminderItem)
.describe(
"The caller's reminders, soonest-due first. Undecryptable rows are omitted. No `status` filter returns the non-terminal set (proposed + active + due + surfaced).",
),
});
const coachReminderCreatedResponse = z.object({
reminder: coachReminderItem,
});
const coachReminderUpdatedResponse = z.object({
reminder: coachReminderItem,
});
const coachReminderDeletedResponse = z.object({
deleted: z
.boolean()
.describe(
"True when a reminder owned by the caller was soft-deleted; false for an unknown / cross-user / already-deleted id (idempotent no-op).",
),
});
const coachSuggestedActionResultResponse = z
.object({
ok: z.literal(true),
actionType: z.enum(["checkup.create", "reminder.note"]),
reminder: z.unknown().optional(),
reminderId: z.string().optional(),
})
.meta({
id: "CoachSuggestedActionResult",
description:
"Outcome of confirming a Coach action card. `checkup.create` returns the created MeasurementReminder DTO; `reminder.note` returns the created CoachReminder id.",
});
// The request bodies carry their `.meta({ id })` from
// `@/lib/validations/coach-reminder` (single-source with the runtime parse).
// ── Coach conversation history (v1.18.0) ─────────────────────────────
// List + detail + delete surface for the Coach's persisted chat
// conversations. Bodies are stored encrypted at rest; the detail
// endpoint decrypts every message server-side, so the client never
// handles a key. The list endpoint stays metadata-only (no decryption).
const coachProvenanceSchema = z
.object({
windows: z
.array(
z.enum([
"last7days",
"last30days",
"last90days",
"lastYear",
"allTime",
]),
)
.describe("Analysis windows the assistant drew on this turn."),
metrics: z
.array(z.string())
.describe(
"Stable metric-topic keys referenced (e.g. bp, weight, sleep, glucose). `general` is the empty-snapshot sentinel. The client translates these labels; the server never localises them.",
),
counts: z
.record(z.string(), z.number().int())
.optional()
.describe(
"Per-metric sample-count summary; absent on an empty snapshot.",
),
keyValues: z
.array(
z.object({
label: z.string(),
value: z.string(),
unit: z.string().optional(),
window: z.string().optional(),
}),
)
.optional()
.describe(
"Load-bearing numbers the assistant surfaced, rendered in the collapsible evidence block. Hard-capped at 8 entries.",
),
toolCalls: z
.array(
z.object({
name: z.string(),
present: z.boolean(),
}),
)
.optional()
.describe(
"v1.20.0 — the retrieval-tool trace for this turn: which tools the Coach called and whether each found data. Metadata only (no values). Absent on the legacy snapshot path and on turns that called no tools.",
),
})
.meta({
id: "CoachProvenance",
description:
"Provenance envelope attached to an assistant message — labels and counts only, plus the optional evidence key-values. No raw timestamps.",
});
const coachMessageSchema = z
.object({
id: z.string(),
role: z.enum(["user", "assistant"]),
content: z
.string()
.describe("Decrypted message body — the server decrypts on read."),
createdAt: z.iso.datetime({ offset: true }),
metricSource: coachProvenanceSchema
.nullable()
.describe("Provenance envelope (assistant turns); null for user turns."),
providerType: z
.string()
.nullable()
.describe(
"Provider that produced the reply (e.g. anthropic, openai, local, refusal); null for user turns.",
),
promptVersion: z
.string()
.nullable()
.describe(
"Coach prompt version that produced the reply; null for user turns.",
),
tokensUsed: z
.number()
.int()
.nullable()
.describe(
"v1.18.9 — total tokens this assistant turn cost, persisted so the per-message token footer survives a reload. Null on user turns, refusals, and pre-feature rows.",
),
model: z
.string()
.nullable()
.describe(
"v1.18.9 — the provider model that produced the reply (e.g. gpt-4o). Null when unknown (user turns, refusals, older rows).",
),
})
.meta({
id: "CoachMessage",
description:
"One Coach chat message, decrypted server-side. Ordered oldest-first within a conversation.",
});
const coachConversationSchema = z
.object({
id: z.string(),
title: z.string().describe("Title summarised from the first user message."),
createdAt: z.iso.datetime({ offset: true }),
updatedAt: z.iso
.datetime({ offset: true })
.describe("Bumped on every appended message; the rail orders by this."),
messageCount: z.number().int(),
})
.meta({
id: "CoachConversation",
description:
"Lightweight conversation metadata for the history rail. No message bodies — the rail does not decrypt.",
});
const coachConversationsPageSchema = z
.object({
conversations: z.array(coachConversationSchema),
nextCursor: z
.string()
.nullable()
.describe(
"Id of the last conversation on this page; pass it back as `cursor` for the next page. Null at the end of the list.",
),
})
.meta({
id: "CoachConversationsPage",
description:
"Cursor-paginated page of the caller's Coach conversations, most-recent activity first.",
});
const coachConversationDetailSchema = z
.object({
id: z.string(),
title: z.string(),
createdAt: z.iso.datetime({ offset: true }),
updatedAt: z.iso.datetime({ offset: true }),
messageCount: z.number().int(),
messages: z
.array(coachMessageSchema)
.describe("Every message in the conversation, decrypted, oldest-first."),
summary: z
.string()
.nullable()
.optional()
.describe(
"Rolling summary of turns elided past the history window; null when none is on file.",
),
})
.meta({
id: "CoachConversationDetail",
description:
"Full conversation with every message decrypted server-side. The client renders the bodies directly; no decryption key is involved.",
});
const coachConversationDeletedResponse = z.object({
deleted: z
.literal(true)
.describe("Always true on success; a foreign / unknown id is a 404."),
});
coachChatRequestSchema.meta({
id: "CoachChatRequest",
description:
"Inbound Coach turn. `message` is the user's turn (1–4 000 chars). `conversationId` is omitted to start a new conversation (the server mints a title from the first message) and supplied to continue one. `scope` narrows which metrics the snapshot ships and which window the timeline covers; omitted fields fall back to server defaults. `locale` picks the reply language. `guidedQuestion` carries the clarifying question a message answers (client-side bubble, never persisted). No `userId` field — the owner is narrowed from the session / Bearer.",
});
export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/insights/chat": {
get: {
tags: ["Insights"],
summary: "List the caller's Coach conversations",
description:
'v1.18.0 — cursor-paginated list of the caller\'s Coach conversations for the history rail, most-recent activity first. Metadata only (id, title, timestamps, message count); message bodies are not decrypted here. `limit` defaults to 20, capped at 50; pass the returned `nextCursor` back as `cursor` for the next page (null at the end). Coach-gated (`requireAssistantSurface("coach")`); a disabled surface 403s. Auth via cookie or Bearer; the owner is narrowed from the session.',
parameters: [
{
name: "cursor",
in: "query",
required: false,
schema: { type: "string" },
description:
"Id of the last conversation on the previous page. Omit for the first page.",
},
{
name: "limit",
in: "query",
required: false,
schema: { type: "integer", minimum: 1, maximum: 50, default: 20 },
description: "Page size. Defaults to 20, capped at 50.",
},
],
responses: {
"200": {
description: "A page of the caller's conversations.",
content: {
"application/json": {
schema: dataEnvelope(
coachConversationsPageSchema,
"CoachConversationsPageResponse",
),
},
},
},
...stdResponses,
},
},
post: {
tags: ["Insights"],
summary: "Send a Coach turn (streaming reply)",
description:
"v1.18.0 — sends a user turn and streams the assistant reply as Server-Sent Events. The response is `text/event-stream`, not JSON: one `data: <json>\\n\\n` frame per event. Frame `type` is one of `token` (a chunk of reply text: `{ type, token }`), `provenance` (the evidence envelope: `{ type, metricSource }`), `suggestion` (a cadence-suggestion card: `{ type, suggestion }`), `reasoning` (v1.18.9, optional reasoning-summary text: `{ type, text }` — emitted only by reasoning-capable providers; absent otherwise), `done` (`{ type, conversationId, messageId, usage? }` — v1.18.9 adds the optional `usage` envelope `{ totalTokens, promptTokens?, completionTokens?, model? }`, server-authoritative; clients display it, never recompute), or `error` (`{ type, code, message }`). The HTTP status is 200 even for a provider/refusal outcome — clients dispatch on the `error` frame, not the status. Clients ignore unknown frame types (additive evolution). Omitting `conversationId` starts a new conversation. Coach-gated; budget- and rate-limited. Auth via cookie or Bearer.",
requestBody: {
required: true,
content: {
"application/json": { schema: coachChatRequestSchema },
},
},
responses: {
"200": {
description:
"Server-Sent Events stream of `token` / `provenance` / `done` / `error` frames.",
content: {
"text/event-stream": {
schema: {
type: "string",
description:
"SSE frames: `data: <json>\\n\\n`. See the operation description for the per-`type` frame shapes.",
},
},
},
},
"403": {
description:
"Coach surface disabled (`errorCode: assistant.disabled.coach`) or AI consent required (`errorCode: consent.ai.required`).",
content: { "application/json": { schema: errorEnvelope } },
},
"413": {
description: "Request body exceeds the 64 KB cap.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/insights/chat/{id}": {
get: {
tags: ["Insights"],
summary: "Read one Coach conversation with all messages",
description:
"v1.18.0 — returns one conversation with every message decrypted server-side and ordered oldest-first, plus the rolling `summary` when one is on file. The client renders the bodies directly; no decryption key is involved. A foreign / unknown id maps to 404 (never 403) so the existence channel does not leak across accounts. Coach-gated. Auth via cookie or Bearer.",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string" },
description: "Conversation id.",
},
],
responses: {
"200": {
description: "The conversation with all decrypted messages.",
content: {
"application/json": {
schema: dataEnvelope(
coachConversationDetailSchema,
"CoachConversationDetailResponse",
),
},
},
},
"404": {
description: "Conversation not found or not owned by the caller.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
delete: {
tags: ["Insights"],
summary: "Delete one Coach conversation",
description:
"v1.18.0 — hard-deletes a conversation and every message under it. A foreign / unknown id maps to 404 (never 403). Coach-gated. Auth via cookie or Bearer.",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string" },
description: "Conversation id.",
},
],
responses: {
"200": {
description: "The conversation was deleted.",
content: {
"application/json": {
schema: dataEnvelope(
coachConversationDeletedResponse,
"CoachConversationDeleted",
),
},
},
},
"404": {
description: "Conversation not found or not owned by the caller.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/insights/layout": {
get: {
tags: ["Insights"],
summary: "Read the calling user's Insights tile layout",
description:
"Returns the per-user Insights tile layout (visibility + order). Falls back to the default layout when the user has not customised it. Mirrors the dashboard-widgets contract.",
responses: {
"200": {
description: "The resolved layout (custom or default).",
content: {
"application/json": {
schema: dataEnvelope(insightsLayoutSchema, "InsightsLayout"),
},
},
},
...stdResponses,
},
},
put: {
tags: ["Insights"],
summary: "Replace the calling user's Insights tile layout",
description:
"Persists the full tile layout. The normalised layout is returned. Invalid bodies return the multi-issue 422 envelope, matching the dashboard-widgets route.",
requestBody: {
required: true,
content: {
"application/json": { schema: insightsLayoutSchema },
},
},
responses: {
"200": {
description: "Layout saved; the normalised layout is echoed back.",
content: {
"application/json": {
schema: dataEnvelope(insightsLayoutSchema, "InsightsLayoutSaved"),
},
},
},
// 422 (multi-issue validation envelope) comes from stdResponses.
...stdResponses,
},
},
delete: {
tags: ["Insights"],
summary: "Reset the calling user's Insights tile layout",
description:
"Clears the persisted layout and returns the default layout. Idempotent.",
responses: {
"200": {
description: "Layout reset; the default layout is returned.",
content: {
"application/json": {
schema: dataEnvelope(insightsLayoutSchema, "InsightsLayoutReset"),
},
},
},
...stdResponses,
},
},
},
"/api/insights/coach/facts": {
get: {
tags: ["Insights"],
summary: "List the caller's durable Coach facts",
description:
"v1.11.1 — returns the active facts the Coach has extracted about the caller (highest-confidence then newest first), each decrypted on the fly. The GDPR 'what do you know about me' surface. Coach-gated (`requireAssistantSurface(\"coach\")`). Auth via cookie or Bearer; the owner is always narrowed from the session, never the body. Undecryptable rows are omitted rather than failing the read.",
responses: {
"200": {
description: "The caller's active facts.",
content: {
"application/json": {
schema: dataEnvelope(coachFactsListResponse, "CoachFactsList"),
},
},
},
...stdResponses,
},
},
delete: {
tags: ["Insights"],
summary: "Forget all of the caller's Coach facts",
description:
"v1.11.1 — bulk 'forget what you know about me': soft-deletes every active fact for the caller and returns the count cleared. Idempotent (a second call clears 0). Coach-gated. Auth via cookie or Bearer.",
responses: {
"200": {
description: "All active facts cleared; the count is returned.",
content: {
"application/json": {
schema: dataEnvelope(
coachFactsClearedResponse,
"CoachFactsCleared",
),
},
},
},
...stdResponses,
},
},
},
"/api/insights/coach/facts/{id}": {
delete: {
tags: ["Insights"],
summary: "Forget one Coach fact",
description:
"v1.11.1 — soft-deletes a single fact owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`, never revealing whether the id exists under another account. Coach-gated. Auth via cookie or Bearer.",
responses: {
"200": {
description:
"The fact was soft-deleted (`deleted: true`) or the id matched nothing the caller owns (`deleted: false`).",
content: {
"application/json": {
schema: dataEnvelope(
coachFactDeletedResponse,
"CoachFactDeleted",
),
},
},
},
...stdResponses,
},
},
},
"/api/insights/coach/nudge-status": {
get: {
tags: ["Insights"],
summary: "Whether an unopened Coach message is waiting",
description:
'v1.18.6 (CCH-03) — server-authoritative unread signal for the Coach FAB. `unread` is true when the caller\'s newest Coach ASSISTANT message (a proactive nudge or any reply) is newer than `User.coachLastSeenAt`; a user who has never opened the Coach reads an existing nudge as unread exactly once. `nudgedAt` carries that newest assistant-message timestamp (null when none exists) so the client can key a local seen-mirror on a stable value. Coach-gated (`requireAssistantSurface("coach")`); a disabled surface 403s. Auth via cookie or Bearer; the owner is narrowed from the session.',
responses: {
"200": {
description: "The current unread state.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({
nudgedAt: z.iso
.datetime({ offset: true })
.nullable()
.describe(
"Timestamp of the newest Coach assistant message; null when none exists.",
),
unread: z
.boolean()
.describe(
"True when that message is newer than the last time the caller opened the Coach.",
),
conversationId: z
.string()
.nullable()
.describe(
"Conversation holding the newest assistant message; null when none exists. The client deep-links into it (`/coach?c=<id>`) on the unread path.",
),
}),
"CoachNudgeStatus",
),
},
},
},
...stdResponses,
},
},
},
"/api/insights/coach/seen": {
post: {
tags: ["Insights"],
summary: "Mark the Coach as opened (clear the unread dot)",
description:
"v1.18.6 (CCH-03) — opening the Coach (drawer or full page) stamps `User.coachLastSeenAt = now()`, so `GET /api/insights/coach/nudge-status` then reports no assistant message newer than the stamp and the FAB drops the unread dot. Server-authoritative, so the cleared state follows the caller across web + iOS rather than just the opening device. No request body — the timestamp is server-minted, so a client can never backdate the stamp to suppress a future nudge. Coach-gated; a disabled surface 403s. Auth via cookie or Bearer.",
responses: {
"200": {
description: "The Coach was marked opened; the stamp is echoed back.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({
seenAt: z.iso
.datetime({ offset: true })
.describe("The server-minted open timestamp."),
}),
"CoachSeenResponse",
),
},
},
},
...stdResponses,
},
},
},
"/api/coach/about-me": {
get: {
tags: ["Insights"],
summary: "Read the caller's self-context",
description:
"v1.16.0 — returns the structured self-context (free text plus chronic conditions, allergies, coach focus) the Coach system prompt and the daily briefing inject as a delimited, user-provided context block, alongside any pending clarifying questions. Every field is stored encrypted at rest; an undecryptable payload reads as null (fail closed). Auth via cookie or Bearer; the owner is always narrowed from the session.",
responses: {
"200": {
description:
"The stored fields (null when never written / cleared) plus pending clarifying questions.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({
aboutMe: z.string().nullable(),
conditions: z.string().nullable(),
allergies: z.string().nullable(),
coachFocus: z.string().nullable(),
pendingQuestions: z.array(z.string()),
updatedAt: z.iso.datetime({ offset: true }).nullable(),
maxChars: z.number().int(),
fieldMaxChars: z.number().int(),
}),
"GetCoachAboutMeResponse",
),
},
},
},
...stdResponses,
},
},
put: {
tags: ["Insights"],
summary: "Write (or clear) the caller's self-context",
description:
"v1.16.0 — persists the free text (4 000-char cap) and the three structured fields (500-char cap each) encrypted at rest; caps are enforced before encryption. Structured fields are optional: omitted leaves the stored value untouched, an empty string clears it. After a non-empty save the server derives up to 3 clarifying questions (AI when a provider and the daily Coach token budget allow, deterministic completion hints otherwise) and returns them as `pendingQuestions`. Rate-limited per user.",
requestBody: {
required: true,
content: { "application/json": { schema: aboutMePutSchema } },
},
responses: {
"200": {
description:
"The effective (trimmed) state echoed back plus the freshly derived pending questions.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({
aboutMe: z.string().nullable(),
conditions: z.string().nullable(),
allergies: z.string().nullable(),
coachFocus: z.string().nullable(),
pendingQuestions: z.array(z.string()),
updatedAt: z.iso.datetime({ offset: true }),
maxChars: z.number().int(),
fieldMaxChars: z.number().int(),
}),
"PutCoachAboutMeResponse",
),
},
},
},
...stdResponses,
},
},
},
"/api/coach/about-me/questions": {
get: {
tags: ["Insights"],
summary: "Read the pending clarifying questions",
description:
"v1.16.0 — the up-to-3 clarifying questions derived after the last self-context save. The Coach composer renders them as tappable suggestion chips. Stored encrypted; an undecryptable payload reads as an empty list (fail closed).",
responses: {
"200": {
description: "The pending questions (possibly empty).",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ questions: z.array(z.string()) }),
"GetCoachAboutMeQuestionsResponse",
),
},
},
},
...stdResponses,
},
},
delete: {
tags: ["Insights"],
summary: "Dismiss pending clarifying questions",
description:
"v1.16.0 — dismisses one question (body `{ question }`, exact match) or all of them (empty body). Tapping a chip in the Coach composer inserts the question into the chat input and dismisses it here.",
requestBody: {
required: false,
content: {
"application/json": {
schema: z.object({
question: z
.string()
.optional()
.describe(
"Exact question text to dismiss. Omitted = dismiss all.",
),
}),
},
},
},
responses: {
"200": {
description: "The remaining questions after the dismissal.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ questions: z.array(z.string()) }),
"DeleteCoachAboutMeQuestionsResponse",
),
},
},
},
...stdResponses,
},
},
},
"/api/coach/plans": {
get: {
tags: ["Insights"],
summary: "List the caller's Coach goal / if-then plans",
description:
'v1.21.3 (B1) — returns the durable plans the Coach has proposed for the caller, newest first, each decrypted on the fly. A plan is an "if-then" implementation intention tied to one metric, with an optional target. The Coach extractor writes a plan as `proposed`; only `PATCH /api/coach/plans/{id}` activates it. Pass `?status=` to filter (proposed | active | met | abandoned); omitted returns the non-terminal set (proposed + active). Coach-gated (`requireModuleEnabled("coach")`); a disabled surface 403s. Auth via cookie or Bearer; the owner is always narrowed from the session, never the body. Undecryptable rows are omitted rather than failing the read.',
parameters: [
{
name: "status",
in: "query",
required: false,
schema: { type: "string", enum: [...COACH_PLAN_STATUSES] },
description:
"Filter to a single lifecycle status. Omit for the non-terminal set (proposed + active).",
},
],
responses: {
"200": {
description: "The caller's plans.",
content: {
"application/json": {
schema: dataEnvelope(coachPlansListResponse, "CoachPlansList"),
},
},
},
"403": {
description: "Coach surface disabled.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/coach/plans/{id}": {
patch: {
tags: ["Insights"],
summary: "Confirm or update a Coach plan's lifecycle",
description:
"v1.21.3 (B1) — confirm a proposed plan (status proposed → active) or mark it met / abandoned, and optionally set / clear a review date. The body carries ONLY lifecycle fields — never the metric or the encrypted free text — so a client can change a plan's status but never inject or overwrite its prose. A foreign / unknown / already-deleted id maps to 404 (never 403) so the existence channel does not leak across accounts. Coach-gated. Auth via cookie or Bearer; the owner is narrowed from the session.",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string" },
description: "Plan id.",
},
],
requestBody: {
required: true,
content: {
"application/json": { schema: coachPlanPatchSchema },
},
},
responses: {
"200": {
description: "The plan after the lifecycle update.",
content: {
"application/json": {
schema: dataEnvelope(
coachPlanUpdatedResponse,
"CoachPlanUpdated",
),
},
},
},
"403": {
description: "Coach surface disabled.",
content: { "application/json": { schema: errorEnvelope } },
},
"404": {
description: "Plan not found or not owned by the caller.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
delete: {
tags: ["Insights"],
summary: "Soft-delete one Coach plan",
description:
"v1.21.3 (B1) — soft-deletes a single plan owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`, never revealing whether the id exists under another account. Coach-gated. Auth via cookie or Bearer.",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string" },
description: "Plan id.",
},
],
responses: {
"200": {
description:
"The plan was soft-deleted (`deleted: true`) or the id matched nothing the caller owns (`deleted: false`).",
content: {
"application/json": {
schema: dataEnvelope(
coachPlanDeletedResponse,
"CoachPlanDeleted",
),
},
},
},
"403": {
description: "Coach surface disabled.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
};
export const coachReminderPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/coach/reminders": {
get: {
tags: ["Insights"],
summary: "List the caller's Coach reminders",
description:
'v1.22 (B2/B6) — the durable "remind me about X" memory the Coach captured inline, decrypted on the fly, soonest-due first. Pass `?status=` (one status or a comma set like `due,surfaced` for the in-app tile); omitted returns the non-terminal set (proposed + active + due + surfaced). Coach-gated (`requireModuleEnabled("coach")`). Auth via cookie or Bearer; the owner is narrowed from the session. Undecryptable rows are omitted.',
parameters: [
{
name: "status",
in: "query",
required: false,
schema: { type: "string" },
description:
"Filter to one status or a comma-separated set (proposed | active | due | surfaced | done | dismissed). Omit for the non-terminal set.",
},
],
responses: {
"200": {
description: "The caller's reminders.",
content: {
"application/json": {
schema: dataEnvelope(
coachRemindersListResponse,
"CoachRemindersList",
),
},
},
},
"403": {
description: "Coach surface disabled.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
post: {
tags: ["Insights"],
summary: "Create a Coach reminder manually",
description:
"v1.22 (B6) — create a reminder from the ledger. `note` is the only writable content; `when` is the closed grammar (ISO date | +Nd / +Nw | a context cue) resolved into the trigger server-side; `metric` optional. The per-user cap returns 409 when the non-terminal set is full. Coach-gated. Auth via cookie or Bearer; the owner is narrowed from the session.",
requestBody: {
required: true,
content: {
"application/json": { schema: coachReminderCreateSchema },
},
},
responses: {
"201": {
description: "The created reminder.",
content: {
"application/json": {
schema: dataEnvelope(
coachReminderCreatedResponse,
"CoachReminderCreated",
),
},
},
},
"403": {
description: "Coach surface disabled.",
content: { "application/json": { schema: errorEnvelope } },
},
"409": {
description: "The per-user reminder cap is full.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
"/api/coach/reminders/{id}": {
patch: {
tags: ["Insights"],
summary: "Confirm or update a Coach reminder's lifecycle",
description:
"v1.22 (B6) — confirm a proposed reminder (→ active), mark it done / dismissed, or re-schedule via the closed `when` grammar (null clears the due moment). The body never carries the note text. A foreign / unknown / already-deleted id maps to 404 (never 403) so the existence channel does not leak. Coach-gated. Auth via cookie or Bearer.",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string" },
description: "Reminder id.",
},
],
requestBody: {
required: true,
content: {
"application/json": { schema: coachReminderPatchSchema },
},
},
responses: {
"200": {
description: "The reminder after the lifecycle update.",
content: {
"application/json": {
schema: dataEnvelope(
coachReminderUpdatedResponse,
"CoachReminderUpdated",
),
},
},
},
"403": {
description: "Coach surface disabled.",
content: { "application/json": { schema: errorEnvelope } },
},
"404": {
description: "Reminder not found or not owned by the caller.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
delete: {
tags: ["Insights"],
summary: "Soft-delete one Coach reminder",
description:
"v1.22 (B6) — soft-deletes a single reminder owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`. Coach-gated. Auth via cookie or Bearer.",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string" },
description: "Reminder id.",
},
],
responses: {
"200": {
description:
"The reminder was soft-deleted (`deleted: true`) or the id matched nothing the caller owns (`deleted: false`).",
content: {
"application/json": {
schema: dataEnvelope(
coachReminderDeletedResponse,
"CoachReminderDeleted",
),
},
},
},
"403": {
description: "Coach surface disabled.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
};
export const coachSuggestedActionPaths: NonNullable<ZodOpenApiObject["paths"]> =
{
"/api/coach/suggested-actions": {
post: {
tags: ["Insights"],
summary: "Confirm a Coach action card",
description:
'v1.22 (F6) — the confirm half of the generalised propose→confirm moat. `actionType` is from the CLOSED allowlist (checkup.create | reminder.note) — NEVER a medication or clinical change. `checkup.create` builds a preventive-care Vorsorge `MeasurementReminder` (free-text label + a closed interval id resolved to an RRULE server-side); `reminder.note` builds a `CoachReminder`. Nothing is created without this explicit confirm. Coach-gated (`requireModuleEnabled("coach")`); per-user rate-limited (429). Auth via cookie or Bearer; the owner is narrowed from the session, built field-by-field — no mass assignment, no IDOR.',
requestBody: {
required: true,
content: {
"application/json": { schema: coachSuggestedActionSchema },
},
},
responses: {
"201": {
description: "The action was applied (entity created server-side).",
content: {
"application/json": {
schema: dataEnvelope(
coachSuggestedActionResultResponse,
"CoachSuggestedActionResultCreated",
),
},
},
},
"403": {
description: "Coach surface disabled.",
content: { "application/json": { schema: errorEnvelope } },
},
"409": {
description: "The per-user reminder cap is full (reminder.note).",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
};
export const coachFeedbackPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/insights/chat/messages/{id}/feedback": {
post: {
tags: ["Insights"],
summary: "Rate a Coach assistant message",
description:
"Persists a helpful/unhelpful rating for a single Coach reply. Reuses the v1.4.16 RecommendationFeedback table via the polymorphic `targetType` column. The aggregator buckets ratings by (promptVersion, tone, verbosity).",
requestBody: {
required: true,
content: {
"application/json": { schema: coachMessageFeedbackBody },
},
},
responses: {
"201": {
description: "Feedback saved.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({
id: z.string(),
createdAt: z.iso.datetime({ offset: true }),
}),
"CoachMessageFeedbackResponse",
),
},
},
},
"404": {
description: "Message not found or not owned by the caller.",
content: { "application/json": { schema: errorEnvelope } },
},
"409": {
description: "Caller has already rated this message text.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
},
};