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

+25 -5
View File
@@ -5205,10 +5205,11 @@ paths:
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.
`?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
in: query
@@ -5222,7 +5223,19 @@ paths:
- abandoned
- review_due
- reviewed
description: Filter to a single lifecycle status. Omit for the non-terminal set (proposed + active).
description: 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:
- open
- past
- all
description: "Named status group: open (proposed + active + review_due), past (met + abandoned + reviewed), or all.
Mutually exclusive with `status`."
responses:
"200":
description: The caller's plans.
@@ -20461,6 +20474,12 @@ components:
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"
description: Optional check-in checkpoint; null when none.
sourceConversationId:
anyOf:
- type: string
- type: "null"
description: 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:
type: string
format: date-time
@@ -20477,6 +20496,7 @@ components:
- target
- status
- reviewDate
- sourceConversationId
- createdAt
- updatedAt
additionalProperties: false
+3
View File
@@ -117,6 +117,7 @@ export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
targetEncrypted: true,
status: true,
reviewDate: true,
sourceConversationId: true,
createdAt: true,
updatedAt: true,
},
@@ -148,6 +149,7 @@ export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
target: null,
status: row.status,
reviewDate: row.reviewDate?.toISOString() ?? null,
sourceConversationId: row.sourceConversationId,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
},
@@ -177,6 +179,7 @@ export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
target,
status: row.status,
reviewDate: row.reviewDate?.toISOString() ?? null,
sourceConversationId: row.sourceConversationId,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
},
@@ -175,11 +175,73 @@ describe("GET /api/coach/plans", () => {
expect(where.status).toBe("proposed");
});
it("maps ?scope=open to the standing set incl. review_due", async () => {
vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([] as never);
await callGet("http://localhost/api/coach/plans?scope=open");
const where = vi.mocked(prisma.coachPlan.findMany).mock.calls[0]?.[0]
?.where as { status: unknown };
expect(where.status).toEqual({
in: ["proposed", "active", "review_due"],
});
});
it("maps ?scope=past to the settled set", async () => {
vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([] as never);
await callGet("http://localhost/api/coach/plans?scope=past");
const where = vi.mocked(prisma.coachPlan.findMany).mock.calls[0]?.[0]
?.where as { status: unknown };
expect(where.status).toEqual({ in: ["met", "abandoned", "reviewed"] });
});
it("drops the status clause entirely for ?scope=all", async () => {
vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([] as never);
await callGet("http://localhost/api/coach/plans?scope=all");
const where = vi.mocked(prisma.coachPlan.findMany).mock.calls[0]?.[0]
?.where as Record<string, unknown>;
expect(where.userId).toBe("user-1");
expect(where.deletedAt).toBeNull();
expect("status" in where).toBe(false);
});
it("422s when both ?status= and ?scope= are given", async () => {
const res = await callGet(
"http://localhost/api/coach/plans?status=active&scope=all",
);
expect(res.status).toBe(422);
});
it("422s on an invalid ?status=", async () => {
const res = await callGet("http://localhost/api/coach/plans?status=bogus");
expect(res.status).toBe(422);
});
it("422s on an invalid ?scope=", async () => {
const res = await callGet("http://localhost/api/coach/plans?scope=bogus");
expect(res.status).toBe(422);
});
it("carries the source conversation id through to the item", async () => {
vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([
{
id: "p1",
metric: "WEIGHT",
ifCueEncrypted: bytes("every morning"),
thenActionEncrypted: bytes("weigh in"),
targetEncrypted: null,
status: "proposed",
reviewDate: null,
sourceConversationId: "conv-9",
createdAt: new Date(),
updatedAt: new Date(),
},
] as never);
const res = await callGet();
const body = (await res.json()) as {
data: { plans: Array<{ sourceConversationId: string | null }> };
};
expect(body.data.plans[0]?.sourceConversationId).toBe("conv-9");
});
it("skips an undecryptable row rather than 500ing", async () => {
vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([
{
+24 -4
View File
@@ -17,7 +17,9 @@
* The owner is always narrowed from the session, never the body.
*
* `?status=proposed|active|met|abandoned` optionally filters the list (e.g.
* the composer fetches `proposed` to surface confirm cards). Omitted returns
* the chat thread fetches `proposed` to surface confirm cards). `?scope=`
* pulls a named status group instead (`open` | `past` | `all`) so the plans
* management page reads its whole ledger in one round-trip. Omitted returns
* the non-terminal set (proposed + active), newest first.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
@@ -28,6 +30,12 @@ import { requireModuleEnabled } from "@/lib/modules/gate";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
import { coachPlansListQuerySchema } from "@/lib/validations/coach-plan";
/** The status sets behind the named `?scope=` groups (`all` = no filter). */
const SCOPE_STATUSES: Record<"open" | "past", string[]> = {
open: ["proposed", "active", "review_due"],
past: ["met", "abandoned", "reviewed"],
};
export const GET = apiHandler(async (req: Request) => {
const { user } = await requireAuth();
// Coach module gate (operator availability + disableCoach), mirroring the
@@ -38,16 +46,23 @@ export const GET = apiHandler(async (req: Request) => {
const url = new URL(req.url);
const parsed = coachPlansListQuerySchema.safeParse({
status: url.searchParams.get("status") ?? undefined,
scope: url.searchParams.get("scope") ?? undefined,
});
if (!parsed.success) {
return returnAllZodIssues(parsed.error, 422);
}
// No status filter → the non-terminal set (the actionable plans). An
// explicit status filters to exactly that status.
// No filter → the non-terminal set (the actionable plans). An explicit
// status filters to exactly that status; a scope selects a named group
// (`all` drops the status clause entirely).
const scope = parsed.data.scope;
const statusWhere = parsed.data.status
? { status: parsed.data.status }
: { status: { in: ["proposed", "active"] } };
: scope === "all"
? {}
: scope
? { status: { in: SCOPE_STATUSES[scope] } }
: { status: { in: ["proposed", "active"] } };
const rows = await prisma.coachPlan.findMany({
where: { userId: user.id, deletedAt: null, ...statusWhere },
@@ -60,6 +75,7 @@ export const GET = apiHandler(async (req: Request) => {
targetEncrypted: true,
status: true,
reviewDate: true,
sourceConversationId: true,
createdAt: true,
updatedAt: true,
},
@@ -73,6 +89,7 @@ export const GET = apiHandler(async (req: Request) => {
target: string | null;
status: string;
reviewDate: string | null;
sourceConversationId: string | null;
createdAt: string;
updatedAt: string;
}> = [];
@@ -104,6 +121,9 @@ export const GET = apiHandler(async (req: Request) => {
target,
status: row.status,
reviewDate: row.reviewDate?.toISOString() ?? null,
// Provenance for the chat surface: the thread only shows proposal
// cards born in the OPEN conversation, never strays from another one.
sourceConversationId: row.sourceConversationId,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
});
+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: {
+20 -4
View File
@@ -17,10 +17,26 @@ import { z } from "zod/v4";
import { COACH_PLAN_STATUSES } from "@/lib/ai/coach/plans";
/** Optional `?status=` filter on the list endpoint. */
export const coachPlansListQuerySchema = z.object({
status: z.enum(COACH_PLAN_STATUSES).optional(),
});
/**
* 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