mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
4d0f16d5a9
commit
acd2f29da2
6 files changed
+152
-16
No files matched your search
+25
-5
@@ -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
|
||||
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user