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

+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(),
});