mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
0a5ba025c8
commit
8e2e697370
6 files changed
+759
No files matched your search
@@ -2407,6 +2407,59 @@ paths:
|
||||
"401": *a1
|
||||
"422": *a2
|
||||
"429": *a3
|
||||
/api/insights/coach/facts:
|
||||
get:
|
||||
tags:
|
||||
- Insights
|
||||
summary: List the caller's durable Coach facts
|
||||
description: v1.11.1 — returns the active facts the Coach has extracted about the caller (highest-confidence then newest
|
||||
first), each decrypted on the fly. The GDPR 'what do you know about me' surface. Coach-gated
|
||||
(`requireAssistantSurface("coach")`). 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.
|
||||
responses:
|
||||
"200":
|
||||
description: The caller's active facts.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachFactsList"
|
||||
"401": *a1
|
||||
"422": *a2
|
||||
"429": *a3
|
||||
delete:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Forget all of the caller's Coach facts
|
||||
description: "v1.11.1 — bulk 'forget what you know about me': soft-deletes every active fact for the caller and returns
|
||||
the count cleared. Idempotent (a second call clears 0). Coach-gated. Auth via cookie or Bearer."
|
||||
responses:
|
||||
"200":
|
||||
description: All active facts cleared; the count is returned.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachFactsCleared"
|
||||
"401": *a1
|
||||
"422": *a2
|
||||
"429": *a3
|
||||
/api/insights/coach/facts/{id}:
|
||||
delete:
|
||||
tags:
|
||||
- Insights
|
||||
summary: Forget one Coach fact
|
||||
description: "v1.11.1 — soft-deletes a single fact owned by the caller. An unknown / cross-user / already-deleted id is
|
||||
an idempotent no-op returning `{ deleted: false }`, never revealing whether the id exists under another account.
|
||||
Coach-gated. Auth via cookie or Bearer."
|
||||
responses:
|
||||
"200":
|
||||
description: "The fact was soft-deleted (`deleted: true`) or the id matched nothing the caller owns (`deleted: false`)."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/CoachFactDeleted"
|
||||
"401": *a1
|
||||
"422": *a2
|
||||
"429": *a3
|
||||
/api/insights/chat/messages/{id}/feedback:
|
||||
post:
|
||||
tags:
|
||||
@@ -7761,6 +7814,114 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachFactsList:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
facts:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
category:
|
||||
type: string
|
||||
enum:
|
||||
- preference
|
||||
- condition
|
||||
- goal
|
||||
- constraint
|
||||
- context
|
||||
description: "App-side closed category: preference | condition | goal | constraint | context."
|
||||
text:
|
||||
type: string
|
||||
description: Decrypted fact text.
|
||||
confidence:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: 0..100 server-assigned extraction confidence.
|
||||
createdAt:
|
||||
type: string
|
||||
format: date-time
|
||||
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)))$
|
||||
required:
|
||||
- id
|
||||
- category
|
||||
- text
|
||||
- confidence
|
||||
- createdAt
|
||||
additionalProperties: false
|
||||
description: The caller's active facts, highest-confidence then newest first. Undecryptable rows are omitted.
|
||||
required:
|
||||
- facts
|
||||
additionalProperties: false
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachFactsCleared:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
cleared:
|
||||
type: integer
|
||||
minimum: -9007199254740991
|
||||
maximum: 9007199254740991
|
||||
description: Number of active facts soft-deleted by the bulk clear.
|
||||
required:
|
||||
- cleared
|
||||
additionalProperties: false
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachFactDeleted:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
deleted:
|
||||
type: boolean
|
||||
description: True when a fact owned by the caller was soft-deleted; false for an unknown / cross-user / already-deleted
|
||||
id (idempotent no-op).
|
||||
required:
|
||||
- deleted
|
||||
additionalProperties: false
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
CoachMessageFeedbackResponse:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
} from "@/lib/validations/medication";
|
||||
import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt";
|
||||
import { ACCEPTED_INSIGHTS_TILE_IDS } from "@/lib/insights-layout";
|
||||
import { COACH_FACT_CATEGORIES } from "@/lib/ai/coach/facts";
|
||||
import { exportSelectionSchema } from "@/lib/validations/health-record-export";
|
||||
import { createShareLinkSchema } from "@/lib/validations/clinician-share-link";
|
||||
import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry";
|
||||
@@ -2010,6 +2011,49 @@ const stdResponses = {
|
||||
},
|
||||
};
|
||||
|
||||
// ── Coach facts (v1.11.1) ────────────────────────────────────────────
|
||||
// Read + delete surface for the durable facts the Coach extracts. Facts
|
||||
// are server-extracted, not user-authored, so there is no create/update
|
||||
// shape — only list, bulk-clear, and single-delete responses.
|
||||
|
||||
const coachFactItem = z.object({
|
||||
id: z.string(),
|
||||
category: z
|
||||
.enum(COACH_FACT_CATEGORIES)
|
||||
.describe(
|
||||
"App-side closed category: preference | condition | goal | constraint | context.",
|
||||
),
|
||||
text: z.string().describe("Decrypted fact text."),
|
||||
confidence: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("0..100 server-assigned extraction confidence."),
|
||||
createdAt: z.iso.datetime({ offset: true }),
|
||||
});
|
||||
|
||||
const coachFactsListResponse = z.object({
|
||||
facts: z
|
||||
.array(coachFactItem)
|
||||
.describe(
|
||||
"The caller's active facts, highest-confidence then newest first. Undecryptable rows are omitted.",
|
||||
),
|
||||
});
|
||||
|
||||
const coachFactsClearedResponse = z.object({
|
||||
cleared: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("Number of active facts soft-deleted by the bulk clear."),
|
||||
});
|
||||
|
||||
const coachFactDeletedResponse = z.object({
|
||||
deleted: z
|
||||
.boolean()
|
||||
.describe(
|
||||
"True when a fact owned by the caller was soft-deleted; false for an unknown / cross-user / already-deleted id (idempotent no-op).",
|
||||
),
|
||||
});
|
||||
|
||||
// ── Path table ───────────────────────────────────────────────────────
|
||||
|
||||
export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
@@ -3228,6 +3272,68 @@ export const openApiPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/coach/facts": {
|
||||
get: {
|
||||
tags: ["Insights"],
|
||||
summary: "List the caller's durable Coach facts",
|
||||
description:
|
||||
"v1.11.1 — returns the active facts the Coach has extracted about the caller (highest-confidence then newest first), each decrypted on the fly. The GDPR 'what do you know about me' surface. Coach-gated (`requireAssistantSurface(\"coach\")`). 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.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The caller's active facts.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(coachFactsListResponse, "CoachFactsList"),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Insights"],
|
||||
summary: "Forget all of the caller's Coach facts",
|
||||
description:
|
||||
"v1.11.1 — bulk 'forget what you know about me': soft-deletes every active fact for the caller and returns the count cleared. Idempotent (a second call clears 0). Coach-gated. Auth via cookie or Bearer.",
|
||||
responses: {
|
||||
"200": {
|
||||
description: "All active facts cleared; the count is returned.",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
coachFactsClearedResponse,
|
||||
"CoachFactsCleared",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/coach/facts/{id}": {
|
||||
delete: {
|
||||
tags: ["Insights"],
|
||||
summary: "Forget one Coach fact",
|
||||
description:
|
||||
"v1.11.1 — soft-deletes a single fact owned by the caller. An unknown / cross-user / already-deleted id is an idempotent no-op returning `{ deleted: false }`, never revealing whether the id exists under another account. Coach-gated. Auth via cookie or Bearer.",
|
||||
responses: {
|
||||
"200": {
|
||||
description:
|
||||
"The fact was soft-deleted (`deleted: true`) or the id matched nothing the caller owns (`deleted: false`).",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: dataEnvelope(
|
||||
coachFactDeletedResponse,
|
||||
"CoachFactDeleted",
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
...stdResponses,
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/insights/chat/messages/{id}/feedback": {
|
||||
post: {
|
||||
tags: ["Insights"],
|
||||
|
||||
Reference in new issue
Block a user