mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(coach): add reminder and suggested-action endpoints
Add the management surface for the reminder memory: list, create, confirm, done/dismiss, and soft-delete, every query scoped to the session owner with the same no-IDOR posture as the plans routes (a cross-user or unknown id is a 0-count no-op, never a leaked existence). The note is the only writable field; timing is resolved server-side from the closed grammar. Add the confirm endpoint behind the generalised action card. It switches on a closed action-type allowlist and builds each entity field-by-field from a server-resolved spec: a preventive-care reminder whose schedule comes from a closed interval catalog, or a reminder note. It never auto-applies, and the allowlist excludes any medication or clinical change. Regenerate the OpenAPI contract for the new routes.
This commit is contained in:
1 parent
27a8a59d67
commit
6f407388b4
7 files changed
+1450
No files matched your search
@@ -4836,6 +4836,189 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/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:
|
||||
$ref: "#/components/schemas/CoachRemindersList"
|
||||
"401": *a1
|
||||
"403":
|
||||
description: Coach surface disabled.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
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:
|
||||
$ref: "#/components/schemas/CoachReminderCreate"
|
||||
responses:
|
||||
"201":
|
||||
description: The created reminder.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachReminderCreated"
|
||||
"401": *a1
|
||||
"403":
|
||||
description: Coach surface disabled.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"409":
|
||||
description: The per-user reminder cap is full.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/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:
|
||||
$ref: "#/components/schemas/CoachReminderPatch"
|
||||
responses:
|
||||
"200":
|
||||
description: The reminder after the lifecycle update.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachReminderUpdated"
|
||||
"401": *a1
|
||||
"403":
|
||||
description: Coach surface disabled.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"404":
|
||||
description: Reminder not found or not owned by the caller.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
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:
|
||||
$ref: "#/components/schemas/CoachReminderDeleted"
|
||||
"401": *a1
|
||||
"403":
|
||||
description: Coach surface disabled.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/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:
|
||||
$ref: "#/components/schemas/CoachSuggestedAction"
|
||||
responses:
|
||||
"201":
|
||||
description: The action was applied (entity created server-side).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachSuggestedActionResultCreated"
|
||||
"401": *a1
|
||||
"403":
|
||||
description: Coach surface disabled.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"409":
|
||||
description: The per-user reminder cap is full (reminder.note).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/admin/invites:
|
||||
get:
|
||||
tags:
|
||||
@@ -8538,6 +8721,99 @@ components:
|
||||
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."
|
||||
CoachReminderCreate:
|
||||
type: object
|
||||
properties:
|
||||
note:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 280
|
||||
when:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 32
|
||||
metric:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 60
|
||||
pattern: ^[A-Za-z][A-Za-z0-9_]*$
|
||||
required:
|
||||
- note
|
||||
additionalProperties: false
|
||||
description: "Create a Coach reminder manually. `note` is free text (the only writable content). `when` is an optional
|
||||
closed grammar token — an ISO date (YYYY-MM-DD), a relative offset (+Nd / +Nw), or a context cue
|
||||
(NEXT_BP_LOGGED, NEXT_WEIGHT_LOGGED, NEXT_SLEEP_LOGGED, NEXT_APP_OPEN) — resolved into the trigger server-side.
|
||||
Omit `when` for a recall-only note. Strict: unknown keys 422."
|
||||
CoachReminderPatch:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- proposed
|
||||
- active
|
||||
- due
|
||||
- surfaced
|
||||
- done
|
||||
- dismissed
|
||||
when:
|
||||
anyOf:
|
||||
- type: string
|
||||
minLength: 1
|
||||
maxLength: 32
|
||||
- type: "null"
|
||||
additionalProperties: false
|
||||
description: "Update a Coach reminder's lifecycle. `status` confirms (proposed → active), or marks done / dismissed.
|
||||
`when` re-schedules via the closed grammar (null clears the due moment). Never carries the note text. Strict:
|
||||
unknown keys 422."
|
||||
CoachSuggestedAction:
|
||||
oneOf:
|
||||
- type: object
|
||||
properties:
|
||||
actionType:
|
||||
type: string
|
||||
const: checkup.create
|
||||
label:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 120
|
||||
interval:
|
||||
type: string
|
||||
enum:
|
||||
- yearly
|
||||
- halfYearly
|
||||
- quarterly
|
||||
- monthly
|
||||
required:
|
||||
- actionType
|
||||
- label
|
||||
- interval
|
||||
- type: object
|
||||
properties:
|
||||
actionType:
|
||||
type: string
|
||||
const: reminder.note
|
||||
note:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 280
|
||||
when:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 32
|
||||
metric:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 60
|
||||
pattern: ^[A-Za-z][A-Za-z0-9_]*$
|
||||
required:
|
||||
- actionType
|
||||
- note
|
||||
description: Confirm a Coach action card. `actionType` is from the closed allowlist (checkup.create, reminder.note);
|
||||
NEVER a medication or clinical change. `checkup.create` builds a preventive-care Vorsorge reminder (free-text
|
||||
label + a closed interval id resolved to an RRULE server-side); `reminder.note` builds a CoachReminder (note +
|
||||
optional closed `when` grammar + optional metric). Nothing is created without this explicit confirm.
|
||||
type: object
|
||||
CoachMessageFeedbackRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -17064,6 +17340,208 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachRemindersList:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
reminders:
|
||||
type: array
|
||||
items:
|
||||
$ref: "#/components/schemas/CoachReminder"
|
||||
description: The caller's reminders, soonest-due first. Undecryptable rows are omitted. No `status` filter returns the
|
||||
non-terminal set (proposed + active + due + surfaced).
|
||||
required:
|
||||
- reminders
|
||||
additionalProperties: false
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachReminder:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
note:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
description: Decrypted note (the user's own framing). Null when the row's key id is no longer in the map.
|
||||
metric:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
triggerKind:
|
||||
type: string
|
||||
enum:
|
||||
- date
|
||||
- context
|
||||
dueAt:
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
- type: "null"
|
||||
contextCue:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
status:
|
||||
type: string
|
||||
enum:
|
||||
- proposed
|
||||
- active
|
||||
- due
|
||||
- surfaced
|
||||
- done
|
||||
- dismissed
|
||||
source:
|
||||
type: string
|
||||
enum:
|
||||
- sentinel
|
||||
- extractor
|
||||
- manual
|
||||
- action
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
updatedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
|
||||
required:
|
||||
- id
|
||||
- note
|
||||
- metric
|
||||
- triggerKind
|
||||
- dueAt
|
||||
- contextCue
|
||||
- status
|
||||
- source
|
||||
- createdAt
|
||||
- updatedAt
|
||||
additionalProperties: false
|
||||
description: One Coach episodic reminder, decrypted server-side. The note reads null only when the encryption key id has
|
||||
rotated out of the map.
|
||||
CoachReminderCreated:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
reminder:
|
||||
$ref: "#/components/schemas/CoachReminder"
|
||||
required:
|
||||
- reminder
|
||||
additionalProperties: false
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachReminderUpdated:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
reminder:
|
||||
$ref: "#/components/schemas/CoachReminder"
|
||||
required:
|
||||
- reminder
|
||||
additionalProperties: false
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachReminderDeleted:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
deleted:
|
||||
type: boolean
|
||||
description: True when a reminder owned by the caller was soft-deleted; false for an unknown / cross-user /
|
||||
already-deleted id (idempotent no-op).
|
||||
required:
|
||||
- deleted
|
||||
additionalProperties: false
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachSuggestedActionResultCreated:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/CoachSuggestedActionResult"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachSuggestedActionResult:
|
||||
type: object
|
||||
properties:
|
||||
ok:
|
||||
type: boolean
|
||||
const: true
|
||||
actionType:
|
||||
type: string
|
||||
enum:
|
||||
- checkup.create
|
||||
- reminder.note
|
||||
reminder: {}
|
||||
reminderId:
|
||||
type: string
|
||||
required:
|
||||
- ok
|
||||
- actionType
|
||||
additionalProperties: false
|
||||
description: Outcome of confirming a Coach action card. `checkup.create` returns the created MeasurementReminder DTO;
|
||||
`reminder.note` returns the created CoachReminder id.
|
||||
AdminInviteList:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* PATCH /api/coach/reminders/[id] — confirm / update a reminder's lifecycle.
|
||||
* DELETE /api/coach/reminders/[id] — soft-delete one reminder.
|
||||
*
|
||||
* v1.22 (B2/B6) — the user-confirm + management surface for the Coach episodic
|
||||
* reminders. `status` confirms a `proposed` reminder (→ active), marks it
|
||||
* `done` / `dismissed`, or re-activates it; `when` re-schedules (snooze) via the
|
||||
* closed grammar. The body never carries the note text, so a client can never
|
||||
* inject or overwrite a reminder's prose — only its lifecycle / timing.
|
||||
*
|
||||
* Ownership + existence privacy: every mutation is scoped
|
||||
* `where: { id, userId, deletedAt: null }` via `updateMany`, so a cross-user id,
|
||||
* an unknown id, or an already-deleted reminder all resolve to a `count: 0`
|
||||
* no-op rather than a P2025 throw — the existence channel never leaks across
|
||||
* accounts. PATCH on a 0-count match returns 404; DELETE returns the idempotent
|
||||
* `{ deleted: false }`.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { resolveWhenGrammar } from "@/lib/ai/coach/reminders";
|
||||
import { coachReminderPatchSchema } from "@/lib/validations/coach-reminder";
|
||||
|
||||
interface RouteCtx {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
const MUTATE_RATE_LIMIT = 40;
|
||||
const MUTATE_WINDOW_MS = 60_000;
|
||||
|
||||
async function enforceMutateLimit(
|
||||
op: string,
|
||||
userId: string,
|
||||
): Promise<Response | null> {
|
||||
const rl = await checkRateLimit(
|
||||
`coach-reminders:${op}:${userId}`,
|
||||
MUTATE_RATE_LIMIT,
|
||||
MUTATE_WINDOW_MS,
|
||||
);
|
||||
if (rl.allowed) return null;
|
||||
const response = apiError("Too many requests", 429);
|
||||
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
|
||||
response.headers.set(k, v);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const limited = await enforceMutateLimit("patch", user.id);
|
||||
if (limited) return limited;
|
||||
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = coachReminderPatchSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
// Field-by-field (no mass assignment): only the lifecycle / timing fields the
|
||||
// body carried are written; the note + encrypted text are never touched here.
|
||||
const data: {
|
||||
status?: string;
|
||||
dueAt?: Date | null;
|
||||
triggerKind?: string;
|
||||
contextCue?: string | null;
|
||||
} = {};
|
||||
if (parsed.data.status !== undefined) data.status = parsed.data.status;
|
||||
if (parsed.data.when !== undefined) {
|
||||
if (parsed.data.when === null) {
|
||||
data.dueAt = null;
|
||||
data.triggerKind = "date";
|
||||
data.contextCue = null;
|
||||
} else {
|
||||
const trigger = resolveWhenGrammar(parsed.data.when, new Date());
|
||||
if (!trigger) return apiError("coach.reminder.invalidWhen", 422);
|
||||
data.dueAt = trigger.dueAt;
|
||||
data.triggerKind = trigger.triggerKind;
|
||||
data.contextCue = trigger.contextCue;
|
||||
}
|
||||
}
|
||||
|
||||
const { count } = await prisma.coachReminder.updateMany({
|
||||
where: { id, userId: user.id, deletedAt: null },
|
||||
data,
|
||||
});
|
||||
|
||||
if (count === 0) return apiError("Reminder not found", 404);
|
||||
|
||||
const row = await prisma.coachReminder.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
select: {
|
||||
id: true,
|
||||
metric: true,
|
||||
noteEncrypted: true,
|
||||
triggerKind: true,
|
||||
dueAt: true,
|
||||
contextCue: true,
|
||||
status: true,
|
||||
source: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
if (!row) return apiError("Reminder not found", 404);
|
||||
|
||||
let note: string | null = null;
|
||||
try {
|
||||
note = decryptFromBytes(row.noteEncrypted);
|
||||
} catch {
|
||||
note = null;
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.reminders.updated" },
|
||||
meta: { status: row.status },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
reminder: {
|
||||
id: row.id,
|
||||
note,
|
||||
metric: row.metric,
|
||||
triggerKind: row.triggerKind,
|
||||
dueAt: row.dueAt?.toISOString() ?? null,
|
||||
contextCue: row.contextCue,
|
||||
status: row.status,
|
||||
source: row.source,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (_request: NextRequest, ctx: RouteCtx) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const limited = await enforceMutateLimit("delete", user.id);
|
||||
if (limited) return limited;
|
||||
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const { count } = await prisma.coachReminder.updateMany({
|
||||
where: { id, userId: user.id, deletedAt: null },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
|
||||
const deleted = count > 0;
|
||||
annotate({
|
||||
action: { name: "coach.reminders.deleted" },
|
||||
meta: { deleted },
|
||||
});
|
||||
return apiSuccess({ deleted });
|
||||
},
|
||||
);
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* GET /api/coach/reminders — list the caller's Coach episodic reminders.
|
||||
* POST /api/coach/reminders — create one manually (the ledger "+ add" path).
|
||||
*
|
||||
* v1.22 (B2/B6) — the management + in-app-surface read for the durable
|
||||
* "remind me about X" memory the Coach captures inline via the `---REMEMBER---`
|
||||
* sentinel. The note free-text is decrypted on the fly; an undecryptable row (a
|
||||
* key rotated out of the map) is skipped rather than 500ing the whole list.
|
||||
*
|
||||
* Ownership: every query is scoped `where: { userId, ... }`, so a caller can
|
||||
* only ever see / create their own reminders. The owner is always narrowed from
|
||||
* the session, never the body. Coach-gated by the same
|
||||
* `requireModuleEnabled(userId, "coach")` kill-switch as the rest of the Coach
|
||||
* management stack.
|
||||
*
|
||||
* `?status=` optionally filters (one status, or a comma set like `due,surfaced`
|
||||
* for the in-app tile). Omitted returns the non-terminal set (proposed / active
|
||||
* / due / surfaced), soonest-due first.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { decryptFromBytes, encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
resolveWhenGrammar,
|
||||
MAX_REMINDERS_PER_USER,
|
||||
} from "@/lib/ai/coach/reminders";
|
||||
import {
|
||||
coachRemindersListQuerySchema,
|
||||
coachReminderCreateSchema,
|
||||
} from "@/lib/validations/coach-reminder";
|
||||
|
||||
const NON_TERMINAL = ["proposed", "active", "due", "surfaced"] as const;
|
||||
|
||||
interface ReminderRow {
|
||||
id: string;
|
||||
metric: string | null;
|
||||
noteEncrypted: Uint8Array;
|
||||
triggerKind: string;
|
||||
dueAt: Date | null;
|
||||
contextCue: string | null;
|
||||
status: string;
|
||||
source: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
function toDto(row: ReminderRow, note: string) {
|
||||
return {
|
||||
id: row.id,
|
||||
note,
|
||||
metric: row.metric,
|
||||
triggerKind: row.triggerKind,
|
||||
dueAt: row.dueAt?.toISOString() ?? null,
|
||||
contextCue: row.contextCue,
|
||||
status: row.status,
|
||||
source: row.source,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export const GET = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const url = new URL(req.url);
|
||||
const parsed = coachRemindersListQuerySchema.safeParse({
|
||||
status: url.searchParams.get("status") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
const statusWhere = parsed.data.status
|
||||
? { status: { in: parsed.data.status } }
|
||||
: { status: { in: [...NON_TERMINAL] } };
|
||||
|
||||
const rows = (await prisma.coachReminder.findMany({
|
||||
where: { userId: user.id, deletedAt: null, ...statusWhere },
|
||||
orderBy: [{ dueAt: "asc" }, { updatedAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
metric: true,
|
||||
noteEncrypted: true,
|
||||
triggerKind: true,
|
||||
dueAt: true,
|
||||
contextCue: true,
|
||||
status: true,
|
||||
source: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
})) as ReminderRow[];
|
||||
|
||||
const reminders: ReturnType<typeof toDto>[] = [];
|
||||
for (const row of rows) {
|
||||
let note: string;
|
||||
try {
|
||||
note = decryptFromBytes(row.noteEncrypted);
|
||||
} catch {
|
||||
// Fail closed per row — never surface ciphertext, never 500 the list.
|
||||
continue;
|
||||
}
|
||||
reminders.push(toDto(row, note));
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.reminders.listed" },
|
||||
meta: { count: reminders.length },
|
||||
});
|
||||
|
||||
return apiSuccess({ reminders });
|
||||
});
|
||||
|
||||
export const POST = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rate = await checkRateLimit(
|
||||
`coach-reminders:create:${user.id}`,
|
||||
30,
|
||||
60_000,
|
||||
);
|
||||
if (!rate.allowed) return apiError("Too many requests", 429);
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = coachReminderCreateSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
|
||||
// Resolve the optional `when` against the closed grammar; an invalid token
|
||||
// 422s rather than silently dropping the timing the user asked for.
|
||||
const now = new Date();
|
||||
let trigger: ReturnType<typeof resolveWhenGrammar> = null;
|
||||
if (parsed.data.when) {
|
||||
trigger = resolveWhenGrammar(parsed.data.when, now);
|
||||
if (!trigger) return apiError("coach.reminder.invalidWhen", 422);
|
||||
}
|
||||
|
||||
// Per-user cap on the non-terminal set.
|
||||
const nonTerminal = await prisma.coachReminder.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
status: { in: [...NON_TERMINAL] },
|
||||
},
|
||||
});
|
||||
if (nonTerminal >= MAX_REMINDERS_PER_USER) {
|
||||
return apiError("coach.reminder.capReached", 409);
|
||||
}
|
||||
|
||||
// Field-by-field — no mass assignment. Manual reminders are active on create.
|
||||
const created = await prisma.coachReminder.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
noteEncrypted: encryptToBytes(parsed.data.note),
|
||||
metric: parsed.data.metric ?? null,
|
||||
triggerKind: trigger?.triggerKind ?? "date",
|
||||
dueAt: trigger?.dueAt ?? null,
|
||||
contextCue: trigger?.contextCue ?? null,
|
||||
status: "active",
|
||||
source: "manual",
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
metric: true,
|
||||
noteEncrypted: true,
|
||||
triggerKind: true,
|
||||
dueAt: true,
|
||||
contextCue: true,
|
||||
status: true,
|
||||
source: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.reminders.created" },
|
||||
meta: { triggerKind: created.triggerKind, hasDue: created.dueAt != null },
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
{ reminder: toDto(created as ReminderRow, parsed.data.note) },
|
||||
201,
|
||||
);
|
||||
});
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@@ -0,0 +1,188 @@
|
||||
/**
|
||||
* POST /api/coach/suggested-actions — confirm a generalised Coach action card.
|
||||
*
|
||||
* v1.22 (F6) — the confirm half of the generalised propose→confirm moat. The
|
||||
* Coach proposes an action via the closed `---SUGGEST-ACTION---` sentinel →
|
||||
* `suggestedAction` SSE frame → `<SuggestedActionCard>`. This endpoint is what
|
||||
* the card's confirm button calls. It switches on the CLOSED `actionType`
|
||||
* allowlist and, for each, builds the entity FIELD-BY-FIELD from a
|
||||
* server-resolved spec:
|
||||
*
|
||||
* - `checkup.create` → a preventive-care Vorsorge `MeasurementReminder`
|
||||
* (free-text label + a closed interval id the SERVER resolves to an RRULE),
|
||||
* minted through the SAME engine the Vorsorge surface uses.
|
||||
* - `reminder.note` → a `CoachReminder` (note + optional closed `when`
|
||||
* grammar + optional metric).
|
||||
*
|
||||
* HARD SAFETY: never auto-apply (this endpoint runs only on the explicit
|
||||
* confirm tap); closed allowlist only (the discriminated-union Zod gate rejects
|
||||
* any other `actionType`); NEVER a medication / dose / clinical-target change.
|
||||
* Cookie-auth, coach module-gated, per-user rate-limited, owner-scoped — no
|
||||
* IDOR, no mass assignment.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
getClientIp,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { encryptToBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import {
|
||||
resolveWhenGrammar,
|
||||
MAX_REMINDERS_PER_USER,
|
||||
} from "@/lib/ai/coach/reminders";
|
||||
import { CHECKUP_INTERVAL_CATALOG } from "@/lib/ai/coach/suggest-action";
|
||||
import { coachSuggestedActionSchema } from "@/lib/validations/coach-reminder";
|
||||
import {
|
||||
computeReminderNextDueAt,
|
||||
type ReminderScheduleInput,
|
||||
} from "@/lib/measurement-reminders/scheduling";
|
||||
import { toMeasurementReminderDto } from "@/lib/measurement-reminders/dto";
|
||||
|
||||
const DEFAULT_TIMEZONE = "Europe/Berlin";
|
||||
const CHECKUP_NOTIFY_HOUR = 9;
|
||||
|
||||
async function postAction(request: NextRequest): Promise<Response> {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rate = await checkRateLimit(
|
||||
`coach:suggested-action:${user.id}`,
|
||||
30,
|
||||
60_000,
|
||||
);
|
||||
if (!rate.allowed) return apiError("coach.action.rateLimited", 429);
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = coachSuggestedActionSchema.safeParse(body);
|
||||
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
|
||||
const action = parsed.data;
|
||||
|
||||
// ── checkup.create → a preventive-care Vorsorge reminder ─────
|
||||
if (action.actionType === "checkup.create") {
|
||||
// The model named an interval id; the SERVER owns the RRULE so the model
|
||||
// can never mint an arbitrary schedule.
|
||||
const preset = CHECKUP_INTERVAL_CATALOG[action.interval];
|
||||
if (!preset) return apiError("coach.action.unknownInterval", 422);
|
||||
|
||||
const row = await prisma.user.findUnique({
|
||||
where: { id: user.id },
|
||||
select: { timezone: true },
|
||||
});
|
||||
const timezone = row?.timezone || DEFAULT_TIMEZONE;
|
||||
const now = new Date();
|
||||
const scheduleInput: ReminderScheduleInput = {
|
||||
intervalDays: null,
|
||||
rrule: preset.rrule,
|
||||
anchorDate: null,
|
||||
notifyHour: CHECKUP_NOTIFY_HOUR,
|
||||
lastSatisfiedAt: null,
|
||||
createdAt: now,
|
||||
endsOn: null,
|
||||
};
|
||||
const nextDueAt = computeReminderNextDueAt(scheduleInput, timezone, now);
|
||||
|
||||
// Field-by-field — no mass assignment. A free-text checklist Vorsorge
|
||||
// (measurementType null) so it resolves only on a manual "done". origin
|
||||
// COACH marks the provenance.
|
||||
const created = await prisma.measurementReminder.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
label: action.label,
|
||||
measurementType: null,
|
||||
intervalDays: null,
|
||||
rrule: preset.rrule,
|
||||
anchorDate: null,
|
||||
endsOn: null,
|
||||
origin: "COACH",
|
||||
notifyHour: CHECKUP_NOTIFY_HOUR,
|
||||
location: null,
|
||||
enabled: true,
|
||||
nextDueAt,
|
||||
},
|
||||
});
|
||||
|
||||
await auditLog("measurementReminder.create.coachAction", {
|
||||
userId: user.id,
|
||||
ipAddress: getClientIp(request),
|
||||
details: { reminderId: created.id, interval: action.interval },
|
||||
});
|
||||
annotate({
|
||||
action: { name: "coach.action.applied" },
|
||||
meta: { actionType: action.actionType, interval: action.interval },
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
{
|
||||
ok: true,
|
||||
actionType: action.actionType,
|
||||
reminder: toMeasurementReminderDto(created),
|
||||
},
|
||||
201,
|
||||
);
|
||||
}
|
||||
|
||||
// ── reminder.note → a CoachReminder ──────────────────────────
|
||||
const now = new Date();
|
||||
let trigger: ReturnType<typeof resolveWhenGrammar> = null;
|
||||
if (action.when) {
|
||||
trigger = resolveWhenGrammar(action.when, now);
|
||||
if (!trigger) return apiError("coach.reminder.invalidWhen", 422);
|
||||
}
|
||||
|
||||
const nonTerminal = await prisma.coachReminder.count({
|
||||
where: {
|
||||
userId: user.id,
|
||||
deletedAt: null,
|
||||
status: { in: ["proposed", "active", "due", "surfaced"] },
|
||||
},
|
||||
});
|
||||
if (nonTerminal >= MAX_REMINDERS_PER_USER) {
|
||||
return apiError("coach.reminder.capReached", 409);
|
||||
}
|
||||
|
||||
const created = await prisma.coachReminder.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
noteEncrypted: encryptToBytes(action.note),
|
||||
metric: action.metric ?? null,
|
||||
triggerKind: trigger?.triggerKind ?? "date",
|
||||
dueAt: trigger?.dueAt ?? null,
|
||||
contextCue: trigger?.contextCue ?? null,
|
||||
status: "active",
|
||||
source: "action",
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.action.applied" },
|
||||
meta: {
|
||||
actionType: action.actionType,
|
||||
hasDue: trigger?.dueAt != null,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess(
|
||||
{ ok: true, actionType: action.actionType, reminderId: created.id },
|
||||
201,
|
||||
);
|
||||
}
|
||||
|
||||
export const POST = apiHandler(postAction);
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@@ -22,6 +22,12 @@ 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) ──────────────────────────────
|
||||
@@ -316,6 +322,70 @@ coachPlansListQuerySchema.meta({
|
||||
"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
|
||||
@@ -1052,6 +1122,198 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
};
|
||||
|
||||
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: {
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
coachFeedbackPaths,
|
||||
coachPaths,
|
||||
coachReminderSuggestionPaths,
|
||||
coachReminderPaths,
|
||||
coachSuggestedActionPaths,
|
||||
} from "./coach";
|
||||
import { consentPaths } from "./consent";
|
||||
import { cyclePaths } from "./cycle";
|
||||
@@ -62,6 +64,8 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
...profilePaths,
|
||||
...coachPaths,
|
||||
...coachReminderSuggestionPaths,
|
||||
...coachReminderPaths,
|
||||
...coachSuggestedActionPaths,
|
||||
...adminInvitePaths,
|
||||
...coachFeedbackPaths,
|
||||
...adminDiagnosticPaths,
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* v1.22 (B2/B6/F6) — request schemas for the Coach episodic-reminder surface
|
||||
* (`/api/coach/reminders` + `/[id]`) and the generalised confirm-card endpoint
|
||||
* (`/api/coach/suggested-actions`).
|
||||
*
|
||||
* Lives outside the route files so the OpenAPI registry can import it without
|
||||
* touching the route modules (route files may only export handlers + config).
|
||||
*
|
||||
* No `userId` field anywhere — the owner is always narrowed from the session.
|
||||
* The note free-text is the only user-writable content; everything timing is
|
||||
* resolved SERVER-side from a closed grammar / catalog, so a client can never
|
||||
* mint an arbitrary schedule.
|
||||
*/
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import {
|
||||
COACH_REMINDER_STATUSES,
|
||||
REMINDER_NOTE_MAX_CHARS,
|
||||
REMINDER_METRIC_MAX_CHARS,
|
||||
} from "@/lib/ai/coach/reminders";
|
||||
import {
|
||||
SUGGESTED_ACTION_TYPES,
|
||||
CHECKUP_LABEL_MAX_CHARS,
|
||||
ACTION_NOTE_MAX_CHARS,
|
||||
ACTION_METRIC_MAX_CHARS,
|
||||
} from "@/lib/ai/coach/suggest-action";
|
||||
|
||||
/** A `when` grammar token: ISO date | relative +Nd/+Nw | a context cue. */
|
||||
const whenToken = z.string().trim().min(1).max(32);
|
||||
const metricToken = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(REMINDER_METRIC_MAX_CHARS)
|
||||
.regex(/^[A-Za-z][A-Za-z0-9_]*$/, "metric must be a metric key");
|
||||
|
||||
/**
|
||||
* `?status=` filter on the list endpoint. Accepts one status or a
|
||||
* comma-separated set (e.g. `due,surfaced` for the in-app tile). Each token is
|
||||
* validated against the closed status enum; an unknown token 422s.
|
||||
*/
|
||||
export const coachRemindersListQuerySchema = z.object({
|
||||
status: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((v) =>
|
||||
v
|
||||
? v
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: undefined,
|
||||
)
|
||||
.pipe(z.array(z.enum(COACH_REMINDER_STATUSES)).min(1).optional()),
|
||||
});
|
||||
|
||||
/**
|
||||
* Manual create (the ledger "+ add a reminder" path). The note is the only
|
||||
* free text; `when` is the closed grammar (resolved server-side into the
|
||||
* trigger), `metric` optional. Strict: unknown keys 422.
|
||||
*/
|
||||
export const coachReminderCreateSchema = z
|
||||
.object({
|
||||
note: z.string().trim().min(1).max(REMINDER_NOTE_MAX_CHARS),
|
||||
when: whenToken.optional(),
|
||||
metric: metricToken.optional(),
|
||||
})
|
||||
.strict()
|
||||
.meta({
|
||||
id: "CoachReminderCreate",
|
||||
description:
|
||||
"Create a Coach reminder manually. `note` is free text (the only writable content). `when` is an optional closed grammar token — an ISO date (YYYY-MM-DD), a relative offset (+Nd / +Nw), or a context cue (NEXT_BP_LOGGED, NEXT_WEIGHT_LOGGED, NEXT_SLEEP_LOGGED, NEXT_APP_OPEN) — resolved into the trigger server-side. Omit `when` for a recall-only note. Strict: unknown keys 422.",
|
||||
});
|
||||
|
||||
/**
|
||||
* The lifecycle PATCH. `status` confirms a proposed reminder (→ active), marks
|
||||
* it done / dismissed, or re-activates it. `when` optionally re-schedules
|
||||
* (snooze) via the same closed grammar. The body never carries the note text,
|
||||
* so a client can never overwrite a reminder's prose — only its lifecycle.
|
||||
*/
|
||||
export const coachReminderPatchSchema = z
|
||||
.object({
|
||||
status: z.enum(COACH_REMINDER_STATUSES).optional(),
|
||||
when: whenToken.nullable().optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(v) => v.status !== undefined || v.when !== undefined,
|
||||
"At least one of status or when must be provided",
|
||||
)
|
||||
.meta({
|
||||
id: "CoachReminderPatch",
|
||||
description:
|
||||
"Update a Coach reminder's lifecycle. `status` confirms (proposed → active), or marks done / dismissed. `when` re-schedules via the closed grammar (null clears the due moment). Never carries the note text. Strict: unknown keys 422.",
|
||||
});
|
||||
|
||||
// ── Suggested-action confirm endpoint (F6) ──────────────────────────────
|
||||
|
||||
const checkupParams = z.object({
|
||||
actionType: z.literal("checkup.create"),
|
||||
label: z.string().trim().min(1).max(CHECKUP_LABEL_MAX_CHARS),
|
||||
interval: z.enum(["yearly", "halfYearly", "quarterly", "monthly"]),
|
||||
});
|
||||
|
||||
const reminderNoteParams = z.object({
|
||||
actionType: z.literal("reminder.note"),
|
||||
note: z.string().trim().min(1).max(ACTION_NOTE_MAX_CHARS),
|
||||
when: whenToken.optional(),
|
||||
metric: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(ACTION_METRIC_MAX_CHARS)
|
||||
.regex(/^[A-Za-z][A-Za-z0-9_]*$/)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* The confirm-card POST body. `actionType` names a closed-allowlist action; the
|
||||
* params are a discriminated union, validated field-by-field. The server resolves
|
||||
* every schedule from a closed catalog and builds the entity field-by-field —
|
||||
* the client can never mint an arbitrary entity, widen a cadence, or touch a
|
||||
* medication / clinical target.
|
||||
*/
|
||||
export const coachSuggestedActionSchema = z
|
||||
.discriminatedUnion("actionType", [checkupParams, reminderNoteParams])
|
||||
.meta({
|
||||
id: "CoachSuggestedAction",
|
||||
description:
|
||||
"Confirm a Coach action card. `actionType` is from the closed allowlist (checkup.create, reminder.note); NEVER a medication or clinical change. `checkup.create` builds a preventive-care Vorsorge reminder (free-text label + a closed interval id resolved to an RRULE server-side); `reminder.note` builds a CoachReminder (note + optional closed `when` grammar + optional metric). Nothing is created without this explicit confirm.",
|
||||
});
|
||||
|
||||
export type CoachSuggestedActionInput = z.infer<
|
||||
typeof coachSuggestedActionSchema
|
||||
>;
|
||||
|
||||
/** Stable list of confirmable action types (mirrors the lib allowlist). */
|
||||
export const COACH_SUGGESTED_ACTION_TYPES = SUGGESTED_ACTION_TYPES;
|
||||
Reference in new issue
Block a user