mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
feat(consent): POST + GET + DELETE endpoints at /api/consent/ai
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) <noreply@anthropic.com>
This commit is contained in:
1 parent
8692a97e3f
commit
d6809e3d54
2 files changed
+227
No files matched your search
@@ -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<ConsentKind, ReturnType<typeof serialiseReceipt> | 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<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(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "consent.ai.revoke" },
|
||||
meta: {
|
||||
kinds_revoked: revokedRows.map((r) => r.kind),
|
||||
count: revokedRows.length,
|
||||
},
|
||||
});
|
||||
|
||||
return apiSuccess({ revoked: revokedRows });
|
||||
});
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user