mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +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
@@ -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(),
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user