diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 88bc29ec8..f1b0c17aa 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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 diff --git a/src/app/api/coach/plans/[id]/route.ts b/src/app/api/coach/plans/[id]/route.ts index ceacd6df3..f89226ee8 100644 --- a/src/app/api/coach/plans/[id]/route.ts +++ b/src/app/api/coach/plans/[id]/route.ts @@ -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(), }, diff --git a/src/app/api/coach/plans/__tests__/route.test.ts b/src/app/api/coach/plans/__tests__/route.test.ts index 53bfc41c1..a41f9d075 100644 --- a/src/app/api/coach/plans/__tests__/route.test.ts +++ b/src/app/api/coach/plans/__tests__/route.test.ts @@ -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; + 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([ { diff --git a/src/app/api/coach/plans/route.ts b/src/app/api/coach/plans/route.ts index 3a2ccd2a7..998ed9fe5 100644 --- a/src/app/api/coach/plans/route.ts +++ b/src/app/api/coach/plans/route.ts @@ -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(), }); diff --git a/src/lib/openapi/routes/coach.ts b/src/lib/openapi/routes/coach.ts index d8b172e8f..b33733c40 100644 --- a/src/lib/openapi/routes/coach.ts +++ b/src/lib/openapi/routes/coach.ts @@ -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 = { 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 = { 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: { diff --git a/src/lib/validations/coach-plan.ts b/src/lib/validations/coach-plan.ts index d30f0c328..75b37f4a3 100644 --- a/src/lib/validations/coach-plan.ts +++ b/src/lib/validations/coach-plan.ts @@ -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