Files
HealthLog/src/lib/validations/coach-plan.ts
T
Marc-André Bombeck acd2f29da2 feat(coach): scope filter + conversation provenance on the plans list
Groundwork for the plan confirm/manage UI. The CoachPlan pipeline has been
complete on the server since v1.21.3/v1.22 — extractor writes proposals
(status "proposed", per-user cap, de-dup, encrypted prose), the PATCH
confirms proposed -> active, buildCoachPlansBlock injects the top-6 active
plans into the snapshot, and the review sweep drives the reviewDate
lifecycle — but no surface ever listed or confirmed a proposal, so the
extraction spend had no user-visible outcome.

Two additive contract changes the UI needs:
- GET /api/coach/plans accepts ?scope=open|past|all (open = proposed +
  active + review_due, past = met + abandoned + reviewed, all = no status
  clause) so the management page reads its ledger in one round-trip.
  Mutually exclusive with ?status=; both omitted keeps today's
  non-terminal default unchanged.
- Plan items now carry sourceConversationId so the chat thread can show
  only the proposals born in the open conversation.

OpenAPI regenerated; route tests cover the scope mappings, the
status+scope 422, and the provenance passthrough.
2026-07-05 19:45:20 +02:00

58 lines
2.3 KiB
TypeScript

/**
* v1.21.3 (B1) — request schemas for the Coach goal / if-then plan surface
* (`/api/coach/plans` + `/api/coach/plans/[id]`).
*
* Lives outside the route files so the OpenAPI registry can import it without
* touching the route modules (route files may only export handlers + config)
* and without dragging the Prisma-backed helper module into the generator
* script.
*
* No `userId` field anywhere — the owner is always narrowed from the session.
* The extractor is the only writer of plan TEXT; this surface only lets the
* user CONFIRM (proposed → active), mark a plan met / abandoned, set a review
* date, or soft-delete. It never accepts the encrypted free-text fields, so a
* client can never inject or overwrite a plan's prose.
*/
import { z } from "zod/v4";
import { COACH_PLAN_STATUSES } from "@/lib/ai/coach/plans";
/**
* Optional `?scope=` filter on the list endpoint — a named status group so
* the plans management surface can pull its whole ledger in one read:
* - `open`: proposed + active + review_due (everything still standing)
* - `past`: met + abandoned + reviewed (the settled history)
* - `all`: every non-deleted plan
* Additive next to `?status=`; the two are mutually exclusive per request.
*/
export const COACH_PLAN_SCOPES = ["open", "past", "all"] as const;
/** Optional `?status=` / `?scope=` filters on the list endpoint. */
export const coachPlansListQuerySchema = z
.object({
status: z.enum(COACH_PLAN_STATUSES).optional(),
scope: z.enum(COACH_PLAN_SCOPES).optional(),
})
.refine(
(v) => !(v.status !== undefined && v.scope !== undefined),
"Provide either status or scope, not both",
);
/**
* The user-facing PATCH body. `status` is the only mutable lifecycle field; a
* `proposed` plan moves to `active` (confirm), or a plan moves to `met` /
* `abandoned`. `reviewDate` optionally pins / clears a check-in checkpoint
* (null clears). Strict: unknown keys 422, so a client cannot smuggle a
* userId, a metric, or the encrypted text.
*/
export const coachPlanPatchSchema = z
.object({
status: z.enum(COACH_PLAN_STATUSES).optional(),
reviewDate: z.iso.datetime({ offset: true }).nullable().optional(),
})
.strict()
.refine(
(v) => v.status !== undefined || v.reviewDate !== undefined,
"At least one of status or reviewDate must be provided",
);