Files
HealthLog/src/lib/consent/withdrawal.ts
T
Marc-André Bombeck 8325f3964d 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
2026-09-24 09:05:09 +02:00

150 lines
5.5 KiB
TypeScript

/**
* 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) };
});
}