@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.
- package/README.md +75 -56
- package/bin/index.mjs +369 -394
- package/package.json +14 -4
- package/templates/drizzle-client.ts +44 -0
- package/templates/drizzle-schema.ts +224 -117
- package/templates/env-example +6 -3
- package/templates/express-breach-route.ts +98 -0
- package/templates/express-consent-route.ts +336 -79
- package/templates/express-cross-border-route.ts +152 -0
- package/templates/express-dpia-route.ts +332 -0
- package/templates/express-dsr-route.ts +76 -0
- package/templates/express-lawful-basis-route.ts +146 -0
- package/templates/express-request-context.ts +130 -0
- package/templates/github-ndpr-audit.yml +31 -0
- package/templates/ndpr-audit.json +14 -0
- package/templates/nextjs-breach-route.ts +145 -79
- package/templates/nextjs-consent-route.ts +343 -70
- package/templates/nextjs-cross-border-route.ts +314 -0
- package/templates/nextjs-dpia-route.ts +510 -0
- package/templates/nextjs-dsr-route.ts +93 -58
- package/templates/nextjs-lawful-basis-route.ts +283 -0
- package/templates/nextjs-layout.tsx +122 -50
- package/templates/nextjs-request-context.ts +137 -0
- package/templates/prisma-schema.prisma +121 -51
- package/templates/express-setup.ts +0 -250
|
@@ -1,100 +1,373 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Next.js App Router —
|
|
3
|
-
*
|
|
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 {
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
|
37
|
-
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' };
|
|
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
|
-
|
|
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: '
|
|
42
|
-
{ status:
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
consents,
|
|
56
|
-
version,
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
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
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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
|
}
|