/** Express — tenant-scoped consent router for {{ORG_NAME_COMMENT}}. */ import { Router } from 'express'; import { getNDPRContextProblem, resolveNDPRRequestContext } from '{{NDPR_CONTEXT_IMPORT}}'; // {{#if ORM=prisma}} import { Prisma, PrismaClient } from '@prisma/client'; const prisma = new PrismaClient(); // {{/if}} // {{#if ORM=drizzle}} import { db } from '{{NDPR_DB_IMPORT}}'; import { complianceAuditLog, consentRecords } from '{{NDPR_SCHEMA_IMPORT}}'; import { and, desc, eq, isNull } from 'drizzle-orm'; // {{/if}} // {{#if ORM=none}} // DEVELOPMENT ONLY: replace both stores with one durable transactional store. interface ConsentRow { id: string; tenantId: string; subjectId: string; activeSubjectKey: string | null; consents: Record; version: string; method: string; hasInteracted: boolean; lawfulBasis: string | null; ipAddress: string | null; userAgent: string | null; clientTimestamp: Date; createdAt: Date; revokedAt: Date | null; } const consentStore = new Map(); const auditLog: Array<{ id: string; tenantId: string; action: string; entityId: string; performedBy: string | null; at: Date; }> = []; // {{/if}} interface ConsentSettings { consents: Record; version: string; method: string; hasInteracted: boolean; lawfulBasis?: string; timestamp: number; } export const consentRouter = Router(); consentRouter.get('/', async (req, res) => { const context = await resolveNDPRRequestContext(req); const problem = getNDPRContextProblem(context, 'subject'); if (problem) return res.status(problem.status).json({ error: problem.error }); const subjectId = context.subjectId as string; // {{#if ORM=prisma}} const record = await prisma.consentRecord.findFirst({ where: { tenantId: context.tenantId, subjectId, revokedAt: null }, orderBy: { createdAt: 'desc' }, }); // {{/if}} // {{#if ORM=drizzle}} const [record] = await db.select().from(consentRecords).where(and( eq(consentRecords.tenantId, context.tenantId), eq(consentRecords.subjectId, subjectId), isNull(consentRecords.revokedAt), )).orderBy(desc(consentRecords.createdAt)).limit(1); // {{/if}} // {{#if ORM=none}} const record = [...consentStore.values()] .filter((row) => row.tenantId === context.tenantId && row.subjectId === subjectId && row.revokedAt === null) .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ?? null; // {{/if}} return res.json(record ? toConsentSettings(record) : null); }); consentRouter.post('/', async (req, res) => { const context = await resolveNDPRRequestContext(req); const problem = getNDPRContextProblem(context, 'subject'); if (problem) return res.status(problem.status).json({ error: problem.error }); const subjectId = context.subjectId as string; const validationError = validateConsentBody(req.body); if (validationError) return res.status(400).json({ error: validationError }); const value = req.body as Omit & { method?: string }; const input: ConsentSettings = { ...value, method: value.method ?? 'api' }; const forwarded = req.get('x-forwarded-for'); const ipAddress = forwarded?.split(',')[0]?.trim() || req.socket.remoteAddress || null; const userAgent = req.get('user-agent') ?? null; const tenantId = context.tenantId; const clientTimestamp = new Date(input.timestamp); const activeSubjectKey = JSON.stringify([tenantId, subjectId]); // {{#if ORM=prisma}} const transactionResult = await prisma.$transaction(async (tx) => { const replay = await tx.consentRecord.findFirst({ where: { tenantId, subjectId, clientTimestamp, version: input.version, method: input.method, }, orderBy: { createdAt: 'desc' }, }); if (replay) { return sameConsentMutation(replay, input) ? { kind: 'record' as const, record: replay } : { kind: 'collision' as const }; } await tx.consentRecord.updateMany({ where: { tenantId, subjectId, revokedAt: null }, data: { revokedAt: new Date(), activeSubjectKey: null }, }); const created = await tx.consentRecord.create({ data: { tenantId, subjectId, activeSubjectKey, consents: input.consents, version: input.version, method: input.method, hasInteracted: input.hasInteracted, lawfulBasis: input.lawfulBasis ?? null, ipAddress, userAgent, clientTimestamp, } }); await tx.complianceAuditLog.create({ data: { tenantId, module: 'consent', action: 'created', entityId: created.id, entityType: 'ConsentRecord', performedBy: context.actorId, changes: { subjectId, version: input.version, method: input.method, hasInteracted: input.hasInteracted, consentCategories: Object.keys(input.consents), }, } }); return { kind: 'record' as const, record: created }; }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable, }).catch((error: unknown) => { if (isConcurrencyConflict(error)) return null; throw error; }); if (!transactionResult) { return res.status(409).json({ error: 'Concurrent consent update conflict; retry the same idempotent request.', }); } if (transactionResult.kind === 'collision') { return res.status(409).json({ error: 'Idempotency collision: timestamp/version/method identify different consent data.', }); } const record = transactionResult.record; // {{/if}} // {{#if ORM=drizzle}} const transactionResult = await db.transaction(async (tx) => { const [replay] = await tx.select().from(consentRecords).where(and( eq(consentRecords.tenantId, tenantId), eq(consentRecords.subjectId, subjectId), eq(consentRecords.clientTimestamp, clientTimestamp), eq(consentRecords.version, input.version), eq(consentRecords.method, input.method), )).orderBy(desc(consentRecords.createdAt)).limit(1); if (replay) { return sameConsentMutation(replay, input) ? { kind: 'record' as const, record: replay } : { kind: 'collision' as const }; } await tx.update(consentRecords).set({ revokedAt: new Date(), activeSubjectKey: null, }).where(and( eq(consentRecords.tenantId, tenantId), eq(consentRecords.subjectId, subjectId), isNull(consentRecords.revokedAt), )); const [created] = await tx.insert(consentRecords).values({ tenantId, subjectId, activeSubjectKey, consents: input.consents, version: input.version, method: input.method, hasInteracted: input.hasInteracted, lawfulBasis: input.lawfulBasis ?? null, ipAddress, userAgent, clientTimestamp, }).returning(); await tx.insert(complianceAuditLog).values({ tenantId, module: 'consent', action: 'created', entityId: created.id, entityType: 'ConsentRecord', performedBy: context.actorId, changes: { subjectId, version: input.version, method: input.method, hasInteracted: input.hasInteracted, consentCategories: Object.keys(input.consents), }, }); return { kind: 'record' as const, record: created }; }, { isolationLevel: 'serializable' }).catch((error: unknown) => { if (isConcurrencyConflict(error)) return null; throw error; }); if (!transactionResult) { return res.status(409).json({ error: 'Concurrent consent update conflict; retry the same idempotent request.', }); } if (transactionResult.kind === 'collision') { return res.status(409).json({ error: 'Idempotency collision: timestamp/version/method identify different consent data.', }); } const record = transactionResult.record; // {{/if}} // {{#if ORM=none}} const replay = [...consentStore.values()].find((row) => row.tenantId === tenantId && row.subjectId === subjectId && row.clientTimestamp.getTime() === input.timestamp && row.version === input.version && row.method === input.method, ); if (replay) { if (!sameConsentMutation(replay, input)) { return res.status(409).json({ error: 'Idempotency collision: timestamp/version/method identify different consent data.', }); } return res.status(201).json(toConsentSettings(replay)); } const now = new Date(); for (const row of consentStore.values()) { if (row.tenantId === tenantId && row.subjectId === subjectId && row.revokedAt === null) { row.revokedAt = now; row.activeSubjectKey = null; } } const record: ConsentRow = { id: crypto.randomUUID(), tenantId, subjectId, activeSubjectKey, consents: input.consents, version: input.version, method: input.method, hasInteracted: input.hasInteracted, lawfulBasis: input.lawfulBasis ?? null, ipAddress, userAgent, clientTimestamp, createdAt: now, revokedAt: null, }; consentStore.set(record.id, record); auditLog.push({ id: crypto.randomUUID(), tenantId, action: 'created', entityId: record.id, performedBy: context.actorId, at: now, }); // {{/if}} return res.status(201).json(toConsentSettings(record)); }); consentRouter.delete('/', async (req, res) => { const context = await resolveNDPRRequestContext(req); const problem = getNDPRContextProblem(context, 'subject'); if (problem) return res.status(problem.status).json({ error: problem.error }); const subjectId = context.subjectId as string; const tenantId = context.tenantId; const now = new Date(); // {{#if ORM=prisma}} const revoked = await prisma.$transaction(async (tx) => { const result = await tx.consentRecord.updateMany({ where: { tenantId, subjectId, revokedAt: null }, data: { revokedAt: now, activeSubjectKey: null }, }); if (result.count > 0) { await tx.complianceAuditLog.create({ data: { tenantId, module: 'consent', action: 'revoked', entityId: subjectId, entityType: 'ConsentRecord', performedBy: context.actorId, changes: { subjectId, revokedRecords: result.count }, } }); } return result.count; }); // {{/if}} // {{#if ORM=drizzle}} const revoked = await db.transaction(async (tx) => { const rows = await tx.update(consentRecords).set({ revokedAt: now, activeSubjectKey: null, }).where(and( eq(consentRecords.tenantId, tenantId), eq(consentRecords.subjectId, subjectId), isNull(consentRecords.revokedAt), )).returning({ id: consentRecords.id }); if (rows.length > 0) { await tx.insert(complianceAuditLog).values({ tenantId, module: 'consent', action: 'revoked', entityId: subjectId, entityType: 'ConsentRecord', performedBy: context.actorId, changes: { subjectId, revokedRecords: rows.length }, }); } return rows.length; }); // {{/if}} // {{#if ORM=none}} let revoked = 0; for (const row of consentStore.values()) { if (row.tenantId === tenantId && row.subjectId === subjectId && row.revokedAt === null) { row.revokedAt = now; row.activeSubjectKey = null; revoked += 1; } } if (revoked > 0) { auditLog.push({ id: crypto.randomUUID(), tenantId, action: 'revoked', entityId: subjectId, performedBy: context.actorId, at: now, }); } // {{/if}} return res.json({ success: true, revoked }); }); function validateConsentBody(body: unknown): string | null { if (!body || typeof body !== 'object') return 'A JSON object is required'; const value = body as Record; if (!value.consents || typeof value.consents !== 'object' || Array.isArray(value.consents)) { return 'consents must be an object'; } if (Object.values(value.consents).some((consent) => typeof consent !== 'boolean')) { return 'Every consent value must be boolean'; } if (typeof value.version !== 'string' || !value.version.trim()) return 'version is required'; if (value.method !== undefined && (typeof value.method !== 'string' || !value.method.trim())) { return 'method must be a non-empty string'; } if (value.hasInteracted !== true && value.hasInteracted !== false) { return 'hasInteracted must be boolean'; } if (typeof value.timestamp !== 'number' || !Number.isSafeInteger(value.timestamp) || value.timestamp < 0 || !Number.isFinite(new Date(value.timestamp).getTime())) { return 'timestamp must be a non-negative safe-integer epoch value within the valid Date range'; } if (value.lawfulBasis !== undefined && typeof value.lawfulBasis !== 'string') { return 'lawfulBasis must be a string'; } return null; } function toConsentSettings(record: { consents: unknown; version: string; method: string; hasInteracted: boolean; lawfulBasis: string | null; clientTimestamp: Date | null; createdAt: Date; }): ConsentSettings { return { consents: record.consents as Record, timestamp: record.clientTimestamp?.getTime() ?? record.createdAt.getTime(), version: record.version, method: record.method, hasInteracted: record.hasInteracted, lawfulBasis: record.lawfulBasis ?? undefined, }; } function sameConsentMutation( record: { consents: unknown; hasInteracted: boolean; lawfulBasis: string | null }, input: ConsentSettings, ): boolean { const storedConsents = record.consents as Record; return JSON.stringify(sortedConsentEntries(storedConsents)) === JSON.stringify(sortedConsentEntries(input.consents)) && record.hasInteracted === input.hasInteracted && (record.lawfulBasis ?? undefined) === input.lawfulBasis; } function sortedConsentEntries(consents: Record) { return Object.entries(consents).sort(([left], [right]) => left.localeCompare(right)); } function isConcurrencyConflict(error: unknown): boolean { if (!error || typeof error !== 'object') return false; const candidate = error as { code?: unknown; cause?: unknown }; const code = typeof candidate.code === 'string' ? candidate.code : ''; if (['P2002', 'P2034', '23505', '40001', '40P01'].includes(code)) return true; return candidate.cause !== error && isConcurrencyConflict(candidate.cause); }