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