mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
feat(consent): add the extraction consent kind and purge on withdrawal
A new receipt kind, ai_extraction, covers reading documents, lab report scans and typed medication text with an external model. Switching the document auto-read on now mints ai_extraction instead of ai_full, so it no longer grants the Coach or the analysis as a side effect. Existing ai_full receipts keep covering everything. DELETE /api/consent/ai/latest now runs through withdrawConsent: the revoke and, when it leaves no receipt covering the analysis, the deletion of the text a model wrote under it commit in one transaction. That is the status notes, the cached briefing, model-written period narratives, reaction lines and workout paragraphs. Coach conversations, facts and plans and document summaries stay. The purge is audit-logged as consent.ai.purge with counts only. Refs #975
This commit is contained in:
1 parent
2710dfc041
commit
8325f3964d
14 files changed
+607
-185
No files matched your search
+38
-15
@@ -7948,12 +7948,13 @@ paths:
|
||||
Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user.
|
||||
|
||||
|
||||
Turning it ON does two further things a caller should expect. It is itself the standing consent act, so the
|
||||
write appends an `ai_full` `ConsentReceipt` — the same receipt POST /api/consent/ai/web mints, and one DELETE
|
||||
/api/consent/ai/latest revokes without touching this flag. And a genuine OFF→ON flip schedules a bounded
|
||||
catch-up over the documents already in the vault, because the summary job is enqueued at upload time and would
|
||||
otherwise only ever apply to future uploads. The catch-up is fire-and-forget and re-runs every consent and
|
||||
budget gate per document.
|
||||
Turning it ON does two further things a caller should expect. It is the consent act for reading documents, so
|
||||
the write appends an `ai_extraction` `ConsentReceipt`: the narrow kind that covers documents, lab report scans
|
||||
and typed medication text and nothing else (not the Coach, not the analysis). DELETE /api/consent/ai/latest
|
||||
revokes it without touching this flag, and the revocation wins: document reading stops even while the flag stays
|
||||
on. And a genuine OFF→ON flip schedules a bounded catch-up over the documents already in the vault, because the
|
||||
summary job is enqueued at upload time and would otherwise only ever apply to future uploads. The catch-up is
|
||||
fire-and-forget and re-runs every consent and budget gate per document.
|
||||
|
||||
|
||||
The body cap here is 1 KB, far tighter than the 64 KB its siblings allow — a payload above it is refused with
|
||||
@@ -12691,6 +12692,7 @@ paths:
|
||||
- ai_full
|
||||
- ai_insights_only
|
||||
- ai_coach
|
||||
- ai_extraction
|
||||
description: Narrow to one consent kind. Omit for the full keyspace.
|
||||
responses:
|
||||
"200":
|
||||
@@ -12702,8 +12704,9 @@ paths:
|
||||
- $ref: "#/components/schemas/ConsentLatestForKindEnvelope"
|
||||
- $ref: "#/components/schemas/ConsentLatestByKindEnvelope"
|
||||
"400": &a22
|
||||
description: "`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`. Multi-issue envelope;
|
||||
nothing was read or revoked. The consent family answers 400 here where the rest of the API answers 422."
|
||||
description: "`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`, `ai_extraction`.
|
||||
Multi-issue envelope; nothing was read or revoked. The consent family answers 400 here where the rest of the
|
||||
API answers 422."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
@@ -12726,9 +12729,18 @@ paths:
|
||||
each revocation is audit-logged.
|
||||
|
||||
|
||||
Revoking `ai_full` does NOT switch off `documentsAutoAiRead`; that flag is written through PATCH
|
||||
/api/auth/me/documents-auto-ai-read and turning it on mints a fresh receipt. A client offering both controls
|
||||
should show them as the two separate things they are.
|
||||
Revoking `ai_full` or `ai_extraction` does NOT switch off `documentsAutoAiRead`, but it does stop document
|
||||
reading: the document gate reads the receipt, not the switch, so a revocation wins over a switch left on.
|
||||
Turning the switch on again mints a fresh `ai_extraction` receipt through PATCH
|
||||
/api/auth/me/documents-auto-ai-read. A client offering both controls should show them as the two separate things
|
||||
they are.
|
||||
|
||||
|
||||
When a revocation leaves no receipt covering the analysis (`ai_insights_only` or `ai_full`), the same
|
||||
transaction deletes the text a model wrote under it that can be written again: per-metric status notes, the
|
||||
cached briefing, model-written period narratives, reaction lines and workout paragraphs. Coach conversations,
|
||||
facts and plans and document summaries are the person's records and stay. A deletion is audit-logged with counts
|
||||
only (`consent.ai.purge`).
|
||||
parameters:
|
||||
- in: query
|
||||
name: kind
|
||||
@@ -12739,6 +12751,7 @@ paths:
|
||||
- ai_full
|
||||
- ai_insights_only
|
||||
- ai_coach
|
||||
- ai_extraction
|
||||
description: Revoke one kind. Omit to revoke the latest active receipt across every kind.
|
||||
responses:
|
||||
"200":
|
||||
@@ -23846,8 +23859,8 @@ components:
|
||||
type: boolean
|
||||
required:
|
||||
- documentsAutoAiRead
|
||||
description: Turn automatic AI reading of newly uploaded documents on or off. Turning it on also mints an `ai_full`
|
||||
consent receipt and schedules a catch-up over the existing vault.
|
||||
description: Turn automatic AI reading of newly uploaded documents on or off. Turning it on also mints an
|
||||
`ai_extraction` consent receipt and schedules a catch-up over the existing vault.
|
||||
InjectionSitePrefsPatchRequest:
|
||||
type: object
|
||||
properties:
|
||||
@@ -26005,6 +26018,7 @@ components:
|
||||
- ai_full
|
||||
- ai_insights_only
|
||||
- ai_coach
|
||||
- ai_extraction
|
||||
artefact:
|
||||
type: string
|
||||
minLength: 1
|
||||
@@ -58019,6 +58033,7 @@ components:
|
||||
- ai_full
|
||||
- ai_insights_only
|
||||
- ai_coach
|
||||
- ai_extraction
|
||||
signedAt:
|
||||
type: string
|
||||
revokedAt:
|
||||
@@ -58115,6 +58130,7 @@ components:
|
||||
- ai_full
|
||||
- ai_insights_only
|
||||
- ai_coach
|
||||
- ai_extraction
|
||||
receipt:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/SerialisedConsentReceipt"
|
||||
@@ -58137,6 +58153,7 @@ components:
|
||||
- ai_full
|
||||
- ai_insights_only
|
||||
- ai_coach
|
||||
- ai_extraction
|
||||
signedAt:
|
||||
type: string
|
||||
format: date-time
|
||||
@@ -58193,13 +58210,18 @@ components:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/SerialisedConsentReceipt"
|
||||
- type: "null"
|
||||
ai_extraction:
|
||||
anyOf:
|
||||
- $ref: "#/components/schemas/SerialisedConsentReceipt"
|
||||
- type: "null"
|
||||
required:
|
||||
- ai_full
|
||||
- ai_insights_only
|
||||
- ai_coach
|
||||
- ai_extraction
|
||||
additionalProperties: false
|
||||
description: "The latest active receipt for every kind. All three keys are always present: a null value means the kind
|
||||
is not granted, so a client never has to read absence as ambiguity."
|
||||
description: "The latest active receipt for every kind. Every key is always present: a null value means the kind is not
|
||||
granted, so a client never has to read absence as ambiguity."
|
||||
ConsentRevokeForKindEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -58248,6 +58270,7 @@ components:
|
||||
- ai_full
|
||||
- ai_insights_only
|
||||
- ai_coach
|
||||
- ai_extraction
|
||||
receipt:
|
||||
$ref: "#/components/schemas/SerialisedConsentReceipt"
|
||||
required:
|
||||
|
||||
@@ -22,7 +22,7 @@ vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/consent/web-grant", () => ({
|
||||
ensureWebAiConsentReceipt: vi.fn().mockResolvedValue(undefined),
|
||||
ensureExtractionConsentReceipt: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/jobs/document-summary-catchup", () => ({
|
||||
enqueueSummaryCatchUp: vi.fn().mockResolvedValue({ enqueued: true }),
|
||||
@@ -47,7 +47,7 @@ vi.mock("next/headers", () => ({
|
||||
import { PATCH } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant";
|
||||
import { ensureExtractionConsentReceipt } from "@/lib/consent/web-grant";
|
||||
import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup";
|
||||
|
||||
const SESSION_OK = {
|
||||
@@ -103,23 +103,20 @@ describe("PATCH /api/auth/me/documents-auto-ai-read — catch-up scheduling", ()
|
||||
await PATCH(mkPatch(false));
|
||||
|
||||
expect(enqueueSummaryCatchUp).not.toHaveBeenCalled();
|
||||
expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled();
|
||||
expect(ensureExtractionConsentReceipt).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still mints the consent receipt on the flip that schedules the pass", async () => {
|
||||
// The catch-up rides the same act of consent, never around it. The mint
|
||||
// is marked `affirmative` because switching the toggle on IS the consent
|
||||
// act: unlike the settings-mount heal, it may lift an earlier
|
||||
// withdrawal, which is the user deciding again rather than the page
|
||||
// deciding for them.
|
||||
// is the extraction kind (documents, lab scans, medication text) and
|
||||
// nothing wider: switching document reading on does not consent to the
|
||||
// Coach or to the analysis. It is the person's own affirmative act, so
|
||||
// it may lift an earlier withdrawal of the same kind.
|
||||
withPrevious(false);
|
||||
|
||||
await PATCH(mkPatch(true));
|
||||
|
||||
expect(ensureWebAiConsentReceipt).toHaveBeenCalledWith(
|
||||
"user-1",
|
||||
"affirmative",
|
||||
);
|
||||
expect(ensureExtractionConsentReceipt).toHaveBeenCalledWith("user-1");
|
||||
});
|
||||
|
||||
it("persists the flag field-by-field", async () => {
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
} from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant";
|
||||
import { ensureExtractionConsentReceipt } from "@/lib/consent/web-grant";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup";
|
||||
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
|
||||
@@ -102,11 +102,12 @@ export const PATCH = apiHandler(async (req: Request) => {
|
||||
data: { documentsAutoAiRead: next },
|
||||
});
|
||||
|
||||
// Turning the toggle ON is the standing consent act — mint an append-only
|
||||
// `ai_full` receipt (idempotent) so the durable audit trail records it, in
|
||||
// addition to the runtime short-circuit the document consent gate reads.
|
||||
// Turning the toggle ON is the consent act for reading documents — mint an
|
||||
// append-only `ai_extraction` receipt (idempotent). The document consent
|
||||
// gate reads that receipt, not the toggle, so a later revocation wins over
|
||||
// a toggle left on. It does not grant the Coach or the analysis.
|
||||
if (next) {
|
||||
await ensureWebAiConsentReceipt(user.id, "affirmative");
|
||||
await ensureExtractionConsentReceipt(user.id);
|
||||
}
|
||||
|
||||
// A genuine OFF→ON flip schedules a catch-up over the documents already in
|
||||
|
||||
@@ -1,24 +1,30 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
// v1.16.16 — `createReceipt` + `revokeLatest` run inside a transaction; the
|
||||
// mock runs the callback against the same proxy.
|
||||
// v1.16.16 — `createReceipt` and the withdrawal run inside a transaction;
|
||||
// the mock runs the callback against the same proxy. The withdrawal also
|
||||
// purges the regenerable AI text, so the proxy carries those delegates too.
|
||||
type TxFn = (tx: unknown) => unknown;
|
||||
|
||||
vi.mock("@/lib/db", () => {
|
||||
const consentReceipt = {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
};
|
||||
return {
|
||||
prisma: {
|
||||
consentReceipt,
|
||||
$transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })),
|
||||
const prisma = {
|
||||
consentReceipt: {
|
||||
create: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
insightStatusCache: { deleteMany: vi.fn() },
|
||||
user: { updateMany: vi.fn() },
|
||||
insightNarrative: { deleteMany: vi.fn() },
|
||||
arrivalReaction: { updateMany: vi.fn() },
|
||||
workoutInsight: { deleteMany: vi.fn() },
|
||||
$transaction: vi.fn(),
|
||||
};
|
||||
prisma.$transaction.mockImplementation((fn: TxFn) => fn(prisma));
|
||||
return { prisma };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
@@ -78,9 +84,21 @@ beforeEach(() => {
|
||||
vi.mocked(auditLog).mockResolvedValue(undefined);
|
||||
// Re-arm rate-limit + transaction pass-throughs cleared by reset.
|
||||
vi.mocked(checkConsentRateLimit).mockResolvedValue(RL_OK);
|
||||
$transaction.mockImplementation((fn: TxFn) =>
|
||||
fn({ consentReceipt: prisma.consentReceipt }),
|
||||
);
|
||||
$transaction.mockImplementation((fn: TxFn) => fn(prisma));
|
||||
// Default: another receipt still covers the analysis, so no purge runs.
|
||||
// The purge cases below set this to 0.
|
||||
vi.mocked(prisma.consentReceipt.count).mockResolvedValue(1 as never);
|
||||
for (const deleteMany of [
|
||||
prisma.insightStatusCache.deleteMany,
|
||||
prisma.insightNarrative.deleteMany,
|
||||
prisma.workoutInsight.deleteMany,
|
||||
]) {
|
||||
vi.mocked(deleteMany).mockResolvedValue({ count: 0 } as never);
|
||||
}
|
||||
vi.mocked(prisma.user.updateMany).mockResolvedValue({ count: 0 } as never);
|
||||
vi.mocked(prisma.arrivalReaction.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/consent/ai/latest", () => {
|
||||
@@ -278,14 +296,15 @@ describe("DELETE /api/consent/ai/latest", () => {
|
||||
const createdAt = new Date("2026-05-18T10:00:01.000Z");
|
||||
const revokedAt = new Date("2026-05-18T11:00:00.000Z");
|
||||
|
||||
// v1.16.16 — `revokeLatest` runs per kind in `consentKindEnum.options`
|
||||
// order: ai_full → ai_insights_only → ai_coach. The `updateMany` count
|
||||
// gates the follow-up re-read. ai_full + ai_coach revoke a row;
|
||||
// ai_insights_only has none (count 0, no re-read).
|
||||
// The withdrawal runs per kind in `consentKindEnum.options` order:
|
||||
// ai_full → ai_insights_only → ai_coach → ai_extraction. The
|
||||
// `updateMany` count gates the follow-up re-read. ai_full + ai_coach
|
||||
// revoke a row; the others have none (count 0, no re-read).
|
||||
vi.mocked(prisma.consentReceipt.updateMany)
|
||||
.mockResolvedValueOnce({ count: 1 } as never) // ai_full
|
||||
.mockResolvedValueOnce({ count: 0 } as never) // ai_insights_only
|
||||
.mockResolvedValueOnce({ count: 1 } as never); // ai_coach
|
||||
.mockResolvedValueOnce({ count: 1 } as never) // ai_coach
|
||||
.mockResolvedValueOnce({ count: 0 } as never); // ai_extraction
|
||||
vi.mocked(prisma.consentReceipt.findFirst)
|
||||
.mockResolvedValueOnce({
|
||||
id: "rcpt-full",
|
||||
@@ -323,6 +342,80 @@ describe("DELETE /api/consent/ai/latest", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /api/consent/ai/latest — purge of regenerable AI text", () => {
|
||||
const revokedRow = {
|
||||
id: "rcpt-1",
|
||||
userId: "user-1",
|
||||
kind: "ai_insights_only",
|
||||
artefact: "PDF",
|
||||
signedAt: new Date("2026-05-18T10:00:00.000Z"),
|
||||
revokedAt: new Date("2026-05-18T11:00:00.000Z"),
|
||||
createdAt: new Date("2026-05-18T10:00:01.000Z"),
|
||||
};
|
||||
|
||||
it("deletes the regenerable text and writes a counts-only purge audit row when nothing covers the analysis any more", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(
|
||||
revokedRow as never,
|
||||
);
|
||||
vi.mocked(prisma.consentReceipt.count).mockResolvedValue(0 as never);
|
||||
vi.mocked(prisma.insightStatusCache.deleteMany).mockResolvedValue({
|
||||
count: 4,
|
||||
} as never);
|
||||
|
||||
const res = await DELETE(
|
||||
new NextRequest(
|
||||
"http://localhost/api/consent/ai/latest?kind=ai_insights_only",
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.insightStatusCache.deleteMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1" },
|
||||
});
|
||||
expect(prisma.workoutInsight.deleteMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1" },
|
||||
});
|
||||
expect(auditLog).toHaveBeenCalledWith("consent.ai.purge", {
|
||||
userId: "user-1",
|
||||
details: {
|
||||
statusNotes: 4,
|
||||
briefing: 0,
|
||||
narratives: 0,
|
||||
reactionLines: 0,
|
||||
workoutParagraphs: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the text while another receipt still covers the analysis", async () => {
|
||||
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(
|
||||
revokedRow as never,
|
||||
);
|
||||
vi.mocked(prisma.consentReceipt.count).mockResolvedValue(1 as never);
|
||||
|
||||
const res = await DELETE(
|
||||
new NextRequest(
|
||||
"http://localhost/api/consent/ai/latest?kind=ai_insights_only",
|
||||
{ method: "DELETE" },
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(prisma.insightStatusCache.deleteMany).not.toHaveBeenCalled();
|
||||
expect(auditLog).not.toHaveBeenCalledWith(
|
||||
"consent.ai.purge",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("append-only invariant", () => {
|
||||
/**
|
||||
* v1.4.40 SB-10 — the receipts table is the legal audit trail.
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
* DELETE /api/consent/ai/latest
|
||||
* Master toggle — revokes the latest active receipt across every
|
||||
* consent kind. Returns the list of revoked rows.
|
||||
*
|
||||
* Either DELETE also deletes the regenerable model-written text (status
|
||||
* notes, the cached briefing, model-written narratives, reaction lines,
|
||||
* workout paragraphs) when it leaves no receipt covering the analysis, in the
|
||||
* same transaction as the revoke. Coach memory and document summaries stay.
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
@@ -28,8 +33,8 @@ import {
|
||||
import {
|
||||
latestActiveReceipt,
|
||||
latestActiveReceiptsByKind,
|
||||
revokeLatest,
|
||||
} from "@/lib/consent/receipts";
|
||||
import { withdrawConsent } from "@/lib/consent/withdrawal";
|
||||
import { serialiseReceipt } from "../route";
|
||||
|
||||
export const GET = apiHandler(async (request: NextRequest) => {
|
||||
@@ -81,6 +86,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
|
||||
? serialiseReceipt(byKind.ai_insights_only)
|
||||
: null,
|
||||
ai_coach: byKind.ai_coach ? serialiseReceipt(byKind.ai_coach) : null,
|
||||
ai_extraction: byKind.ai_extraction
|
||||
? serialiseReceipt(byKind.ai_extraction)
|
||||
: null,
|
||||
};
|
||||
|
||||
annotate({
|
||||
@@ -119,53 +127,50 @@ export const DELETE = apiHandler(async (request: NextRequest) => {
|
||||
|
||||
const { kind } = parsed.data;
|
||||
|
||||
if (kind) {
|
||||
const revoked = await revokeLatest(user.id, kind);
|
||||
if (!revoked) {
|
||||
// Idempotent: "no active receipt to revoke" is success-shaped.
|
||||
// The iOS toggle hammers this on every flip; returning 404
|
||||
// would surface as a spurious error in the client.
|
||||
annotate({
|
||||
action: { name: "consent.ai.revoke" },
|
||||
meta: { kind, revoked: false },
|
||||
});
|
||||
return apiSuccess({ kind, receipt: null });
|
||||
}
|
||||
// One kind, or every kind for the master "turn AI off" toggle. The revoke
|
||||
// and the purge of the regenerable text the consent covered commit
|
||||
// together (`withdrawConsent`).
|
||||
const kinds = kind ? [kind] : consentKindEnum.options;
|
||||
const { revoked, purged } = await withdrawConsent(user.id, kinds);
|
||||
|
||||
for (const { kind: k, receipt } of revoked) {
|
||||
auditLog("consent.ai.revoke", {
|
||||
userId: user.id,
|
||||
details: { kind, receiptId: revoked.id },
|
||||
details: { kind: k, receiptId: receipt.id },
|
||||
}).catch(() => {});
|
||||
annotate({
|
||||
action: { name: "consent.ai.revoke" },
|
||||
meta: { kind, receiptId: revoked.id },
|
||||
});
|
||||
return apiSuccess({ kind, receipt: serialiseReceipt(revoked) });
|
||||
}
|
||||
|
||||
// No kind specified — master "AI deaktivieren" toggle. Revoke the
|
||||
// latest active row for every kind that currently has one.
|
||||
const revokedRows: Array<{
|
||||
kind: ConsentKind;
|
||||
receipt: ReturnType<typeof serialiseReceipt>;
|
||||
}> = [];
|
||||
for (const k of consentKindEnum.options) {
|
||||
const r = await revokeLatest(user.id, k);
|
||||
if (r) {
|
||||
revokedRows.push({ kind: k, receipt: serialiseReceipt(r) });
|
||||
auditLog("consent.ai.revoke", {
|
||||
userId: user.id,
|
||||
details: { kind: k, receiptId: r.id },
|
||||
}).catch(() => {});
|
||||
}
|
||||
if (purged) {
|
||||
// Counts only, never content.
|
||||
auditLog("consent.ai.purge", {
|
||||
userId: user.id,
|
||||
details: { ...purged },
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "consent.ai.revoke" },
|
||||
meta: {
|
||||
kinds_revoked: revokedRows.map((r) => r.kind),
|
||||
count: revokedRows.length,
|
||||
kinds_revoked: revoked.map((r) => r.kind),
|
||||
count: revoked.length,
|
||||
purged: purged !== null,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ revoked: revokedRows });
|
||||
if (kind) {
|
||||
// Idempotent: "no active receipt to revoke" is success-shaped. The iOS
|
||||
// toggle hammers this on every flip; returning 404 would surface as a
|
||||
// spurious error in the client.
|
||||
const receipt = revoked[0]?.receipt ?? null;
|
||||
return apiSuccess({
|
||||
kind,
|
||||
receipt: receipt ? serialiseReceipt(receipt) : null,
|
||||
});
|
||||
}
|
||||
|
||||
return apiSuccess({
|
||||
revoked: revoked.map((r) => ({
|
||||
kind: r.kind,
|
||||
receipt: serialiseReceipt(r.receipt),
|
||||
})),
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
// `createReceipt` + `revokeLatest` run their supersede/revoke logic inside
|
||||
// a transaction. Run the callback against the same mock proxy. The Prisma
|
||||
// `createReceipt` runs its supersede logic inside a transaction. Run the callback against the same mock proxy. The Prisma
|
||||
// `$transaction` signature is generic; the mock takes a loose callback.
|
||||
type TxFn = (tx: unknown) => unknown;
|
||||
|
||||
@@ -26,7 +25,6 @@ import {
|
||||
createReceipt,
|
||||
latestActiveReceipt,
|
||||
latestActiveReceiptsByKind,
|
||||
revokeLatest,
|
||||
} from "../receipts";
|
||||
|
||||
const $transaction = vi.mocked(prisma.$transaction) as unknown as {
|
||||
@@ -196,39 +194,3 @@ describe("latestActiveReceiptsByKind", () => {
|
||||
expect(result.ai_insights_only).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("revokeLatest", () => {
|
||||
it("atomically revokes the active row via updateMany and returns it", async () => {
|
||||
const now = new Date("2026-05-18T11:00:00.000Z");
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(
|
||||
row({ revokedAt: now }) as never,
|
||||
);
|
||||
|
||||
const result = await revokeLatest("user-1", "ai_full", now);
|
||||
|
||||
expect(prisma.consentReceipt.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", kind: "ai_full", revokedAt: null },
|
||||
data: { revokedAt: now },
|
||||
});
|
||||
// Re-reads the just-revoked row so the audit log keeps the receipt id.
|
||||
expect(prisma.consentReceipt.findFirst).toHaveBeenCalledWith({
|
||||
where: { userId: "user-1", kind: "ai_full", revokedAt: now },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
expect(result?.revokedAt).toEqual(now);
|
||||
});
|
||||
|
||||
it("returns null without re-reading when no active receipt exists", async () => {
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockResolvedValue({
|
||||
count: 0,
|
||||
} as never);
|
||||
|
||||
const result = await revokeLatest("user-1", "ai_full");
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(prisma.consentReceipt.findFirst).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,10 @@ vi.mock("@/lib/db", () => {
|
||||
});
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { ensureWebAiConsentReceipt } from "../web-grant";
|
||||
import {
|
||||
ensureExtractionConsentReceipt,
|
||||
ensureWebAiConsentReceipt,
|
||||
} from "../web-grant";
|
||||
|
||||
const $transaction = vi.mocked(prisma.$transaction) as unknown as {
|
||||
mockImplementation: (impl: (fn: TxFn) => unknown) => void;
|
||||
@@ -246,3 +249,44 @@ describe("ensureWebAiConsentReceipt — a revocation is a standing decision", ()
|
||||
expect(prisma.consentReceipt.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureExtractionConsentReceipt", () => {
|
||||
it("mints an ai_extraction receipt, never the master grant", async () => {
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.consentReceipt.create).mockResolvedValue(
|
||||
row({ kind: "ai_extraction" }) as never,
|
||||
);
|
||||
|
||||
const result = await ensureExtractionConsentReceipt("user-1");
|
||||
|
||||
expect(result.minted).toBe(true);
|
||||
const createArg = vi.mocked(prisma.consentReceipt.create).mock.calls[0][0];
|
||||
expect(createArg.data.kind).toBe("ai_extraction");
|
||||
expect(JSON.parse(createArg.data.artefact as string).kind).toBe(
|
||||
"ai_extraction",
|
||||
);
|
||||
for (const [args] of vi.mocked(prisma.consentReceipt.findFirst).mock
|
||||
.calls) {
|
||||
expect((args as { where: { kind: string } }).where.kind).toBe(
|
||||
"ai_extraction",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("supersedes an earlier revocation of the same kind (it is the person's own act)", async () => {
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockResolvedValue(null);
|
||||
vi.mocked(prisma.consentReceipt.create).mockResolvedValue(
|
||||
row({ kind: "ai_extraction" }) as never,
|
||||
);
|
||||
|
||||
await ensureExtractionConsentReceipt("user-1");
|
||||
|
||||
// An affirmative mint never looks for a past revocation.
|
||||
for (const [args] of vi.mocked(prisma.consentReceipt.findFirst).mock
|
||||
.calls) {
|
||||
expect(
|
||||
(args as { where: { revokedAt: unknown } }).where.revokedAt,
|
||||
).toBeNull();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// The revoke and the purge run inside one transaction; the mock runs the
|
||||
// callback against the same proxy. Atomicity against a real database is
|
||||
// pinned by `tests/integration/consent-withdrawal-purge.test.ts`.
|
||||
type TxFn = (tx: unknown) => unknown;
|
||||
|
||||
vi.mock("@/lib/db", () => {
|
||||
const consentReceipt = {
|
||||
updateMany: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
count: vi.fn(),
|
||||
};
|
||||
return {
|
||||
prisma: {
|
||||
consentReceipt,
|
||||
$transaction: vi.fn((fn: TxFn) => fn({ consentReceipt })),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { withdrawConsent, type AiTextPurgeCounts } from "../withdrawal";
|
||||
|
||||
const COUNTS: AiTextPurgeCounts = {
|
||||
statusNotes: 3,
|
||||
briefing: 1,
|
||||
narratives: 2,
|
||||
reactionLines: 1,
|
||||
workoutParagraphs: 1,
|
||||
};
|
||||
|
||||
const now = new Date("2026-09-20T08:00:00.000Z");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(prisma.$transaction).mockImplementation(((fn: TxFn) =>
|
||||
fn({ consentReceipt: prisma.consentReceipt })) as never);
|
||||
});
|
||||
|
||||
/** Receipts active before the call, by kind. */
|
||||
function active(kinds: string[]) {
|
||||
const live = new Set(kinds);
|
||||
vi.mocked(prisma.consentReceipt.updateMany).mockImplementation((async (args: {
|
||||
where: { kind: string };
|
||||
}) => {
|
||||
const had = live.delete(args.where.kind);
|
||||
return { count: had ? 1 : 0 };
|
||||
}) as never);
|
||||
vi.mocked(prisma.consentReceipt.findFirst).mockImplementation((async (args: {
|
||||
where: { kind: string };
|
||||
}) => ({ id: `r-${args.where.kind}`, kind: args.where.kind })) as never);
|
||||
vi.mocked(prisma.consentReceipt.count).mockImplementation(
|
||||
(async (args: { where: { kind: { in: string[] } } }) =>
|
||||
args.where.kind.in.filter((k) => live.has(k)).length) as never,
|
||||
);
|
||||
}
|
||||
|
||||
describe("withdrawConsent", () => {
|
||||
it("revokes each active kind conditionally and re-reads it for the audit trail", async () => {
|
||||
active(["ai_coach"]);
|
||||
const purge = vi.fn();
|
||||
const result = await withdrawConsent("u1", ["ai_coach"], now, purge);
|
||||
expect(prisma.consentReceipt.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: "u1", kind: "ai_coach", revokedAt: null },
|
||||
data: { revokedAt: now },
|
||||
});
|
||||
expect(result.revoked.map((r) => r.kind)).toEqual(["ai_coach"]);
|
||||
});
|
||||
|
||||
it("purges when the revoke leaves no receipt covering the analysis", async () => {
|
||||
active(["ai_full"]);
|
||||
const purge = vi.fn(async () => COUNTS);
|
||||
const result = await withdrawConsent("u1", ["ai_full"], now, purge);
|
||||
expect(purge).toHaveBeenCalledTimes(1);
|
||||
expect(result.purged).toEqual(COUNTS);
|
||||
});
|
||||
|
||||
it("keeps the text while another receipt still covers the analysis", async () => {
|
||||
active(["ai_full", "ai_insights_only"]);
|
||||
const purge = vi.fn(async () => COUNTS);
|
||||
const result = await withdrawConsent("u1", ["ai_full"], now, purge);
|
||||
expect(purge).not.toHaveBeenCalled();
|
||||
expect(result.purged).toBeNull();
|
||||
});
|
||||
|
||||
it("never purges for a revoke that did not touch the analysis (Coach or extraction only)", async () => {
|
||||
active(["ai_coach", "ai_extraction"]);
|
||||
const purge = vi.fn(async () => COUNTS);
|
||||
const result = await withdrawConsent(
|
||||
"u1",
|
||||
["ai_coach", "ai_extraction"],
|
||||
now,
|
||||
purge,
|
||||
);
|
||||
expect(purge).not.toHaveBeenCalled();
|
||||
expect(result.purged).toBeNull();
|
||||
expect(result.revoked).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does nothing, and purges nothing, when no receipt was active", async () => {
|
||||
active([]);
|
||||
const purge = vi.fn(async () => COUNTS);
|
||||
const result = await withdrawConsent("u1", ["ai_full"], now, purge);
|
||||
expect(result).toEqual({ revoked: [], purged: null });
|
||||
expect(prisma.consentReceipt.findFirst).not.toHaveBeenCalled();
|
||||
expect(purge).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("propagates a purge failure out of the transaction (the revoke rolls back with it)", async () => {
|
||||
active(["ai_insights_only"]);
|
||||
const purge = vi.fn(async () => {
|
||||
throw new Error("purge failed");
|
||||
});
|
||||
await expect(
|
||||
withdrawConsent("u1", ["ai_insights_only"], now, purge),
|
||||
).rejects.toThrow("purge failed");
|
||||
});
|
||||
});
|
||||
+23
-37
@@ -2,10 +2,10 @@
|
||||
* v1.4.40 SB-10 — AI consent receipts CRUD helper.
|
||||
*
|
||||
* Append-only audit trail. Every grant + revoke mints a fresh row;
|
||||
* no updates, no deletes outside of cascade-on-user-delete. The
|
||||
* `revokeLatest` helper writes the current row's `revokedAt` and
|
||||
* returns it — that single field flip is the only mutation the
|
||||
* helper performs. A subsequent re-grant inserts a new row, so the
|
||||
* no updates, no deletes outside of cascade-on-user-delete. A
|
||||
* revocation (`withdrawConsent` in `./withdrawal`) writes the current
|
||||
* row's `revokedAt` — that single field flip is the only mutation a
|
||||
* receipt ever sees. A subsequent re-grant inserts a new row, so the
|
||||
* historical chain stays intact for legal review.
|
||||
*
|
||||
* Source of truth for "is AI active right now?" is
|
||||
@@ -19,6 +19,25 @@ import { isP2002 } from "@/lib/prisma-errors";
|
||||
|
||||
export type ConsentReceipt = ConsentReceiptModel;
|
||||
|
||||
/**
|
||||
* The consent kind for reading documents, lab report scans and typed
|
||||
* medication text with an external model. Minted by switching the document
|
||||
* auto-read on; required (or the master `ai_full`) by every extraction path.
|
||||
* It does not satisfy the Coach or the analysis.
|
||||
*/
|
||||
export const AI_EXTRACTION_CONSENT_KIND = "ai_extraction" satisfies ConsentKind;
|
||||
|
||||
/**
|
||||
* The kinds whose active receipt authorises model-written analysis over the
|
||||
* person's own readings (briefing, status notes, narratives, workout
|
||||
* paragraphs, reaction lines). When none of these is left active, the
|
||||
* regenerable text those surfaces stored is purged.
|
||||
*/
|
||||
export const INSIGHTS_CONSENT_KINDS: readonly ConsentKind[] = [
|
||||
"ai_insights_only",
|
||||
"ai_full",
|
||||
];
|
||||
|
||||
/**
|
||||
* Insert a fresh consent receipt for (userId, kind). The caller has
|
||||
* already Zod-validated `artefact` (≤ 64 KB) and `signedAt` (ISO-8601).
|
||||
@@ -119,36 +138,3 @@ export async function latestActiveReceiptsByKind(
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the active receipt for (userId, kind) as revoked. Returns the
|
||||
* updated row, or `null` if no active receipt exists.
|
||||
*
|
||||
* v1.16.16 — the partial unique index guarantees at most one active row
|
||||
* per (user, kind), so a single atomic `updateMany` over the active
|
||||
* predicate revokes "the" active receipt without a separate read. Two
|
||||
* concurrent revokes converge: the first flips `revoked_at`, the second's
|
||||
* predicate no longer matches and writes nothing. The follow-up read
|
||||
* returns the row the caller (audit log) needs; it is `null` only when no
|
||||
* active receipt existed at all.
|
||||
*/
|
||||
export async function revokeLatest(
|
||||
userId: string,
|
||||
kind: ConsentKind,
|
||||
now: Date = new Date(),
|
||||
): Promise<ConsentReceipt | null> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.consentReceipt.updateMany({
|
||||
where: { userId, kind, revokedAt: null },
|
||||
data: { revokedAt: now },
|
||||
});
|
||||
if (updated.count === 0) return null;
|
||||
// Re-read the just-revoked row (revoked at exactly `now`) so the audit
|
||||
// trail keeps the receipt id. The active predicate guaranteed a single
|
||||
// row, so this resolves it unambiguously.
|
||||
return tx.consentReceipt.findFirst({
|
||||
where: { userId, kind, revokedAt: now },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -35,7 +35,10 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { isP2002 } from "@/lib/prisma-errors";
|
||||
import { latestActiveReceipt } from "@/lib/consent/receipts";
|
||||
import {
|
||||
AI_EXTRACTION_CONSENT_KIND,
|
||||
latestActiveReceipt,
|
||||
} from "@/lib/consent/receipts";
|
||||
import type { ConsentReceipt } from "@/lib/consent/receipts";
|
||||
|
||||
/**
|
||||
@@ -63,24 +66,55 @@ export type WebConsentGrantResult =
|
||||
*/
|
||||
export type WebConsentGrantIntent = "heal" | "affirmative";
|
||||
|
||||
/** The receipt kinds the web mints: the master grant, or extraction alone. */
|
||||
type WebConsentKind = "ai_full" | typeof AI_EXTRACTION_CONSENT_KIND;
|
||||
|
||||
/**
|
||||
* Ensure the web user has an active `ai_full` consent receipt, minting one
|
||||
* if absent. Idempotent: returns `{ minted: false }` when an active master
|
||||
* grant already exists, so callers can invoke it freely on mount.
|
||||
*
|
||||
* Mirrors the iOS `ai_full` grant. `ai_full` is the master kind that
|
||||
* satisfies every surface gate (insights + coach), matching the iOS app's
|
||||
* single master toggle rather than per-surface web grants.
|
||||
* satisfies every surface gate (insights + coach + extraction), matching the
|
||||
* iOS app's single master toggle rather than per-surface web grants.
|
||||
*/
|
||||
export async function ensureWebAiConsentReceipt(
|
||||
userId: string,
|
||||
intent: WebConsentGrantIntent,
|
||||
now: Date = new Date(),
|
||||
): Promise<WebConsentGrantResult> {
|
||||
return ensureWebConsentReceipt(userId, "ai_full", intent, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure an active `ai_extraction` receipt: the consent to read documents,
|
||||
* lab report scans and typed medication text with an external model. Minted
|
||||
* by switching the document auto-read on, which is the person's own
|
||||
* affirmative act, so it supersedes an earlier revocation of the same kind.
|
||||
* It does not satisfy the Coach or the analysis.
|
||||
*/
|
||||
export async function ensureExtractionConsentReceipt(
|
||||
userId: string,
|
||||
now: Date = new Date(),
|
||||
): Promise<WebConsentGrantResult> {
|
||||
return ensureWebConsentReceipt(
|
||||
userId,
|
||||
AI_EXTRACTION_CONSENT_KIND,
|
||||
"affirmative",
|
||||
now,
|
||||
);
|
||||
}
|
||||
|
||||
async function ensureWebConsentReceipt(
|
||||
userId: string,
|
||||
kind: WebConsentKind,
|
||||
intent: WebConsentGrantIntent,
|
||||
now: Date,
|
||||
): Promise<WebConsentGrantResult> {
|
||||
// Fast path: a cheap read outside the transaction short-circuits the
|
||||
// common already-granted case (every mount after the first) without
|
||||
// opening a transaction.
|
||||
const existing = await latestActiveReceipt(userId, "ai_full");
|
||||
const existing = await latestActiveReceipt(userId, kind);
|
||||
if (existing) return { minted: false, reason: "already_active" };
|
||||
|
||||
// A revoked receipt anywhere in this account's history means the user made
|
||||
@@ -88,7 +122,7 @@ export async function ensureWebAiConsentReceipt(
|
||||
// affirmative act is the user deciding again, so it may proceed.
|
||||
if (intent === "heal") {
|
||||
const revoked = await prisma.consentReceipt.findFirst({
|
||||
where: { userId, kind: "ai_full", revokedAt: { not: null } },
|
||||
where: { userId, kind, revokedAt: { not: null } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (revoked) return { minted: false, reason: "previously_revoked" };
|
||||
@@ -96,7 +130,7 @@ export async function ensureWebAiConsentReceipt(
|
||||
|
||||
const artefact = JSON.stringify({
|
||||
source: "web",
|
||||
kind: "ai_full",
|
||||
kind,
|
||||
grantedAt: now.toISOString(),
|
||||
note: "In-app affirmative AI consent granted via the web client.",
|
||||
});
|
||||
@@ -112,7 +146,7 @@ export async function ensureWebAiConsentReceipt(
|
||||
return await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const inTx = await tx.consentReceipt.findFirst({
|
||||
where: { userId, kind: "ai_full", revokedAt: null },
|
||||
where: { userId, kind, revokedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
if (inTx) return { minted: false, reason: "already_active" } as const;
|
||||
@@ -122,7 +156,7 @@ export async function ensureWebAiConsentReceipt(
|
||||
// mint would overwrite a decision made a moment earlier.
|
||||
if (intent === "heal") {
|
||||
const revokedInTx = await tx.consentReceipt.findFirst({
|
||||
where: { userId, kind: "ai_full", revokedAt: { not: null } },
|
||||
where: { userId, kind, revokedAt: { not: null } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (revokedInTx)
|
||||
@@ -130,7 +164,7 @@ export async function ensureWebAiConsentReceipt(
|
||||
}
|
||||
|
||||
const receipt = await tx.consentReceipt.create({
|
||||
data: { userId, kind: "ai_full", artefact, signedAt: now },
|
||||
data: { userId, kind, artefact, signedAt: now },
|
||||
});
|
||||
return { minted: true, receipt } as const;
|
||||
},
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Consent withdrawal: revoking receipts, and deleting the model-written text
|
||||
* that the withdrawn consent covered.
|
||||
*
|
||||
* A revocation stops new AI work at once (every capability reads the active
|
||||
* receipts). What it also owes the person is the text already written under
|
||||
* that consent. Two kinds of stored text are treated differently:
|
||||
*
|
||||
* - Regenerable caches are DELETED, in the same transaction as the revoke,
|
||||
* when the revoke leaves no active receipt covering the analysis
|
||||
* (`ai_insights_only` or `ai_full`): the per-metric status notes, the
|
||||
* cached briefing, model-written period narratives (the deterministic ones
|
||||
* are data and stay), arrival reaction lines (the markers stay), and the
|
||||
* workout paragraphs. Nothing of value is lost; granting consent again
|
||||
* regenerates them.
|
||||
* - The person's own records stay: Coach conversations, facts and plans,
|
||||
* document summaries and extracted text. They are hidden from AI surfaces
|
||||
* while a capability is unavailable, but they remain the person's to read
|
||||
* and delete.
|
||||
*
|
||||
* Revoke and purge commit together or not at all: a purge that fails leaves
|
||||
* the receipt active, so the person sees the withdrawal fail and can retry,
|
||||
* rather than a revoked consent with its text still stored.
|
||||
*/
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { DETERMINISTIC_PROVIDER_TYPE } from "@/lib/insights/narrative/period-narrative-deterministic";
|
||||
import type { ConsentKind } from "@/lib/validations/consent";
|
||||
|
||||
import { INSIGHTS_CONSENT_KINDS, type ConsentReceipt } from "./receipts";
|
||||
|
||||
/** How many stored items one withdrawal deleted, per surface. */
|
||||
export interface AiTextPurgeCounts {
|
||||
statusNotes: number;
|
||||
/** 1 when a cached briefing was cleared, else 0. */
|
||||
briefing: number;
|
||||
narratives: number;
|
||||
reactionLines: number;
|
||||
workoutParagraphs: number;
|
||||
}
|
||||
|
||||
export interface ConsentWithdrawal {
|
||||
/** The receipts this call revoked, in the order of `kinds`. */
|
||||
revoked: Array<{ kind: ConsentKind; receipt: ConsentReceipt }>;
|
||||
/** What was deleted, or `null` when the withdrawal left the analysis covered. */
|
||||
purged: AiTextPurgeCounts | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the regenerable model-written text for one account, inside `tx`.
|
||||
* Exported for the transaction below and its tests; call it through
|
||||
* `withdrawConsent`, never on its own.
|
||||
*/
|
||||
export async function purgeRegenerableAiText(
|
||||
tx: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
): Promise<AiTextPurgeCounts> {
|
||||
const [statusNotes, briefing, narratives, reactionLines, workoutParagraphs] =
|
||||
await Promise.all([
|
||||
tx.insightStatusCache.deleteMany({ where: { userId } }),
|
||||
tx.user.updateMany({
|
||||
where: {
|
||||
id: userId,
|
||||
OR: [
|
||||
{ insightsCachedText: { not: null } },
|
||||
{ insightsCachedAt: { not: null } },
|
||||
{ insightsSnapshotHash: { not: null } },
|
||||
],
|
||||
},
|
||||
data: {
|
||||
insightsCachedText: null,
|
||||
insightsCachedAt: null,
|
||||
insightsSnapshotHash: null,
|
||||
},
|
||||
}),
|
||||
tx.insightNarrative.deleteMany({
|
||||
where: {
|
||||
userId,
|
||||
OR: [
|
||||
{ providerType: null },
|
||||
{ providerType: { not: DETERMINISTIC_PROVIDER_TYPE } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
// The marker is data (it drives the "just in" chip); only the sentence
|
||||
// goes. `generatedAt` stays, so the worker does not write it again.
|
||||
tx.arrivalReaction.updateMany({
|
||||
where: { userId, lineEncrypted: { not: null } },
|
||||
data: { lineEncrypted: null },
|
||||
}),
|
||||
tx.workoutInsight.deleteMany({ where: { userId } }),
|
||||
]);
|
||||
return {
|
||||
statusNotes: statusNotes.count,
|
||||
briefing: briefing.count,
|
||||
narratives: narratives.count,
|
||||
reactionLines: reactionLines.count,
|
||||
workoutParagraphs: workoutParagraphs.count,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke the active receipt of each of `kinds`, and purge the regenerable
|
||||
* text when that leaves the analysis without a covering receipt. One
|
||||
* transaction. The purge runs only when this call revoked a receipt that
|
||||
* covered the analysis; revoking the Coach consent alone never deletes a
|
||||
* status note.
|
||||
*/
|
||||
export async function withdrawConsent(
|
||||
userId: string,
|
||||
kinds: readonly ConsentKind[],
|
||||
now: Date = new Date(),
|
||||
purge: typeof purgeRegenerableAiText = purgeRegenerableAiText,
|
||||
): Promise<ConsentWithdrawal> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const revoked: ConsentWithdrawal["revoked"] = [];
|
||||
for (const kind of kinds) {
|
||||
// The partial unique index keeps at most one active row per (user,
|
||||
// kind), so one conditional update revokes "the" active receipt; a
|
||||
// concurrent revoke finds nothing left to match and writes nothing.
|
||||
const updated = await tx.consentReceipt.updateMany({
|
||||
where: { userId, kind, revokedAt: null },
|
||||
data: { revokedAt: now },
|
||||
});
|
||||
if (updated.count === 0) continue;
|
||||
const receipt = await tx.consentReceipt.findFirst({
|
||||
where: { userId, kind, revokedAt: now },
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
if (receipt) revoked.push({ kind, receipt });
|
||||
}
|
||||
|
||||
const touchedAnalysis = revoked.some(({ kind }) =>
|
||||
INSIGHTS_CONSENT_KINDS.includes(kind),
|
||||
);
|
||||
if (!touchedAnalysis) return { revoked, purged: null };
|
||||
|
||||
const stillCovered = await tx.consentReceipt.count({
|
||||
where: {
|
||||
userId,
|
||||
revokedAt: null,
|
||||
kind: { in: [...INSIGHTS_CONSENT_KINDS] },
|
||||
},
|
||||
});
|
||||
if (stillCovered > 0) return { revoked, purged: null };
|
||||
|
||||
return { revoked, purged: await purge(tx, userId) };
|
||||
});
|
||||
}
|
||||
@@ -9,7 +9,9 @@
|
||||
* clients have a stable reference.
|
||||
*
|
||||
* Receipt kinds: `ai_full` (master — satisfies every surface),
|
||||
* `ai_insights_only` (Insights only), `ai_coach` (Coach only).
|
||||
* `ai_insights_only` (Insights only), `ai_coach` (Coach only),
|
||||
* `ai_extraction` (reading documents, lab report scans and typed medication
|
||||
* text; minted by the document auto-read switch).
|
||||
*
|
||||
* - POST /api/consent/ai — explicit grant with a signed artefact (iOS).
|
||||
* - POST /api/consent/ai/web — idempotent `ai_full` grant for the web
|
||||
@@ -110,11 +112,12 @@ const consentLatestAllKinds = z
|
||||
ai_full: serialisedReceipt.nullable(),
|
||||
ai_insights_only: serialisedReceipt.nullable(),
|
||||
ai_coach: serialisedReceipt.nullable(),
|
||||
ai_extraction: serialisedReceipt.nullable(),
|
||||
})
|
||||
.meta({
|
||||
id: "ConsentLatestByKind",
|
||||
description:
|
||||
"The latest active receipt for every kind. All three keys are always present: a null value means the kind is not granted, so a client never has to read absence as ambiguity.",
|
||||
"The latest active receipt for every kind. Every key is always present: a null value means the kind is not granted, so a client never has to read absence as ambiguity.",
|
||||
});
|
||||
|
||||
const consentRevokeAllKinds = z
|
||||
@@ -137,7 +140,7 @@ const consentRevokeAllKinds = z
|
||||
const consentQueryValidation400 = {
|
||||
"400": {
|
||||
description:
|
||||
"`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`. Multi-issue envelope; nothing was read or revoked. The consent family answers 400 here where the rest of the API answers 422.",
|
||||
"`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`, `ai_extraction`. Multi-issue envelope; nothing was read or revoked. The consent family answers 400 here where the rest of the API answers 422.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
};
|
||||
@@ -257,7 +260,9 @@ export const consentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
"\n" +
|
||||
"Idempotent on purpose, and it is worth knowing why: revoking a kind that has nothing active answers 200 with `receipt: null` rather than 404, because the client toggle fires this on every flip and a 404 would render as a failure the user cannot act on. Revocation is append-only in effect — the row is stamped, never deleted — and each revocation is audit-logged.\n" +
|
||||
"\n" +
|
||||
"Revoking `ai_full` does NOT switch off `documentsAutoAiRead`; that flag is written through PATCH /api/auth/me/documents-auto-ai-read and turning it on mints a fresh receipt. A client offering both controls should show them as the two separate things they are.",
|
||||
"Revoking `ai_full` or `ai_extraction` does NOT switch off `documentsAutoAiRead`, but it does stop document reading: the document gate reads the receipt, not the switch, so a revocation wins over a switch left on. Turning the switch on again mints a fresh `ai_extraction` receipt through PATCH /api/auth/me/documents-auto-ai-read. A client offering both controls should show them as the two separate things they are.\n" +
|
||||
"\n" +
|
||||
"When a revocation leaves no receipt covering the analysis (`ai_insights_only` or `ai_full`), the same transaction deletes the text a model wrote under it that can be written again: per-metric status notes, the cached briefing, model-written period narratives, reaction lines and workout paragraphs. Coach conversations, facts and plans and document summaries are the person's records and stay. A deletion is audit-logged with counts only (`consent.ai.purge`).",
|
||||
requestParams: {
|
||||
query: z.object({
|
||||
kind: consentKindEnum
|
||||
|
||||
@@ -709,7 +709,7 @@ const glucoseUnitResponse = z
|
||||
const documentsAutoAiReadPatchRequest = documentsAutoAiReadPatchSchema.meta({
|
||||
id: "DocumentsAutoAiReadPatchRequest",
|
||||
description:
|
||||
"Turn automatic AI reading of newly uploaded documents on or off. Turning it on also mints an `ai_full` consent receipt and schedules a catch-up over the existing vault.",
|
||||
"Turn automatic AI reading of newly uploaded documents on or off. Turning it on also mints an `ai_extraction` consent receipt and schedules a catch-up over the existing vault.",
|
||||
});
|
||||
|
||||
const documentsAutoAiReadResponse = z
|
||||
@@ -1820,7 +1820,7 @@ export const profilePaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
summary: "Set the automatic document-reading opt-in",
|
||||
description:
|
||||
"Hard-set, idempotent, audit-logged. Rate-limited 60 / min per user.\n\n" +
|
||||
"Turning it ON does two further things a caller should expect. It is itself the standing consent act, so the write appends an `ai_full` `ConsentReceipt` — the same receipt POST /api/consent/ai/web mints, and one DELETE /api/consent/ai/latest revokes without touching this flag. And a genuine OFF→ON flip schedules a bounded catch-up over the documents already in the vault, because the summary job is enqueued at upload time and would otherwise only ever apply to future uploads. The catch-up is fire-and-forget and re-runs every consent and budget gate per document.\n\n" +
|
||||
"Turning it ON does two further things a caller should expect. It is the consent act for reading documents, so the write appends an `ai_extraction` `ConsentReceipt`: the narrow kind that covers documents, lab report scans and typed medication text and nothing else (not the Coach, not the analysis). DELETE /api/consent/ai/latest revokes it without touching this flag, and the revocation wins: document reading stops even while the flag stays on. And a genuine OFF→ON flip schedules a bounded catch-up over the documents already in the vault, because the summary job is enqueued at upload time and would otherwise only ever apply to future uploads. The catch-up is fire-and-forget and re-runs every consent and budget gate per document.\n\n" +
|
||||
"The body cap here is 1 KB, far tighter than the 64 KB its siblings allow — a payload above it is refused with 413 rather than parsed.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
|
||||
@@ -4,11 +4,14 @@ import { z } from "zod/v4";
|
||||
* v1.4.40 SB-10 — AI consent receipts (App-Store Guideline 5.1.2(i) +
|
||||
* GDPR Art. 7 audit trail).
|
||||
*
|
||||
* The discriminator covers the three consent surfaces the iOS client
|
||||
* collects independently:
|
||||
* The discriminator covers the consent surfaces collected independently:
|
||||
* - `ai_full` full assistant + on-device personalisation
|
||||
* - `ai_insights_only` read-only Insights generation (no Coach)
|
||||
* - `ai_coach` chat-style Coach access
|
||||
* - `ai_extraction` reading documents, lab report scans and typed
|
||||
* medication text with an external model (minted by
|
||||
* the document auto-read toggle; it satisfies neither
|
||||
* the Coach nor the analysis)
|
||||
*
|
||||
* Keeping each surface as its own row means the legal team can prove
|
||||
* which exact scope the user agreed to without parsing the signed
|
||||
@@ -20,6 +23,7 @@ export const consentKindEnum = z.enum([
|
||||
"ai_full",
|
||||
"ai_insights_only",
|
||||
"ai_coach",
|
||||
"ai_extraction",
|
||||
]);
|
||||
|
||||
export type ConsentKind = z.infer<typeof consentKindEnum>;
|
||||
@@ -73,7 +77,7 @@ export type WebConsentGrantBodyInput = z.input<typeof webConsentGrantBody>;
|
||||
*
|
||||
* Omitting `kind` on GET returns the latest active receipt per kind
|
||||
* (a `Record<ConsentKind, …>` shape). Omitting it on DELETE revokes
|
||||
* the latest receipt across all three kinds — handy for the iOS
|
||||
* the latest receipt across every kind — handy for the iOS
|
||||
* "deaktivieren alle KI-Funktionen" master toggle.
|
||||
*/
|
||||
export const consentLatestQuery = z.object({
|
||||
|
||||
Reference in new issue
Block a user