feat(coach): facts management routes (list + forget)

Add the GDPR/trust surface for the durable Coach facts: GET lists the
caller's active facts decrypted, DELETE /[id] soft-deletes one, and a
bulk DELETE clears all — every verb apiHandler + requireAuth +
requireAssistantSurface("coach") + ownership-scoped, with an unknown or
cross-user id collapsing to an idempotent no-op that never leaks
existence. Registered in the OpenAPI contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-04 10:29:18 +02:00
1 parent 0a5ba025c8
commit 8e2e697370
6 files changed
+759

No files matched your search

@@ -267,6 +267,7 @@ describe("GET /api/analytics", () => {
vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([
{ n: BigInt(0) },
] as never);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(prisma.$queryRawUnsafe as any)
.mockResolvedValueOnce([
{
@@ -0,0 +1,57 @@
/**
* DELETE /api/insights/coach/facts/[id] — soft-delete one Coach fact.
*
* v1.11.1 — the per-fact "forget this one thing" delete for the durable
* Coach facts surface.
*
* Ownership + existence privacy: the soft-delete uses `updateMany` scoped
* `where: { id, userId, deletedAt: null }` rather than `update`. A
* cross-user id, an unknown id, or an already-deleted fact all resolve to
* a `count: 0` no-op — the route returns `200 { deleted: false }` and
* never reveals whether the id exists under another account. The matching
* convention is the idempotent-delete one used elsewhere in the tree
* (a not-found delete is a successful no-op, not a 404).
*
* Coach-gated: same `requireAssistantSurface("coach")` kill-switch as the
* collection route.
*/
import type { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { requireAssistantSurface } from "@/lib/feature-flags";
interface RouteCtx {
params: Promise<{ id: string }>;
}
export const DELETE = apiHandler(
async (_request: NextRequest, ctx: RouteCtx) => {
const { user } = await requireAuth();
await requireAssistantSurface("coach");
const { id } = await ctx.params;
// `updateMany` (not `update`) so an unknown / cross-user / already-soft-
// deleted id is a 0-count no-op rather than a P2025 throw — the
// existence channel never leaks across accounts.
const { count } = await prisma.coachFact.updateMany({
where: { id, userId: user.id, deletedAt: null },
data: { deletedAt: new Date() },
});
const deleted = count > 0;
annotate({
action: { name: "coach.facts.deleted" },
meta: { deleted },
});
return apiSuccess({ deleted });
},
);
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
@@ -0,0 +1,332 @@
/**
* v1.11.1 — `/api/insights/coach/facts` (+ `/[id]`) routes.
*
* The GDPR / "forget what you know about me" management surface for the
* durable Coach facts. Covers:
* - GET lists only ACTIVE facts (deletedAt: null), decrypted, scoped to
* the caller, highest-confidence-then-newest ordered.
* - GET skips an undecryptable row rather than 500ing the whole list.
* - bulk DELETE soft-deletes all active facts (sets deletedAt) and
* returns the count.
* - [id] DELETE soft-deletes the caller's own fact, and a cross-user /
* unknown id is a 0-count no-op that returns `{ deleted: false }`.
* - the `requireAssistantSurface("coach")` gate is present on every verb.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
coachFact: {
findMany: vi.fn(),
updateMany: vi.fn(),
},
},
}));
vi.mock("@/lib/auth/session", () => ({
getSession: vi.fn(),
}));
// v1.4.31 — `requireAssistantSurface()` gates near the top of each
// handler. Mock the module boundary so flag reads are deterministic; the
// gate-presence assertions below verify the call is actually made.
vi.mock("@/lib/feature-flags", () => ({
requireAssistantSurface: vi.fn(async () => undefined),
AssistantDisabledError: class extends Error {},
}));
// Mock the codec so the test never needs an encryption key. The "row"
// carries a tagged Uint8Array and the mock maps it back to a string;
// a sentinel buffer triggers a throw to exercise the fail-closed skip.
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, DELETE } from "../route";
import { DELETE as DELETE_ONE } from "../[id]/route";
import { prisma } from "@/lib/db";
import { getSession } from "@/lib/auth/session";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { annotate } from "@/lib/logging/context";
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,
},
};
const callGet = GET as unknown as () => Promise<Response>;
const callDeleteAll = DELETE as unknown as () => Promise<Response>;
const callDeleteOne = DELETE_ONE as unknown as (
req: NextRequest,
ctx: { params: Promise<{ id: string }> },
) => Promise<Response>;
function bytes(tag: string): Uint8Array {
return new Uint8Array(Buffer.from(tag, "utf8"));
}
function deleteReq(): NextRequest {
return new NextRequest("http://localhost/api/insights/coach/facts/x", {
method: "DELETE",
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(requireAssistantSurface).mockResolvedValue(undefined as never);
});
describe("GET /api/insights/coach/facts", () => {
it("lists the caller's active facts, decrypted, scoped to the user", async () => {
vi.mocked(prisma.coachFact.findMany).mockResolvedValue([
{
id: "f1",
category: "goal",
factEncrypted: bytes("lose 5kg"),
confidence: 90,
createdAt: new Date("2026-06-01T00:00:00Z"),
},
{
id: "f2",
category: "preference",
factEncrypted: bytes("morning workouts"),
confidence: 70,
createdAt: 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: {
facts: Array<{
id: string;
category: string;
text: string;
confidence: number;
}>;
};
};
expect(body.data.facts).toHaveLength(2);
expect(body.data.facts[0]).toMatchObject({
id: "f1",
category: "goal",
text: "decrypted:lose 5kg",
confidence: 90,
});
expect(body.data.facts[1]?.text).toBe("decrypted:morning workouts");
// Ownership-scoped + active-only query.
const where = vi.mocked(prisma.coachFact.findMany).mock.calls[0]?.[0]
?.where as { userId: string; deletedAt: null };
expect(where.userId).toBe("user-1");
expect(where.deletedAt).toBeNull();
});
it("orders highest-confidence then newest", async () => {
vi.mocked(prisma.coachFact.findMany).mockResolvedValue([] as never);
await callGet();
const orderBy = vi.mocked(prisma.coachFact.findMany).mock.calls[0]?.[0]
?.orderBy;
expect(orderBy).toEqual([
{ confidence: "desc" },
{ createdAt: "desc" },
]);
});
it("skips an undecryptable row rather than 500ing the whole list", async () => {
vi.mocked(prisma.coachFact.findMany).mockResolvedValue([
{
id: "f1",
category: "goal",
factEncrypted: bytes("__undecryptable__"),
confidence: 90,
createdAt: new Date("2026-06-01T00:00:00Z"),
},
{
id: "f2",
category: "context",
factEncrypted: bytes("works night shifts"),
confidence: 60,
createdAt: 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: { facts: Array<{ id: string; text: string }> };
};
// The undecryptable row is dropped; the good one survives.
expect(body.data.facts).toHaveLength(1);
expect(body.data.facts[0]?.id).toBe("f2");
expect(body.data.facts[0]?.text).toBe("decrypted:works night shifts");
});
it("annotates coach.facts.listed with the count only", async () => {
vi.mocked(prisma.coachFact.findMany).mockResolvedValue([
{
id: "f1",
category: "goal",
factEncrypted: bytes("a"),
confidence: 50,
createdAt: new Date(),
},
] as never);
await callGet();
const call = vi
.mocked(annotate)
.mock.calls.find(
(c) =>
(c[0] as { action?: { name?: string } })?.action?.name ===
"coach.facts.listed",
);
expect(call).toBeTruthy();
expect((call![0] as { meta?: { count?: number } }).meta).toEqual({
count: 1,
});
});
it("invokes the coach assistant-surface gate", async () => {
vi.mocked(prisma.coachFact.findMany).mockResolvedValue([] as never);
await callGet();
expect(requireAssistantSurface).toHaveBeenCalledWith("coach");
});
});
describe("DELETE /api/insights/coach/facts — forget all", () => {
it("soft-deletes all active facts and returns the count", async () => {
vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({
count: 3,
} as never);
const res = await callDeleteAll();
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { cleared: number } };
expect(body.data.cleared).toBe(3);
const arg = vi.mocked(prisma.coachFact.updateMany).mock.calls[0]?.[0] as {
where: { userId: string; deletedAt: null };
data: { deletedAt: Date };
};
expect(arg.where.userId).toBe("user-1");
expect(arg.where.deletedAt).toBeNull();
// Soft-delete sets a deletedAt timestamp; the row is not removed.
expect(arg.data.deletedAt).toBeInstanceOf(Date);
});
it("is idempotent — a second clear returns 0", async () => {
vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({
count: 0,
} as never);
const res = await callDeleteAll();
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { cleared: number } };
expect(body.data.cleared).toBe(0);
});
it("invokes the coach assistant-surface gate", async () => {
vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({
count: 0,
} as never);
await callDeleteAll();
expect(requireAssistantSurface).toHaveBeenCalledWith("coach");
});
});
describe("DELETE /api/insights/coach/facts/[id] — forget one", () => {
it("soft-deletes the caller's own fact and returns deleted:true", async () => {
vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({
count: 1,
} as never);
const res = await callDeleteOne(deleteReq(), {
params: Promise.resolve({ id: "f1" }),
});
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.coachFact.updateMany).mock.calls[0]?.[0] as {
where: { id: string; userId: string; deletedAt: null };
data: { deletedAt: Date };
};
// Scoped by BOTH id and userId, active-only.
expect(arg.where.id).toBe("f1");
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 () => {
// updateMany scoped to { id, userId } matches no row owned by the
// caller → count 0 → no other user's row is ever touched.
vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({
count: 0,
} as never);
const res = await callDeleteOne(deleteReq(), {
params: Promise.resolve({ id: "someone-elses-fact" }),
});
expect(res.status).toBe(200);
const body = (await res.json()) as { data: { deleted: boolean } };
expect(body.data.deleted).toBe(false);
// The where clause pins userId so the query can never reach a
// foreign row regardless of the id supplied.
const arg = vi.mocked(prisma.coachFact.updateMany).mock.calls[0]?.[0] as {
where: { userId: string };
};
expect(arg.where.userId).toBe("user-1");
});
it("invokes the coach assistant-surface gate", async () => {
vi.mocked(prisma.coachFact.updateMany).mockResolvedValue({
count: 0,
} as never);
await callDeleteOne(deleteReq(), {
params: Promise.resolve({ id: "f1" }),
});
expect(requireAssistantSurface).toHaveBeenCalledWith("coach");
});
});
+102
View File
@@ -0,0 +1,102 @@
/**
* GET /api/insights/coach/facts — list the caller's ACTIVE Coach facts.
* DELETE /api/insights/coach/facts — "forget all": soft-delete every active
* fact for the caller.
*
* v1.11.1 — the GDPR / "forget what you know about me" surface for the
* durable Coach facts the assistant extracts during conversations. Facts
* are server-extracted, not user-authored, so this surface is read +
* delete only (no POST / PATCH).
*
* Ownership: every query is scoped `where: { userId, ... }`, so a caller
* can only ever see or clear their own facts. The fact text is decrypted
* on the fly; an undecryptable row (e.g. a key rotated out of the map) is
* skipped rather than 500ing the whole list — the surface stays available
* for the rows that DO decrypt.
*
* Coach-gated: a fact only exists because the Coach extracted it, so the
* management surface sits behind the same `requireAssistantSurface("coach")`
* kill-switch as the rest of the Coach stack.
*/
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiSuccess } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { prisma } from "@/lib/db";
import { requireAssistantSurface } from "@/lib/feature-flags";
import { decryptFromBytes } from "@/lib/ai/coach/bytes-codec";
export const GET = apiHandler(async () => {
const { user } = await requireAuth();
await requireAssistantSurface("coach");
const rows = await prisma.coachFact.findMany({
where: { userId: user.id, deletedAt: null },
// Highest-confidence first, then newest — mirrors the injection
// ordering so the management list reads in the same priority the
// assistant actually weights the facts.
orderBy: [{ confidence: "desc" }, { createdAt: "desc" }],
select: {
id: true,
category: true,
factEncrypted: true,
confidence: true,
createdAt: true,
},
});
const facts: Array<{
id: string;
category: string;
text: string;
confidence: number;
createdAt: Date;
}> = [];
for (const row of rows) {
let text: string;
try {
text = decryptFromBytes(row.factEncrypted);
} 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;
}
facts.push({
id: row.id,
category: row.category,
text,
confidence: row.confidence,
createdAt: row.createdAt,
});
}
annotate({
action: { name: "coach.facts.listed" },
meta: { count: facts.length },
});
return apiSuccess({ facts });
});
export const DELETE = apiHandler(async () => {
const { user } = await requireAuth();
await requireAssistantSurface("coach");
// Soft-delete every active fact — keeps the rows for audit while
// hiding them from injection. `updateMany` scoped to the caller can
// never touch another user's rows.
const { count } = await prisma.coachFact.updateMany({
where: { userId: user.id, deletedAt: null },
data: { deletedAt: new Date() },
});
annotate({
action: { name: "coach.facts.cleared" },
meta: { cleared: count },
});
return apiSuccess({ cleared: count });
});
export const dynamic = "force-dynamic";
export const runtime = "nodejs";