@tantainnovative/create-ndpr 0.4.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.
- package/README.md +64 -68
- package/bin/index.mjs +366 -526
- package/package.json +1 -1
- package/templates/drizzle-client.ts +44 -0
- package/templates/drizzle-schema.ts +200 -178
- package/templates/env-example +6 -3
- package/templates/express-breach-route.ts +98 -0
- package/templates/express-consent-route.ts +304 -153
- package/templates/express-cross-border-route.ts +94 -259
- package/templates/express-dpia-route.ts +267 -222
- package/templates/express-dsr-route.ts +76 -0
- package/templates/express-lawful-basis-route.ts +88 -234
- package/templates/express-request-context.ts +130 -0
- package/templates/github-ndpr-audit.yml +3 -3
- package/templates/nextjs-breach-route.ts +117 -183
- package/templates/nextjs-consent-route.ts +312 -104
- package/templates/nextjs-cross-border-route.ts +239 -250
- package/templates/nextjs-dpia-route.ts +434 -214
- package/templates/nextjs-dsr-route.ts +70 -116
- package/templates/nextjs-lawful-basis-route.ts +207 -221
- package/templates/nextjs-layout.tsx +120 -57
- package/templates/nextjs-request-context.ts +137 -0
- package/templates/prisma-schema.prisma +89 -77
- package/templates/express-setup.ts +0 -509
|
@@ -1,165 +1,373 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Next.js App Router —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* NDPA Section 26 (consent) and Section 25 (lawful basis). Records the
|
|
6
|
-
* subject's affirmative choice with the audit metadata NDPC asks for.
|
|
7
|
-
*
|
|
8
|
-
* Endpoints:
|
|
9
|
-
* GET /api/consent?subjectId=xxx — Load the active consent record
|
|
10
|
-
* POST /api/consent — Save new consent (revokes previous)
|
|
11
|
-
* 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.
|
|
12
4
|
*/
|
|
13
|
-
|
|
14
5
|
import { NextRequest, NextResponse } from 'next/server';
|
|
6
|
+
import {
|
|
7
|
+
getNDPRContextProblem,
|
|
8
|
+
resolveNDPRRequestContext,
|
|
9
|
+
} from '{{NDPR_CONTEXT_IMPORT}}';
|
|
15
10
|
// {{#if ORM=prisma}}
|
|
16
|
-
import { PrismaClient } from '@prisma/client';
|
|
17
|
-
|
|
11
|
+
import { Prisma, PrismaClient } from '@prisma/client';
|
|
18
12
|
const prisma = new PrismaClient();
|
|
19
13
|
// {{/if}}
|
|
20
14
|
// {{#if ORM=drizzle}}
|
|
21
|
-
import { db } from '
|
|
22
|
-
import { consentRecords, complianceAuditLog } from '
|
|
23
|
-
import {
|
|
15
|
+
import { db } from '{{NDPR_DB_IMPORT}}';
|
|
16
|
+
import { consentRecords, complianceAuditLog } from '{{NDPR_SCHEMA_IMPORT}}';
|
|
17
|
+
import { and, desc, eq, isNull } from 'drizzle-orm';
|
|
24
18
|
// {{/if}}
|
|
25
19
|
// {{#if ORM=none}}
|
|
26
|
-
//
|
|
27
|
-
// in-memory map below is enough to develop against locally but DOES NOT
|
|
28
|
-
// satisfy NDPA Section 44 (record-keeping) or the auditability NDPC asks
|
|
29
|
-
// about. Replace `consentStore`/`auditLog` with your DB / KV / API.
|
|
20
|
+
// DEVELOPMENT ONLY: replace both stores with one durable transactional store.
|
|
30
21
|
interface ConsentRecord {
|
|
31
|
-
id: string;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
revokedAt: Date | null;
|
|
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;
|
|
41
31
|
}
|
|
42
32
|
const consentStore = new Map<string, ConsentRecord>();
|
|
43
|
-
const auditLog:
|
|
44
|
-
function newId() { return crypto.randomUUID(); }
|
|
33
|
+
const auditLog: AuditRow[] = [];
|
|
45
34
|
// {{/if}}
|
|
46
35
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
36
|
+
interface ConsentSettings {
|
|
37
|
+
consents: Record<string, boolean>;
|
|
38
|
+
version: string;
|
|
39
|
+
method: string;
|
|
40
|
+
hasInteracted: boolean;
|
|
41
|
+
lawfulBasis?: string;
|
|
42
|
+
timestamp: number;
|
|
43
|
+
}
|
|
50
44
|
|
|
51
|
-
|
|
52
|
-
|
|
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';
|
|
53
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';
|
|
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 };
|
|
54
76
|
|
|
55
77
|
// {{#if ORM=prisma}}
|
|
56
78
|
const record = await prisma.consentRecord.findFirst({
|
|
57
|
-
where: { subjectId, revokedAt: null },
|
|
79
|
+
where: { tenantId, subjectId, revokedAt: null },
|
|
58
80
|
orderBy: { createdAt: 'desc' },
|
|
59
81
|
});
|
|
60
82
|
// {{/if}}
|
|
61
83
|
// {{#if ORM=drizzle}}
|
|
62
|
-
const [record] = await db
|
|
63
|
-
.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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);
|
|
68
91
|
// {{/if}}
|
|
69
92
|
// {{#if ORM=none}}
|
|
70
93
|
const record = [...consentStore.values()]
|
|
71
|
-
.filter((
|
|
72
|
-
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0];
|
|
94
|
+
.filter((row) => row.tenantId === tenantId && row.subjectId === subjectId && row.revokedAt === null)
|
|
95
|
+
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ?? null;
|
|
73
96
|
// {{/if}}
|
|
74
|
-
|
|
75
|
-
return NextResponse.json(record ?? null);
|
|
97
|
+
return NextResponse.json(record ? toConsentSettings(record) : null);
|
|
76
98
|
}
|
|
77
99
|
|
|
78
|
-
// POST /api/consent
|
|
79
100
|
export async function POST(req: NextRequest) {
|
|
80
|
-
const
|
|
81
|
-
const
|
|
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' };
|
|
82
110
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
{ error: 'subjectId, consents, and version are required' },
|
|
86
|
-
{ status: 400 },
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
const ipAddress = req.headers.get('x-forwarded-for');
|
|
111
|
+
const forwardedFor = req.headers.get('x-forwarded-for');
|
|
112
|
+
const ipAddress = forwardedFor?.split(',')[0]?.trim() || null;
|
|
91
113
|
const userAgent = req.headers.get('user-agent');
|
|
114
|
+
const clientTimestamp = new Date(input.timestamp);
|
|
115
|
+
const activeSubjectKey = JSON.stringify([tenantId, subjectId]);
|
|
92
116
|
|
|
93
117
|
// {{#if ORM=prisma}}
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
+
}
|
|
99
134
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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;
|
|
106
160
|
});
|
|
161
|
+
if (!transactionResult) {
|
|
162
|
+
return NextResponse.json(
|
|
163
|
+
{ error: 'Concurrent consent update conflict; retry the same idempotent request.' },
|
|
164
|
+
{ status: 409 },
|
|
165
|
+
);
|
|
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;
|
|
107
174
|
// {{/if}}
|
|
108
175
|
// {{#if ORM=drizzle}}
|
|
109
|
-
await db.
|
|
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
|
+
}
|
|
110
189
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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;
|
|
117
217
|
});
|
|
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;
|
|
118
231
|
// {{/if}}
|
|
119
232
|
// {{#if ORM=none}}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
+
}
|
|
123
255
|
}
|
|
124
256
|
const record: ConsentRecord = {
|
|
125
|
-
id:
|
|
126
|
-
|
|
127
|
-
|
|
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,
|
|
128
261
|
};
|
|
129
262
|
consentStore.set(record.id, record);
|
|
130
|
-
auditLog.push({
|
|
263
|
+
auditLog.push({
|
|
264
|
+
id: crypto.randomUUID(), tenantId, module: 'consent', action: 'created',
|
|
265
|
+
entityId: record.id, performedBy: context.actorId, at: now,
|
|
266
|
+
});
|
|
131
267
|
// {{/if}}
|
|
132
|
-
|
|
133
|
-
return NextResponse.json(record, { status: 201 });
|
|
268
|
+
return NextResponse.json(toConsentSettings(record), { status: 201 });
|
|
134
269
|
}
|
|
135
270
|
|
|
136
|
-
// DELETE /api/consent?subjectId=xxx — Section 26 withdrawal
|
|
137
271
|
export async function DELETE(req: NextRequest) {
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
if (
|
|
141
|
-
|
|
142
|
-
|
|
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();
|
|
143
277
|
|
|
144
278
|
// {{#if ORM=prisma}}
|
|
145
|
-
await prisma
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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;
|
|
151
292
|
});
|
|
152
293
|
// {{/if}}
|
|
153
294
|
// {{#if ORM=drizzle}}
|
|
154
|
-
await db.
|
|
155
|
-
|
|
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
|
+
});
|
|
156
313
|
// {{/if}}
|
|
157
314
|
// {{#if ORM=none}}
|
|
158
|
-
|
|
159
|
-
|
|
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
|
+
});
|
|
160
328
|
}
|
|
161
|
-
auditLog.push({ id: newId(), module: 'consent', action: 'revoked', entityId: subjectId, at: new Date() });
|
|
162
329
|
// {{/if}}
|
|
330
|
+
return NextResponse.json({ success: true, revoked });
|
|
331
|
+
}
|
|
332
|
+
|
|
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
|
+
}
|
|
351
|
+
|
|
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
|
+
}
|
|
163
366
|
|
|
164
|
-
|
|
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);
|
|
165
373
|
}
|