From 8e2e6973709a0b92eeb68945dacf480cf2b2a7a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 4 Jun 2026 10:29:18 +0200 Subject: [PATCH] feat(coach): facts management routes (list + forget) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- docs/api/openapi.yaml | 161 +++++++++ src/app/api/analytics/__tests__/route.test.ts | 1 + .../api/insights/coach/facts/[id]/route.ts | 57 +++ .../coach/facts/__tests__/route.test.ts | 332 ++++++++++++++++++ src/app/api/insights/coach/facts/route.ts | 102 ++++++ src/lib/openapi/routes.ts | 106 ++++++ 6 files changed, 759 insertions(+) create mode 100644 src/app/api/insights/coach/facts/[id]/route.ts create mode 100644 src/app/api/insights/coach/facts/__tests__/route.test.ts create mode 100644 src/app/api/insights/coach/facts/route.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 51e43b146..bfd2edaa4 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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: diff --git a/src/app/api/analytics/__tests__/route.test.ts b/src/app/api/analytics/__tests__/route.test.ts index b74119b60..22d4f3a9e 100644 --- a/src/app/api/analytics/__tests__/route.test.ts +++ b/src/app/api/analytics/__tests__/route.test.ts @@ -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([ { diff --git a/src/app/api/insights/coach/facts/[id]/route.ts b/src/app/api/insights/coach/facts/[id]/route.ts new file mode 100644 index 000000000..e28dd04c7 --- /dev/null +++ b/src/app/api/insights/coach/facts/[id]/route.ts @@ -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"; diff --git a/src/app/api/insights/coach/facts/__tests__/route.test.ts b/src/app/api/insights/coach/facts/__tests__/route.test.ts new file mode 100644 index 000000000..6c7cc9f73 --- /dev/null +++ b/src/app/api/insights/coach/facts/__tests__/route.test.ts @@ -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(); + 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; +const callDeleteAll = DELETE as unknown as () => Promise; +const callDeleteOne = DELETE_ONE as unknown as ( + req: NextRequest, + ctx: { params: Promise<{ id: string }> }, +) => Promise; + +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"); + }); +}); diff --git a/src/app/api/insights/coach/facts/route.ts b/src/app/api/insights/coach/facts/route.ts new file mode 100644 index 000000000..4a37ce2ef --- /dev/null +++ b/src/app/api/insights/coach/facts/route.ts @@ -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"; diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index 5d35f7df2..5e146fedb 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -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 = { @@ -3228,6 +3272,68 @@ export const openApiPaths: NonNullable = { }, }, }, + "/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"],