mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
merge: wave/v1213-b1 into release/v1.21.3
This commit is contained in:
commit
a77fafcd0b
20 files changed
+2162
-14
No files matched your search
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* PATCH /api/coach/plans/[id] — confirm / update a Coach plan's lifecycle.
|
||||
* DELETE /api/coach/plans/[id] — soft-delete one Coach plan.
|
||||
*
|
||||
* v1.21.3 (B1) — the user-confirm + management surface for the durable Coach
|
||||
* plans. Coach-proposes-then-user-confirms: the extractor writes a plan as
|
||||
* `status: "proposed"`; this PATCH is the ONLY path that activates it
|
||||
* (proposed → active), or marks it met / abandoned, or sets a review date.
|
||||
* The body never carries the plan's metric or its encrypted free text, so a
|
||||
* client can never inject or overwrite a plan's prose — only its lifecycle.
|
||||
*
|
||||
* Ownership + existence privacy: every mutation is scoped
|
||||
* `where: { id, userId, deletedAt: null }` via `updateMany`, so a cross-user
|
||||
* id, an unknown id, or an already-deleted plan all resolve to a `count: 0`
|
||||
* no-op rather than a P2025 throw — the existence channel never leaks across
|
||||
* accounts. PATCH on a 0-count match returns 404; DELETE on a 0-count match
|
||||
* returns the idempotent `{ deleted: false }`.
|
||||
*
|
||||
* Coach-gated by the same `requireModuleEnabled(userId, "coach")` kill-switch
|
||||
* as the list route (mirrors the about-me routes). Bodies are Zod-parsed; an
|
||||
* invalid body returns the multi-issue 422 envelope.
|
||||
*/
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
returnAllZodIssues,
|
||||
safeJson,
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { coachPlanPatchSchema } from "@/lib/validations/coach-plan";
|
||||
|
||||
interface RouteCtx {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export const PATCH = apiHandler(async (req: NextRequest, ctx: RouteCtx) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { id } = await ctx.params;
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(req, {
|
||||
maxBytes: 4 * 1024,
|
||||
});
|
||||
if (jsonError) return jsonError;
|
||||
|
||||
const parsed = coachPlanPatchSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return returnAllZodIssues(parsed.error, 422);
|
||||
}
|
||||
|
||||
// Field-by-field data assembly (no mass assignment): only the lifecycle
|
||||
// fields the body carried are written; the metric + encrypted text are never
|
||||
// touched by this route.
|
||||
const data: { status?: string; reviewDate?: Date | null } = {};
|
||||
if (parsed.data.status !== undefined) data.status = parsed.data.status;
|
||||
if (parsed.data.reviewDate !== undefined) {
|
||||
data.reviewDate = parsed.data.reviewDate
|
||||
? new Date(parsed.data.reviewDate)
|
||||
: null;
|
||||
}
|
||||
|
||||
// `updateMany` (not `update`) so an unknown / cross-user / already-deleted id
|
||||
// is a 0-count no-op rather than a P2025 throw — the existence channel never
|
||||
// leaks across accounts.
|
||||
const { count } = await prisma.coachPlan.updateMany({
|
||||
where: { id, userId: user.id, deletedAt: null },
|
||||
data,
|
||||
});
|
||||
|
||||
if (count === 0) {
|
||||
// Indistinguishable from "owned by someone else" — never reveal which.
|
||||
return apiError("Plan not found", 404);
|
||||
}
|
||||
|
||||
const row = await prisma.coachPlan.findFirst({
|
||||
where: { id, userId: user.id },
|
||||
select: {
|
||||
id: true,
|
||||
metric: true,
|
||||
ifCueEncrypted: true,
|
||||
thenActionEncrypted: true,
|
||||
targetEncrypted: true,
|
||||
status: true,
|
||||
reviewDate: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!row) {
|
||||
// Should not happen (we just updated it), but fail safe.
|
||||
return apiError("Plan not found", 404);
|
||||
}
|
||||
|
||||
let ifCue: string;
|
||||
let thenAction: string;
|
||||
try {
|
||||
ifCue = decryptFromBytes(row.ifCueEncrypted);
|
||||
thenAction = decryptFromBytes(row.thenActionEncrypted);
|
||||
} catch {
|
||||
// The row updated fine but its key id is no longer in the map — surface the
|
||||
// lifecycle change without the (unreadable) prose rather than 500ing.
|
||||
annotate({
|
||||
action: { name: "coach.plans.updated" },
|
||||
meta: { status: row.status },
|
||||
});
|
||||
return apiSuccess({
|
||||
plan: {
|
||||
id: row.id,
|
||||
metric: row.metric,
|
||||
ifCue: null,
|
||||
thenAction: null,
|
||||
target: null,
|
||||
status: row.status,
|
||||
reviewDate: row.reviewDate?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let target: string | null = null;
|
||||
if (row.targetEncrypted) {
|
||||
try {
|
||||
target = decryptFromBytes(row.targetEncrypted);
|
||||
} catch {
|
||||
target = null;
|
||||
}
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.plans.updated" },
|
||||
meta: { status: row.status },
|
||||
});
|
||||
|
||||
return apiSuccess({
|
||||
plan: {
|
||||
id: row.id,
|
||||
metric: row.metric,
|
||||
ifCue,
|
||||
thenAction,
|
||||
target,
|
||||
status: row.status,
|
||||
reviewDate: row.reviewDate?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export const DELETE = apiHandler(
|
||||
async (_request: NextRequest, ctx: RouteCtx) => {
|
||||
const { user } = await requireAuth();
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const { id } = await ctx.params;
|
||||
|
||||
// `updateMany` scoped by BOTH id and userId, active-only: an unknown /
|
||||
// cross-user / already-deleted id is a 0-count no-op.
|
||||
const { count } = await prisma.coachPlan.updateMany({
|
||||
where: { id, userId: user.id, deletedAt: null },
|
||||
data: { deletedAt: new Date() },
|
||||
});
|
||||
|
||||
const deleted = count > 0;
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.plans.deleted" },
|
||||
meta: { deleted },
|
||||
});
|
||||
|
||||
return apiSuccess({ deleted });
|
||||
},
|
||||
);
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@@ -0,0 +1,316 @@
|
||||
/**
|
||||
* v1.21.3 (B1) — `/api/coach/plans` (+ `/[id]`) routes.
|
||||
*
|
||||
* The management + confirm surface for the durable Coach goal / if-then plans.
|
||||
* Covers:
|
||||
* - GET lists the caller's plans decrypted, scoped to the user, newest first;
|
||||
* the default (no `?status=`) returns the non-terminal set.
|
||||
* - GET skips an undecryptable row rather than 500ing the whole list.
|
||||
* - PATCH confirms proposed → active field-by-field; a 0-count match 404s;
|
||||
* the body never carries the metric or encrypted text.
|
||||
* - [id] DELETE soft-deletes the caller's plan; a cross-user / unknown id is
|
||||
* a 0-count no-op returning `{ deleted: false }`.
|
||||
* - the `requireModuleEnabled(userId, "coach")` gate runs on every verb.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
coachPlan: {
|
||||
findMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
getSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/modules/gate", () => ({
|
||||
requireModuleEnabled: vi.fn(async () => ({ enabled: true })),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/ai/coach/bytes-codec", () => ({
|
||||
decryptFromBytes: vi.fn((buf: Uint8Array) => {
|
||||
const tag = Buffer.from(buf).toString("utf8");
|
||||
if (tag === "__undecryptable__") throw new Error("unknown key id");
|
||||
return `decrypted:${tag}`;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({
|
||||
emitIfSampled: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/logging/context")>();
|
||||
return { ...actual, annotate: vi.fn() };
|
||||
});
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { PATCH, DELETE as DELETE_ONE } from "../[id]/route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
|
||||
const SESSION_OK = {
|
||||
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
|
||||
user: {
|
||||
id: "user-1",
|
||||
username: "tester",
|
||||
role: "USER" as const,
|
||||
displayName: null,
|
||||
},
|
||||
};
|
||||
|
||||
function bytes(tag: string): Uint8Array {
|
||||
return new Uint8Array(Buffer.from(tag, "utf8"));
|
||||
}
|
||||
|
||||
const callGet = (url = "http://localhost/api/coach/plans") =>
|
||||
(GET as unknown as (req: Request) => Promise<Response>)(new Request(url));
|
||||
|
||||
function patchReq(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost/api/coach/plans/x", {
|
||||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const callPatch = (body: unknown, id = "p1") =>
|
||||
(
|
||||
PATCH as unknown as (
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ id: string }> },
|
||||
) => Promise<Response>
|
||||
)(patchReq(body), { params: Promise.resolve({ id }) });
|
||||
|
||||
const callDeleteOne = (id = "p1") =>
|
||||
(
|
||||
DELETE_ONE as unknown as (
|
||||
req: NextRequest,
|
||||
ctx: { params: Promise<{ id: string }> },
|
||||
) => Promise<Response>
|
||||
)(
|
||||
new NextRequest("http://localhost/api/coach/plans/x", { method: "DELETE" }),
|
||||
{ params: Promise.resolve({ id }) },
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(requireModuleEnabled).mockResolvedValue({ enabled: true } as never);
|
||||
});
|
||||
|
||||
describe("GET /api/coach/plans", () => {
|
||||
it("lists the caller's plans, decrypted, scoped to the user", async () => {
|
||||
vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "p1",
|
||||
metric: "WEIGHT",
|
||||
ifCueEncrypted: bytes("every morning"),
|
||||
thenActionEncrypted: bytes("weigh in"),
|
||||
targetEncrypted: bytes("70 kg"),
|
||||
status: "active",
|
||||
reviewDate: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-02T00:00:00Z"),
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await callGet();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { plans: Array<Record<string, unknown>> };
|
||||
};
|
||||
expect(body.data.plans).toHaveLength(1);
|
||||
expect(body.data.plans[0]).toMatchObject({
|
||||
id: "p1",
|
||||
metric: "WEIGHT",
|
||||
ifCue: "decrypted:every morning",
|
||||
thenAction: "decrypted:weigh in",
|
||||
target: "decrypted:70 kg",
|
||||
status: "active",
|
||||
});
|
||||
|
||||
const where = vi.mocked(prisma.coachPlan.findMany).mock.calls[0]?.[0]
|
||||
?.where as { userId: string; deletedAt: null; status: unknown };
|
||||
expect(where.userId).toBe("user-1");
|
||||
expect(where.deletedAt).toBeNull();
|
||||
// Default = the non-terminal set.
|
||||
expect(where.status).toEqual({ in: ["proposed", "active"] });
|
||||
});
|
||||
|
||||
it("filters by an explicit ?status=", async () => {
|
||||
vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([] as never);
|
||||
await callGet("http://localhost/api/coach/plans?status=proposed");
|
||||
const where = vi.mocked(prisma.coachPlan.findMany).mock.calls[0]?.[0]
|
||||
?.where as { status: unknown };
|
||||
expect(where.status).toBe("proposed");
|
||||
});
|
||||
|
||||
it("422s on an invalid ?status=", async () => {
|
||||
const res = await callGet("http://localhost/api/coach/plans?status=bogus");
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("skips an undecryptable row rather than 500ing", async () => {
|
||||
vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([
|
||||
{
|
||||
id: "p1",
|
||||
metric: "SLEEP",
|
||||
ifCueEncrypted: bytes("__undecryptable__"),
|
||||
thenActionEncrypted: bytes("lights out"),
|
||||
targetEncrypted: null,
|
||||
status: "active",
|
||||
reviewDate: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
metric: "STEPS",
|
||||
ifCueEncrypted: bytes("after lunch"),
|
||||
thenActionEncrypted: bytes("walk"),
|
||||
targetEncrypted: null,
|
||||
status: "active",
|
||||
reviewDate: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
] as never);
|
||||
|
||||
const res = await callGet();
|
||||
const body = (await res.json()) as {
|
||||
data: { plans: Array<{ id: string }> };
|
||||
};
|
||||
expect(body.data.plans).toHaveLength(1);
|
||||
expect(body.data.plans[0]?.id).toBe("p2");
|
||||
});
|
||||
|
||||
it("invokes the coach module gate", async () => {
|
||||
vi.mocked(prisma.coachPlan.findMany).mockResolvedValue([] as never);
|
||||
await callGet();
|
||||
expect(requireModuleEnabled).toHaveBeenCalledWith("user-1", "coach");
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /api/coach/plans/[id]", () => {
|
||||
it("confirms proposed → active, scoped to the caller, field-by-field", async () => {
|
||||
vi.mocked(prisma.coachPlan.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.coachPlan.findFirst).mockResolvedValue({
|
||||
id: "p1",
|
||||
metric: "WEIGHT",
|
||||
ifCueEncrypted: bytes("every morning"),
|
||||
thenActionEncrypted: bytes("weigh in"),
|
||||
targetEncrypted: null,
|
||||
status: "active",
|
||||
reviewDate: null,
|
||||
createdAt: new Date("2026-06-01T00:00:00Z"),
|
||||
updatedAt: new Date("2026-06-03T00:00:00Z"),
|
||||
} as never);
|
||||
|
||||
const res = await callPatch({ status: "active" });
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as {
|
||||
data: { plan: { status: string; ifCue: string } };
|
||||
};
|
||||
expect(body.data.plan.status).toBe("active");
|
||||
expect(body.data.plan.ifCue).toBe("decrypted:every morning");
|
||||
|
||||
const arg = vi.mocked(prisma.coachPlan.updateMany).mock.calls[0]?.[0] as {
|
||||
where: { id: string; userId: string; deletedAt: null };
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
expect(arg.where.id).toBe("p1");
|
||||
expect(arg.where.userId).toBe("user-1");
|
||||
expect(arg.where.deletedAt).toBeNull();
|
||||
// Only the lifecycle field is written — never metric / encrypted text.
|
||||
expect(Object.keys(arg.data)).toEqual(["status"]);
|
||||
});
|
||||
|
||||
it("404s on a 0-count (unknown / cross-user) match", async () => {
|
||||
vi.mocked(prisma.coachPlan.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
const res = await callPatch({ status: "met" }, "someone-elses");
|
||||
expect(res.status).toBe(404);
|
||||
expect(prisma.coachPlan.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("422s on an empty body (no mutable field)", async () => {
|
||||
const res = await callPatch({});
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("422s on an unknown key (strict)", async () => {
|
||||
const res = await callPatch({ status: "active", metric: "WEIGHT" });
|
||||
expect(res.status).toBe(422);
|
||||
});
|
||||
|
||||
it("invokes the coach module gate", async () => {
|
||||
vi.mocked(prisma.coachPlan.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
await callPatch({ status: "met" });
|
||||
expect(requireModuleEnabled).toHaveBeenCalledWith("user-1", "coach");
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/coach/plans/[id]", () => {
|
||||
it("soft-deletes the caller's own plan and returns deleted:true", async () => {
|
||||
vi.mocked(prisma.coachPlan.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
const res = await callDeleteOne();
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { deleted: boolean } };
|
||||
expect(body.data.deleted).toBe(true);
|
||||
|
||||
const arg = vi.mocked(prisma.coachPlan.updateMany).mock.calls[0]?.[0] as {
|
||||
where: { id: string; userId: string; deletedAt: null };
|
||||
data: { deletedAt: Date };
|
||||
};
|
||||
expect(arg.where.id).toBe("p1");
|
||||
expect(arg.where.userId).toBe("user-1");
|
||||
expect(arg.where.deletedAt).toBeNull();
|
||||
expect(arg.data.deletedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
|
||||
it("returns deleted:false for an unknown / cross-user id (0-count no-op)", async () => {
|
||||
vi.mocked(prisma.coachPlan.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
const res = await callDeleteOne("someone-elses");
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { data: { deleted: boolean } };
|
||||
expect(body.data.deleted).toBe(false);
|
||||
});
|
||||
|
||||
it("invokes the coach module gate", async () => {
|
||||
vi.mocked(prisma.coachPlan.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
await callDeleteOne();
|
||||
expect(requireModuleEnabled).toHaveBeenCalledWith("user-1", "coach");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* GET /api/coach/plans — list the caller's Coach goal / if-then plans.
|
||||
*
|
||||
* v1.21.3 (B1) — the management + confirm surface for the durable plans the
|
||||
* Coach proposes. A plan is an "if-then" implementation intention tied to one
|
||||
* metric, with an optional target. The Coach extractor writes a plan as
|
||||
* `status: "proposed"`; only the user-facing PATCH (`/api/coach/plans/[id]`)
|
||||
* activates it. There is no silent self-edit of the user's plan set.
|
||||
*
|
||||
* Ownership: every query is scoped `where: { userId, ... }`, so a caller can
|
||||
* only ever see their own plans. The free-text fields (if-cue, then-action,
|
||||
* target) are decrypted on the fly; an undecryptable row (a key rotated out of
|
||||
* the map) is skipped rather than 500ing the whole list.
|
||||
*
|
||||
* Coach-gated by the same `requireModuleEnabled(userId, "coach")` kill-switch
|
||||
* the rest of the Coach management stack uses (mirrors the about-me routes).
|
||||
* 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 non-terminal set (proposed + active), newest first.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, returnAllZodIssues } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { requireModuleEnabled } from "@/lib/modules/gate";
|
||||
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
|
||||
import { coachPlansListQuerySchema } from "@/lib/validations/coach-plan";
|
||||
|
||||
export const GET = apiHandler(async (req: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
// Coach module gate (operator availability + disableCoach), mirroring the
|
||||
// about-me management routes.
|
||||
const gate = await requireModuleEnabled(user.id, "coach");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const url = new URL(req.url);
|
||||
const parsed = coachPlansListQuerySchema.safeParse({
|
||||
status: url.searchParams.get("status") ?? 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.
|
||||
const statusWhere = parsed.data.status
|
||||
? { status: parsed.data.status }
|
||||
: { status: { in: ["proposed", "active"] } };
|
||||
|
||||
const rows = await prisma.coachPlan.findMany({
|
||||
where: { userId: user.id, deletedAt: null, ...statusWhere },
|
||||
orderBy: [{ updatedAt: "desc" }],
|
||||
select: {
|
||||
id: true,
|
||||
metric: true,
|
||||
ifCueEncrypted: true,
|
||||
thenActionEncrypted: true,
|
||||
targetEncrypted: true,
|
||||
status: true,
|
||||
reviewDate: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
const plans: Array<{
|
||||
id: string;
|
||||
metric: string;
|
||||
ifCue: string;
|
||||
thenAction: string;
|
||||
target: string | null;
|
||||
status: string;
|
||||
reviewDate: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}> = [];
|
||||
|
||||
for (const row of rows) {
|
||||
let ifCue: string;
|
||||
let thenAction: string;
|
||||
try {
|
||||
ifCue = decryptFromBytes(row.ifCueEncrypted);
|
||||
thenAction = decryptFromBytes(row.thenActionEncrypted);
|
||||
} catch {
|
||||
// Fail closed per row — never surface ciphertext, never 500 the whole
|
||||
// list because one row's key id is no longer in the map.
|
||||
continue;
|
||||
}
|
||||
let target: string | null = null;
|
||||
if (row.targetEncrypted) {
|
||||
try {
|
||||
target = decryptFromBytes(row.targetEncrypted);
|
||||
} catch {
|
||||
target = null;
|
||||
}
|
||||
}
|
||||
plans.push({
|
||||
id: row.id,
|
||||
metric: row.metric,
|
||||
ifCue,
|
||||
thenAction,
|
||||
target,
|
||||
status: row.status,
|
||||
reviewDate: row.reviewDate?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "coach.plans.listed" },
|
||||
meta: { count: plans.length },
|
||||
});
|
||||
|
||||
return apiSuccess({ plans });
|
||||
});
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
Reference in new issue
Block a user