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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-05 19:45:20 +02:00
1 parent 4d0f16d5a9
commit acd2f29da2
6 files changed
+152 -16

No files matched your search

+18 -3
View File
@@ -15,6 +15,7 @@ import {
import { COACH_FACT_CATEGORIES } from "@/lib/ai/coach/facts";
import { COACH_PLAN_STATUSES } from "@/lib/ai/coach/plans";
import {
COACH_PLAN_SCOPES,
coachPlanPatchSchema,
coachPlansListQuerySchema,
} from "@/lib/validations/coach-plan";
@@ -283,6 +284,12 @@ const coachPlanItem = z
.datetime({ offset: true })
.nullable()
.describe("Optional check-in checkpoint; null when none."),
sourceConversationId: z
.string()
.nullable()
.describe(
"Id of the conversation the proposal was extracted from; null for a plan without conversation provenance. Lets a chat surface show only the proposals born in the open thread.",
),
createdAt: z.iso.datetime({ offset: true }),
updatedAt: z.iso.datetime({ offset: true }),
})
@@ -321,7 +328,7 @@ coachPlanPatchSchema.meta({
coachPlansListQuerySchema.meta({
id: "CoachPlansListQuery",
description:
"Optional `?status=` filter for the plans list (proposed | active | met | abandoned). Omitted returns the non-terminal set (proposed + active).",
"Optional `?status=` filter for the plans list (a single lifecycle status), or `?scope=` for a named group (open = proposed + active + review_due, past = met + abandoned + reviewed, all = every non-deleted plan). Mutually exclusive. Both omitted returns the non-terminal set (proposed + active).",
});
// ── Coach episodic reminders (v1.22 B2/B6) ──────────────────────────────
@@ -1021,7 +1028,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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.',
'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 to one lifecycle status, or `?scope=` for a named group (open = proposed + active + review_due, past = met + abandoned + reviewed, all = every non-deleted plan) — mutually exclusive. Both 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",
@@ -1029,7 +1036,15 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
required: false,
schema: { type: "string", enum: [...COACH_PLAN_STATUSES] },
description:
"Filter to a single lifecycle status. Omit for the non-terminal set (proposed + active).",
"Filter to a single lifecycle status. Mutually exclusive with `scope`. Omit both for the non-terminal set (proposed + active).",
},
{
name: "scope",
in: "query",
required: false,
schema: { type: "string", enum: [...COACH_PLAN_SCOPES] },
description:
"Named status group: open (proposed + active + review_due), past (met + abandoned + reviewed), or all. Mutually exclusive with `status`.",
},
],
responses: {