From d6809e3d549e15a0b345db712dc16df88380906d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 11:49:11 +0200 Subject: [PATCH] feat(consent): POST + GET + DELETE endpoints at /api/consent/ai MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the iOS onboarding + Settings "AI deaktivieren" flow to the persistence layer landed in the previous commit: - POST /api/consent/ai — accepts { kind, artefact, signedAt }, persists a fresh receipt, returns id + serialised row (artefact stripped from the response so the signed token doesn't echo over network paths it doesn't need to cross). - GET /api/consent/ai/latest?kind=… — latest non-revoked receipt for the given kind. Omit ?kind to get the full three-kind keyspace keyed by kind, with null slots for kinds the user hasn't granted. - DELETE /api/consent/ai/latest?kind=… — flips revokedAt on the latest active row. Idempotent: a no-op returns 200 with null receipt rather than 404 so the iOS toggle can hammer the endpoint without spurious error states. Omit ?kind for the master "deaktivieren alle KI-Funktionen" sweep across every kind. Both grant + revoke write to auditLog so the trail is reconstructible even if the receipts table is rebuilt from a backup. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/app/api/consent/ai/latest/route.ts | 143 +++++++++++++++++++++++++ src/app/api/consent/ai/route.ts | 84 +++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 src/app/api/consent/ai/latest/route.ts create mode 100644 src/app/api/consent/ai/route.ts diff --git a/src/app/api/consent/ai/latest/route.ts b/src/app/api/consent/ai/latest/route.ts new file mode 100644 index 000000000..f3719d4cb --- /dev/null +++ b/src/app/api/consent/ai/latest/route.ts @@ -0,0 +1,143 @@ +/** + * v1.4.40 SB-10 — AI consent latest-receipt reader + revoke endpoint. + * + * GET /api/consent/ai/latest?kind=ai_full + * Returns the latest non-revoked receipt for that kind, or null. + * + * GET /api/consent/ai/latest + * Returns the latest non-revoked receipt per kind, keyed by kind. + * + * DELETE /api/consent/ai/latest?kind=ai_full + * Marks the latest active receipt for that kind as revoked. + * + * DELETE /api/consent/ai/latest + * Master toggle — revokes the latest active receipt across every + * consent kind. Returns the list of revoked rows. + */ +import { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { + consentKindEnum, + consentLatestQuery, + type ConsentKind, +} from "@/lib/validations/consent"; +import { + latestActiveReceipt, + latestActiveReceiptsByKind, + revokeLatest, +} from "@/lib/consent/receipts"; +import { serialiseReceipt } from "../route"; + +export const GET = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const parsed = consentLatestQuery.safeParse( + Object.fromEntries(request.nextUrl.searchParams), + ); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 400); + } + + const { kind } = parsed.data; + + if (kind) { + const receipt = await latestActiveReceipt(user.id, kind); + annotate({ + action: { name: "consent.ai.latest" }, + meta: { kind, found: receipt != null }, + }); + return apiSuccess({ + kind, + receipt: receipt ? serialiseReceipt(receipt) : null, + }); + } + + const byKind = await latestActiveReceiptsByKind(user.id); + // Map each enum value to either the serialised row or `null` so the + // client always sees the full keyspace and never has to guess + // whether a missing key means "not granted" or "schema gap". + const result: Record | null> = { + ai_full: byKind.ai_full ? serialiseReceipt(byKind.ai_full) : null, + ai_insights_only: byKind.ai_insights_only + ? serialiseReceipt(byKind.ai_insights_only) + : null, + ai_coach: byKind.ai_coach ? serialiseReceipt(byKind.ai_coach) : null, + }; + + annotate({ + action: { name: "consent.ai.latest" }, + meta: { + kinds_active: Object.entries(result) + .filter(([, v]) => v != null) + .map(([k]) => k), + }, + }); + + return apiSuccess(result); +}); + +export const DELETE = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const parsed = consentLatestQuery.safeParse( + Object.fromEntries(request.nextUrl.searchParams), + ); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 400); + } + + 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 }); + } + auditLog("consent.ai.revoke", { + userId: user.id, + details: { kind, receiptId: revoked.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; + }> = []; + 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(() => {}); + } + } + + annotate({ + action: { name: "consent.ai.revoke" }, + meta: { + kinds_revoked: revokedRows.map((r) => r.kind), + count: revokedRows.length, + }, + }); + + return apiSuccess({ revoked: revokedRows }); +}); diff --git a/src/app/api/consent/ai/route.ts b/src/app/api/consent/ai/route.ts new file mode 100644 index 000000000..39d25faf3 --- /dev/null +++ b/src/app/api/consent/ai/route.ts @@ -0,0 +1,84 @@ +/** + * v1.4.40 SB-10 — AI consent receipts. + * + * POST /api/consent/ai + * Body: { kind, artefact, signedAt } + * Persists a fresh consent receipt. Returns the inserted row. + * + * Reader + revoke handlers live in `./latest/route.ts`. + */ +import { NextRequest } from "next/server"; +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiError, apiSuccess, safeJson } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { consentPostBody } from "@/lib/validations/consent"; +import { createReceipt } from "@/lib/consent/receipts"; + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const { data: body, error } = await safeJson(request); + if (error) return error; + + const parsed = consentPostBody.safeParse(body); + if (!parsed.success) { + return apiError(parsed.error.issues[0].message, 400); + } + + const { kind, artefact, signedAt } = parsed.data; + const receipt = await createReceipt(user.id, kind, artefact, signedAt); + + // Audit trail — separate from the row itself so the legal team can + // reconstruct "who minted this and when" even if the receipts table + // is rebuilt from a backup. Fire-and-forget; audit failures must + // never bubble into a user-facing 500. + auditLog("consent.ai.grant", { + userId: user.id, + details: { kind, receiptId: receipt.id }, + }).catch(() => {}); + + annotate({ + action: { name: "consent.ai.grant" }, + meta: { kind, receiptId: receipt.id }, + }); + + return apiSuccess({ + id: receipt.id, + receipt: serialiseReceipt(receipt), + }); +}); + +interface SerialisedReceipt { + id: string; + userId: string; + kind: string; + signedAt: string; + revokedAt: string | null; + createdAt: string; +} + +/** + * Public projection of a `ConsentReceipt`. We deliberately strip + * `artefact` from the API response — the artefact is large, opaque, + * and only useful at audit time (read directly from the DB by an + * operator). Echoing it on every grant would balloon the payload and + * leak the signed token over network paths it doesn't need to cross. + */ +export function serialiseReceipt(receipt: { + id: string; + userId: string; + kind: string; + signedAt: Date; + revokedAt: Date | null; + createdAt: Date; +}): SerialisedReceipt { + return { + id: receipt.id, + userId: receipt.userId, + kind: receipt.kind, + signedAt: receipt.signedAt.toISOString(), + revokedAt: receipt.revokedAt?.toISOString() ?? null, + createdAt: receipt.createdAt.toISOString(), + }; +}