@tantainnovative/create-ndpr 0.1.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,100 +1,373 @@
1
1
  /**
2
- * Next.js App Router — Consent API Route
3
- * Generated by create-ndpr for: {{ORG_NAME}}
4
- *
5
- * NDPA §25 (lawful basis) and §26 (right to withdraw consent).
6
- *
7
- * Endpoints:
8
- * GET /api/consent?subjectId=xxx — Load the active consent record
9
- * POST /api/consent — Save new consent (revokes previous)
10
- * DELETE /api/consent?subjectId=xxx — Revoke all active consent
2
+ * Next.js App Router — tenant-scoped consent persistence for {{ORG_NAME_COMMENT}}.
3
+ * Subject and tenant identity come only from the generated server context.
11
4
  */
12
-
13
5
  import { NextRequest, NextResponse } from 'next/server';
14
- import { PrismaClient } from '@prisma/client';
15
-
6
+ import {
7
+ getNDPRContextProblem,
8
+ resolveNDPRRequestContext,
9
+ } from '{{NDPR_CONTEXT_IMPORT}}';
10
+ // {{#if ORM=prisma}}
11
+ import { Prisma, PrismaClient } from '@prisma/client';
16
12
  const prisma = new PrismaClient();
13
+ // {{/if}}
14
+ // {{#if ORM=drizzle}}
15
+ import { db } from '{{NDPR_DB_IMPORT}}';
16
+ import { consentRecords, complianceAuditLog } from '{{NDPR_SCHEMA_IMPORT}}';
17
+ import { and, desc, eq, isNull } from 'drizzle-orm';
18
+ // {{/if}}
19
+ // {{#if ORM=none}}
20
+ // DEVELOPMENT ONLY: replace both stores with one durable transactional store.
21
+ interface ConsentRecord {
22
+ id: string; tenantId: string; subjectId: string; activeSubjectKey: string | null;
23
+ consents: Record<string, boolean>; version: string; method: string;
24
+ hasInteracted: boolean; lawfulBasis: string | null;
25
+ ipAddress: string | null; userAgent: string | null; clientTimestamp: Date;
26
+ createdAt: Date; revokedAt: Date | null;
27
+ }
28
+ interface AuditRow {
29
+ id: string; tenantId: string; module: string; action: string;
30
+ entityId: string; performedBy: string | null; at: Date;
31
+ }
32
+ const consentStore = new Map<string, ConsentRecord>();
33
+ const auditLog: AuditRow[] = [];
34
+ // {{/if}}
17
35
 
18
- // GET /api/consent?subjectId=xxx
19
- export async function GET(req: NextRequest) {
20
- const subjectId = req.nextUrl.searchParams.get('subjectId');
36
+ interface ConsentSettings {
37
+ consents: Record<string, boolean>;
38
+ version: string;
39
+ method: string;
40
+ hasInteracted: boolean;
41
+ lawfulBasis?: string;
42
+ timestamp: number;
43
+ }
21
44
 
22
- if (!subjectId) {
23
- return NextResponse.json({ error: 'subjectId required' }, { status: 400 });
45
+ function validateConsentBody(body: unknown): string | null {
46
+ if (!body || typeof body !== 'object') return 'A JSON object is required';
47
+ const value = body as Record<string, unknown>;
48
+ if (!value.consents || typeof value.consents !== 'object' || Array.isArray(value.consents)) {
49
+ return 'consents must be an object';
50
+ }
51
+ if (Object.values(value.consents).some((consent) => typeof consent !== 'boolean')) {
52
+ return 'Every consent value must be boolean';
53
+ }
54
+ if (typeof value.version !== 'string' || !value.version.trim()) return 'version is required';
55
+ if (value.method !== undefined && (typeof value.method !== 'string' || !value.method.trim())) {
56
+ return 'method must be a non-empty string';
57
+ }
58
+ if (value.hasInteracted !== true && value.hasInteracted !== false) {
59
+ return 'hasInteracted must be boolean';
60
+ }
61
+ if (typeof value.timestamp !== 'number' || !Number.isSafeInteger(value.timestamp) || value.timestamp < 0
62
+ || !Number.isFinite(new Date(value.timestamp).getTime())) {
63
+ return 'timestamp must be a non-negative safe-integer epoch value within the valid Date range';
64
+ }
65
+ if (value.lawfulBasis !== undefined && typeof value.lawfulBasis !== 'string') {
66
+ return 'lawfulBasis must be a string';
24
67
  }
68
+ return null;
69
+ }
70
+
71
+ export async function GET(req: NextRequest) {
72
+ const context = await resolveNDPRRequestContext(req);
73
+ const problem = getNDPRContextProblem(context, 'subject');
74
+ if (problem) return NextResponse.json({ error: problem.error }, { status: problem.status });
75
+ const { tenantId, subjectId } = context as typeof context & { subjectId: string };
25
76
 
77
+ // {{#if ORM=prisma}}
26
78
  const record = await prisma.consentRecord.findFirst({
27
- where: { subjectId, revokedAt: null },
79
+ where: { tenantId, subjectId, revokedAt: null },
28
80
  orderBy: { createdAt: 'desc' },
29
81
  });
30
-
31
- return NextResponse.json(record);
82
+ // {{/if}}
83
+ // {{#if ORM=drizzle}}
84
+ const [record] = await db.select().from(consentRecords)
85
+ .where(and(
86
+ eq(consentRecords.tenantId, tenantId),
87
+ eq(consentRecords.subjectId, subjectId),
88
+ isNull(consentRecords.revokedAt),
89
+ ))
90
+ .orderBy(desc(consentRecords.createdAt)).limit(1);
91
+ // {{/if}}
92
+ // {{#if ORM=none}}
93
+ const record = [...consentStore.values()]
94
+ .filter((row) => row.tenantId === tenantId && row.subjectId === subjectId && row.revokedAt === null)
95
+ .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ?? null;
96
+ // {{/if}}
97
+ return NextResponse.json(record ? toConsentSettings(record) : null);
32
98
  }
33
99
 
34
- // POST /api/consent
35
100
  export async function POST(req: NextRequest) {
36
- const body = await req.json();
37
- const { subjectId, consents, version, method, lawfulBasis } = body;
101
+ const context = await resolveNDPRRequestContext(req);
102
+ const problem = getNDPRContextProblem(context, 'subject');
103
+ if (problem) return NextResponse.json({ error: problem.error }, { status: problem.status });
104
+ const { tenantId, subjectId } = context as typeof context & { subjectId: string };
105
+ const body: unknown = await req.json().catch(() => null);
106
+ const validationError = validateConsentBody(body);
107
+ if (validationError) return NextResponse.json({ error: validationError }, { status: 400 });
108
+ const value = body as Omit<ConsentSettings, 'method'> & { method?: string };
109
+ const input: ConsentSettings = { ...value, method: value.method ?? 'api' };
110
+
111
+ const forwardedFor = req.headers.get('x-forwarded-for');
112
+ const ipAddress = forwardedFor?.split(',')[0]?.trim() || null;
113
+ const userAgent = req.headers.get('user-agent');
114
+ const clientTimestamp = new Date(input.timestamp);
115
+ const activeSubjectKey = JSON.stringify([tenantId, subjectId]);
116
+
117
+ // {{#if ORM=prisma}}
118
+ const transactionResult = await prisma.$transaction(async (tx) => {
119
+ const replay = await tx.consentRecord.findFirst({
120
+ where: {
121
+ tenantId,
122
+ subjectId,
123
+ clientTimestamp,
124
+ version: input.version,
125
+ method: input.method,
126
+ },
127
+ orderBy: { createdAt: 'desc' },
128
+ });
129
+ if (replay) {
130
+ return sameConsentMutation(replay, input)
131
+ ? { kind: 'record' as const, record: replay }
132
+ : { kind: 'collision' as const };
133
+ }
38
134
 
39
- if (!subjectId || !consents || !version) {
135
+ await tx.consentRecord.updateMany({
136
+ where: { tenantId, subjectId, revokedAt: null },
137
+ data: { revokedAt: new Date(), activeSubjectKey: null },
138
+ });
139
+ const created = await tx.consentRecord.create({ data: {
140
+ tenantId, subjectId, activeSubjectKey, consents: input.consents,
141
+ version: input.version, method: input.method,
142
+ hasInteracted: input.hasInteracted, lawfulBasis: input.lawfulBasis ?? null,
143
+ ipAddress, userAgent, clientTimestamp,
144
+ } });
145
+ await tx.complianceAuditLog.create({ data: {
146
+ tenantId, module: 'consent', action: 'created', entityId: created.id,
147
+ entityType: 'ConsentRecord', performedBy: context.actorId,
148
+ changes: {
149
+ subjectId, version: input.version, method: input.method,
150
+ hasInteracted: input.hasInteracted,
151
+ consentCategories: Object.keys(input.consents),
152
+ },
153
+ } });
154
+ return { kind: 'record' as const, record: created };
155
+ }, {
156
+ isolationLevel: Prisma.TransactionIsolationLevel.Serializable,
157
+ }).catch((error: unknown) => {
158
+ if (isConcurrencyConflict(error)) return null;
159
+ throw error;
160
+ });
161
+ if (!transactionResult) {
40
162
  return NextResponse.json(
41
- { error: 'subjectId, consents, and version are required' },
42
- { status: 400 },
163
+ { error: 'Concurrent consent update conflict; retry the same idempotent request.' },
164
+ { status: 409 },
43
165
  );
44
166
  }
167
+ if (transactionResult.kind === 'collision') {
168
+ return NextResponse.json(
169
+ { error: 'Idempotency collision: timestamp/version/method identify different consent data.' },
170
+ { status: 409 },
171
+ );
172
+ }
173
+ const record = transactionResult.record;
174
+ // {{/if}}
175
+ // {{#if ORM=drizzle}}
176
+ const transactionResult = await db.transaction(async (tx) => {
177
+ const [replay] = await tx.select().from(consentRecords).where(and(
178
+ eq(consentRecords.tenantId, tenantId),
179
+ eq(consentRecords.subjectId, subjectId),
180
+ eq(consentRecords.clientTimestamp, clientTimestamp),
181
+ eq(consentRecords.version, input.version),
182
+ eq(consentRecords.method, input.method),
183
+ )).orderBy(desc(consentRecords.createdAt)).limit(1);
184
+ if (replay) {
185
+ return sameConsentMutation(replay, input)
186
+ ? { kind: 'record' as const, record: replay }
187
+ : { kind: 'collision' as const };
188
+ }
45
189
 
46
- // Revoke any previously active consent records (immutable-audit pattern — NDPA §44).
47
- await prisma.consentRecord.updateMany({
48
- where: { subjectId, revokedAt: null },
49
- data: { revokedAt: new Date() },
50
- });
51
-
52
- const record = await prisma.consentRecord.create({
53
- data: {
54
- subjectId,
55
- consents,
56
- version,
57
- method: method ?? 'api',
58
- lawfulBasis: lawfulBasis ?? null,
59
- ipAddress: req.headers.get('x-forwarded-for'),
60
- userAgent: req.headers.get('user-agent'),
61
- },
190
+ await tx.update(consentRecords).set({
191
+ revokedAt: new Date(),
192
+ activeSubjectKey: null,
193
+ }).where(and(
194
+ eq(consentRecords.tenantId, tenantId),
195
+ eq(consentRecords.subjectId, subjectId),
196
+ isNull(consentRecords.revokedAt),
197
+ ));
198
+ const [created] = await tx.insert(consentRecords).values({
199
+ tenantId, subjectId, activeSubjectKey, consents: input.consents,
200
+ version: input.version, method: input.method,
201
+ hasInteracted: input.hasInteracted, lawfulBasis: input.lawfulBasis ?? null,
202
+ ipAddress, userAgent, clientTimestamp,
203
+ }).returning();
204
+ await tx.insert(complianceAuditLog).values({
205
+ tenantId, module: 'consent', action: 'created', entityId: created.id,
206
+ entityType: 'ConsentRecord', performedBy: context.actorId,
207
+ changes: {
208
+ subjectId, version: input.version, method: input.method,
209
+ hasInteracted: input.hasInteracted,
210
+ consentCategories: Object.keys(input.consents),
211
+ },
212
+ });
213
+ return { kind: 'record' as const, record: created };
214
+ }, { isolationLevel: 'serializable' }).catch((error: unknown) => {
215
+ if (isConcurrencyConflict(error)) return null;
216
+ throw error;
62
217
  });
63
-
64
- await prisma.complianceAuditLog.create({
65
- data: {
66
- module: 'consent',
67
- action: 'created',
68
- entityId: record.id,
69
- entityType: 'ConsentRecord',
70
- changes: { subjectId, version, consents },
71
- },
218
+ if (!transactionResult) {
219
+ return NextResponse.json(
220
+ { error: 'Concurrent consent update conflict; retry the same idempotent request.' },
221
+ { status: 409 },
222
+ );
223
+ }
224
+ if (transactionResult.kind === 'collision') {
225
+ return NextResponse.json(
226
+ { error: 'Idempotency collision: timestamp/version/method identify different consent data.' },
227
+ { status: 409 },
228
+ );
229
+ }
230
+ const record = transactionResult.record;
231
+ // {{/if}}
232
+ // {{#if ORM=none}}
233
+ const replay = [...consentStore.values()].find((row) =>
234
+ row.tenantId === tenantId
235
+ && row.subjectId === subjectId
236
+ && row.clientTimestamp.getTime() === input.timestamp
237
+ && row.version === input.version
238
+ && row.method === input.method,
239
+ );
240
+ if (replay) {
241
+ if (!sameConsentMutation(replay, input)) {
242
+ return NextResponse.json(
243
+ { error: 'Idempotency collision: timestamp/version/method identify different consent data.' },
244
+ { status: 409 },
245
+ );
246
+ }
247
+ return NextResponse.json(toConsentSettings(replay), { status: 201 });
248
+ }
249
+ const now = new Date();
250
+ for (const row of consentStore.values()) {
251
+ if (row.tenantId === tenantId && row.subjectId === subjectId && row.revokedAt === null) {
252
+ row.revokedAt = now;
253
+ row.activeSubjectKey = null;
254
+ }
255
+ }
256
+ const record: ConsentRecord = {
257
+ id: crypto.randomUUID(), tenantId, subjectId, activeSubjectKey,
258
+ consents: input.consents, version: input.version, method: input.method,
259
+ hasInteracted: input.hasInteracted, lawfulBasis: input.lawfulBasis ?? null,
260
+ ipAddress, userAgent, clientTimestamp, createdAt: now, revokedAt: null,
261
+ };
262
+ consentStore.set(record.id, record);
263
+ auditLog.push({
264
+ id: crypto.randomUUID(), tenantId, module: 'consent', action: 'created',
265
+ entityId: record.id, performedBy: context.actorId, at: now,
72
266
  });
73
-
74
- return NextResponse.json(record, { status: 201 });
267
+ // {{/if}}
268
+ return NextResponse.json(toConsentSettings(record), { status: 201 });
75
269
  }
76
270
 
77
- // DELETE /api/consent?subjectId=xxx
78
271
  export async function DELETE(req: NextRequest) {
79
- const subjectId = req.nextUrl.searchParams.get('subjectId');
272
+ const context = await resolveNDPRRequestContext(req);
273
+ const problem = getNDPRContextProblem(context, 'subject');
274
+ if (problem) return NextResponse.json({ error: problem.error }, { status: problem.status });
275
+ const { tenantId, subjectId } = context as typeof context & { subjectId: string };
276
+ const now = new Date();
80
277
 
81
- if (!subjectId) {
82
- return NextResponse.json({ error: 'subjectId required' }, { status: 400 });
278
+ // {{#if ORM=prisma}}
279
+ const revoked = await prisma.$transaction(async (tx) => {
280
+ const result = await tx.consentRecord.updateMany({
281
+ where: { tenantId, subjectId, revokedAt: null },
282
+ data: { revokedAt: now, activeSubjectKey: null },
283
+ });
284
+ if (result.count > 0) {
285
+ await tx.complianceAuditLog.create({ data: {
286
+ tenantId, module: 'consent', action: 'revoked', entityId: subjectId,
287
+ entityType: 'ConsentRecord', performedBy: context.actorId,
288
+ changes: { subjectId, revokedRecords: result.count },
289
+ } });
290
+ }
291
+ return result.count;
292
+ });
293
+ // {{/if}}
294
+ // {{#if ORM=drizzle}}
295
+ const revoked = await db.transaction(async (tx) => {
296
+ const rows = await tx.update(consentRecords).set({
297
+ revokedAt: now,
298
+ activeSubjectKey: null,
299
+ }).where(and(
300
+ eq(consentRecords.tenantId, tenantId),
301
+ eq(consentRecords.subjectId, subjectId),
302
+ isNull(consentRecords.revokedAt),
303
+ )).returning({ id: consentRecords.id });
304
+ if (rows.length > 0) {
305
+ await tx.insert(complianceAuditLog).values({
306
+ tenantId, module: 'consent', action: 'revoked', entityId: subjectId,
307
+ entityType: 'ConsentRecord', performedBy: context.actorId,
308
+ changes: { subjectId, revokedRecords: rows.length },
309
+ });
310
+ }
311
+ return rows.length;
312
+ });
313
+ // {{/if}}
314
+ // {{#if ORM=none}}
315
+ let revoked = 0;
316
+ for (const row of consentStore.values()) {
317
+ if (row.tenantId === tenantId && row.subjectId === subjectId && row.revokedAt === null) {
318
+ row.revokedAt = now;
319
+ row.activeSubjectKey = null;
320
+ revoked += 1;
321
+ }
322
+ }
323
+ if (revoked > 0) {
324
+ auditLog.push({
325
+ id: crypto.randomUUID(), tenantId, module: 'consent', action: 'revoked',
326
+ entityId: subjectId, performedBy: context.actorId, at: now,
327
+ });
83
328
  }
329
+ // {{/if}}
330
+ return NextResponse.json({ success: true, revoked });
331
+ }
84
332
 
85
- await prisma.consentRecord.updateMany({
86
- where: { subjectId, revokedAt: null },
87
- data: { revokedAt: new Date() },
88
- });
333
+ function toConsentSettings(record: {
334
+ consents: unknown;
335
+ version: string;
336
+ method: string;
337
+ hasInteracted: boolean;
338
+ lawfulBasis: string | null;
339
+ clientTimestamp: Date | null;
340
+ createdAt: Date;
341
+ }): ConsentSettings {
342
+ return {
343
+ consents: record.consents as Record<string, boolean>,
344
+ timestamp: record.clientTimestamp?.getTime() ?? record.createdAt.getTime(),
345
+ version: record.version,
346
+ method: record.method,
347
+ hasInteracted: record.hasInteracted,
348
+ lawfulBasis: record.lawfulBasis ?? undefined,
349
+ };
350
+ }
89
351
 
90
- await prisma.complianceAuditLog.create({
91
- data: {
92
- module: 'consent',
93
- action: 'revoked',
94
- entityId: subjectId,
95
- entityType: 'ConsentRecord',
96
- },
97
- });
352
+ function sameConsentMutation(
353
+ record: { consents: unknown; hasInteracted: boolean; lawfulBasis: string | null },
354
+ input: ConsentSettings,
355
+ ): boolean {
356
+ const storedConsents = record.consents as Record<string, boolean>;
357
+ return JSON.stringify(sortedConsentEntries(storedConsents))
358
+ === JSON.stringify(sortedConsentEntries(input.consents))
359
+ && record.hasInteracted === input.hasInteracted
360
+ && (record.lawfulBasis ?? undefined) === input.lawfulBasis;
361
+ }
362
+
363
+ function sortedConsentEntries(consents: Record<string, boolean>) {
364
+ return Object.entries(consents).sort(([left], [right]) => left.localeCompare(right));
365
+ }
98
366
 
99
- return NextResponse.json({ success: true });
367
+ function isConcurrencyConflict(error: unknown): boolean {
368
+ if (!error || typeof error !== 'object') return false;
369
+ const candidate = error as { code?: unknown; cause?: unknown };
370
+ const code = typeof candidate.code === 'string' ? candidate.code : '';
371
+ if (['P2002', 'P2034', '23505', '40001', '40P01'].includes(code)) return true;
372
+ return candidate.cause !== error && isConcurrencyConflict(candidate.cause);
100
373
  }