@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,105 +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';
|
|
19
|
-
import {
|
|
20
|
-
|
|
3
|
+
import { getNDPRContextProblem, resolveNDPRRequestContext } from '{{NDPR_CONTEXT_IMPORT}}';
|
|
4
|
+
// {{#if ORM=prisma}}
|
|
5
|
+
import { Prisma, PrismaClient } from '@prisma/client';
|
|
21
6
|
const prisma = new PrismaClient();
|
|
7
|
+
// {{/if}}
|
|
8
|
+
// {{#if ORM=drizzle}}
|
|
9
|
+
import { db } from '{{NDPR_DB_IMPORT}}';
|
|
10
|
+
import { complianceAuditLog, consentRecords } from '{{NDPR_SCHEMA_IMPORT}}';
|
|
11
|
+
import { and, desc, eq, isNull } from 'drizzle-orm';
|
|
12
|
+
// {{/if}}
|
|
13
|
+
// {{#if ORM=none}}
|
|
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 {
|
|
30
|
+
consents: Record<string, boolean>;
|
|
31
|
+
version: string;
|
|
32
|
+
method: string;
|
|
33
|
+
hasInteracted: boolean;
|
|
34
|
+
lawfulBasis?: string;
|
|
35
|
+
timestamp: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
22
38
|
export const consentRouter = Router();
|
|
23
39
|
|
|
24
|
-
// GET /consent?subjectId=xxx
|
|
25
40
|
consentRouter.get('/', async (req, res) => {
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
if (
|
|
29
|
-
|
|
30
|
-
}
|
|
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;
|
|
31
45
|
|
|
46
|
+
// {{#if ORM=prisma}}
|
|
32
47
|
const record = await prisma.consentRecord.findFirst({
|
|
33
|
-
where: { subjectId, revokedAt: null },
|
|
48
|
+
where: { tenantId: context.tenantId, subjectId, revokedAt: null },
|
|
34
49
|
orderBy: { createdAt: 'desc' },
|
|
35
50
|
});
|
|
36
|
-
|
|
37
|
-
|
|
51
|
+
// {{/if}}
|
|
52
|
+
// {{#if ORM=drizzle}}
|
|
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);
|
|
58
|
+
// {{/if}}
|
|
59
|
+
// {{#if ORM=none}}
|
|
60
|
+
const record = [...consentStore.values()]
|
|
61
|
+
.filter((row) => row.tenantId === context.tenantId && row.subjectId === subjectId && row.revokedAt === null)
|
|
62
|
+
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ?? null;
|
|
63
|
+
// {{/if}}
|
|
64
|
+
return res.json(record ? toConsentSettings(record) : null);
|
|
38
65
|
});
|
|
39
66
|
|
|
40
|
-
// POST /consent
|
|
41
67
|
consentRouter.post('/', async (req, res) => {
|
|
42
|
-
const
|
|
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' };
|
|
43
76
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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]);
|
|
47
83
|
|
|
48
|
-
//
|
|
49
|
-
await prisma
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
84
|
+
// {{#if ORM=prisma}}
|
|
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
|
+
}
|
|
53
101
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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;
|
|
67
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;
|
|
139
|
+
// {{/if}}
|
|
140
|
+
// {{#if ORM=drizzle}}
|
|
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
|
+
}
|
|
68
154
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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;
|
|
77
182
|
});
|
|
78
|
-
|
|
79
|
-
|
|
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;
|
|
194
|
+
// {{/if}}
|
|
195
|
+
// {{#if ORM=none}}
|
|
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
|
+
}
|
|
217
|
+
}
|
|
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,
|
|
223
|
+
};
|
|
224
|
+
consentStore.set(record.id, record);
|
|
225
|
+
auditLog.push({
|
|
226
|
+
id: crypto.randomUUID(), tenantId, action: 'created', entityId: record.id,
|
|
227
|
+
performedBy: context.actorId, at: now,
|
|
228
|
+
});
|
|
229
|
+
// {{/if}}
|
|
230
|
+
return res.status(201).json(toConsentSettings(record));
|
|
80
231
|
});
|
|
81
232
|
|
|
82
|
-
// DELETE /consent?subjectId=xxx
|
|
83
233
|
consentRouter.delete('/', async (req, res) => {
|
|
84
|
-
const
|
|
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();
|
|
85
240
|
|
|
86
|
-
if
|
|
87
|
-
|
|
241
|
+
// {{#if ORM=prisma}}
|
|
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;
|
|
255
|
+
});
|
|
256
|
+
// {{/if}}
|
|
257
|
+
// {{#if ORM=drizzle}}
|
|
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;
|
|
275
|
+
});
|
|
276
|
+
// {{/if}}
|
|
277
|
+
// {{#if ORM=none}}
|
|
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
|
+
});
|
|
88
291
|
}
|
|
292
|
+
// {{/if}}
|
|
293
|
+
return res.json({ success: true, revoked });
|
|
294
|
+
});
|
|
89
295
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
+
}
|
|
94
321
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
+
}
|
|
103
340
|
|
|
104
|
-
|
|
105
|
-
}
|
|
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
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/** Express — staff-only, tenant-scoped cross-border transfer register for {{ORG_NAME_COMMENT}}. */
|
|
2
|
+
import { Router } from 'express';
|
|
3
|
+
import { getNDPRContextProblem, resolveNDPRRequestContext } from '{{NDPR_CONTEXT_IMPORT}}';
|
|
4
|
+
// {{#if ORM=prisma}}
|
|
5
|
+
import { PrismaClient } from '@prisma/client';
|
|
6
|
+
const prisma = new PrismaClient();
|
|
7
|
+
// {{/if}}
|
|
8
|
+
// {{#if ORM=drizzle}}
|
|
9
|
+
import { db } from '{{NDPR_DB_IMPORT}}';
|
|
10
|
+
import { complianceAuditLog, crossBorderTransferRecords } from '{{NDPR_SCHEMA_IMPORT}}';
|
|
11
|
+
import { and, desc, eq, isNull, type SQL } from 'drizzle-orm';
|
|
12
|
+
// {{/if}}
|
|
13
|
+
|
|
14
|
+
const TRANSFER_MECHANISMS = ['adequacy_decision', 'standard_clauses', 'binding_corporate_rules', 'ndpc_authorization', 'explicit_consent', 'contract_performance', 'public_interest', 'legal_claims', 'vital_interests'] as const;
|
|
15
|
+
const ADEQUACY_STATUSES = ['adequate', 'inadequate', 'pending_review', 'unknown'] as const;
|
|
16
|
+
const RISK_LEVELS = ['low', 'medium', 'high'] as const;
|
|
17
|
+
const TRANSFER_STATUSES = ['active', 'suspended', 'terminated', 'pending_approval'] as const;
|
|
18
|
+
type TransferMechanism = (typeof TRANSFER_MECHANISMS)[number];
|
|
19
|
+
type AdequacyStatus = (typeof ADEQUACY_STATUSES)[number];
|
|
20
|
+
type RiskLevel = (typeof RISK_LEVELS)[number];
|
|
21
|
+
type TransferStatus = (typeof TRANSFER_STATUSES)[number];
|
|
22
|
+
type Validation<T> = { ok: true; value: T } | { ok: false; error: string };
|
|
23
|
+
interface TransferExisting { destinationCountry: string; recipientName: string; transferMechanism: string; safeguards: string; dataCategories: unknown; adequacyStatus: string; ndpcApprovalReference: string | null; status: string }
|
|
24
|
+
interface TransferMutation { destinationCountry: string; recipientName: string; transferMechanism: TransferMechanism; safeguards: string; dataCategories: string[]; adequacyStatus: AdequacyStatus; ndpcApprovalRequired: boolean; ndpcApprovalReference: string | null; riskLevel: RiskLevel; status: TransferStatus; updatedAt: Date }
|
|
25
|
+
|
|
26
|
+
// {{#if ORM=none}}
|
|
27
|
+
// DEVELOPMENT ONLY: replace both stores with one durable transactional store.
|
|
28
|
+
interface TransferRow extends TransferMutation { id: string; tenantId: string; createdAt: Date; removedAt: Date | null }
|
|
29
|
+
const transferStore = new Map<string, TransferRow>();
|
|
30
|
+
const auditLog: Array<{ id: string; tenantId: string; action: string; entityId: string; performedBy: string; changes?: unknown; at: Date }> = [];
|
|
31
|
+
// {{/if}}
|
|
32
|
+
|
|
33
|
+
function isRecord(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
|
34
|
+
function hasOwn(value: Record<string, unknown>, key: string): boolean { return Object.prototype.hasOwnProperty.call(value, key); }
|
|
35
|
+
function rejectUnknownFields(value: Record<string, unknown>, allowed: readonly string[]): string | null { const allowedSet = new Set(allowed); const unknown = Object.keys(value).filter((key) => !allowedSet.has(key)); return unknown.length > 0 ? `Unsupported field(s): ${unknown.join(', ')}` : null; }
|
|
36
|
+
function nonEmptyText(value: unknown, field: string, maxLength = 10_000): Validation<string> { if (typeof value !== 'string' || !value.trim() || value.trim().length > maxLength) return { ok: false, error: `${field} must be non-empty text of at most ${maxLength} characters` }; return { ok: true, value: value.trim() }; }
|
|
37
|
+
function nonEmptyStringArray(value: unknown, field: string): Validation<string[]> {
|
|
38
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 100) return { ok: false, error: `${field} must be a non-empty array with at most 100 items` };
|
|
39
|
+
const result: string[] = []; for (const item of value) { if (typeof item !== 'string' || !item.trim()) return { ok: false, error: `${field} must contain only non-empty strings` }; if (!result.includes(item.trim())) result.push(item.trim()); } return { ok: true, value: result };
|
|
40
|
+
}
|
|
41
|
+
function isMechanism(value: unknown): value is TransferMechanism { return typeof value === 'string' && (TRANSFER_MECHANISMS as readonly string[]).includes(value); }
|
|
42
|
+
function isAdequacyStatus(value: unknown): value is AdequacyStatus { return typeof value === 'string' && (ADEQUACY_STATUSES as readonly string[]).includes(value); }
|
|
43
|
+
function isRiskLevel(value: unknown): value is RiskLevel { return typeof value === 'string' && (RISK_LEVELS as readonly string[]).includes(value); }
|
|
44
|
+
function isTransferStatus(value: unknown): value is TransferStatus { return typeof value === 'string' && (TRANSFER_STATUSES as readonly string[]).includes(value); }
|
|
45
|
+
function approvalRequired(mechanism: TransferMechanism): boolean { return mechanism === 'binding_corporate_rules' || mechanism === 'ndpc_authorization'; }
|
|
46
|
+
function deriveRiskLevel(adequacy: AdequacyStatus, mechanism: TransferMechanism, required: boolean, reference: string | null): RiskLevel {
|
|
47
|
+
if ((required && !reference) || adequacy === 'inadequate') return 'high';
|
|
48
|
+
const derogation = ['explicit_consent', 'contract_performance', 'public_interest', 'legal_claims', 'vital_interests'].includes(mechanism);
|
|
49
|
+
if (adequacy === 'unknown' || adequacy === 'pending_review' || derogation) return 'medium'; return 'low';
|
|
50
|
+
}
|
|
51
|
+
const STATUS_TRANSITIONS: Record<TransferStatus, readonly TransferStatus[]> = { active: ['active', 'suspended', 'terminated', 'pending_approval'], suspended: ['suspended', 'active', 'terminated', 'pending_approval'], pending_approval: ['pending_approval', 'active', 'suspended', 'terminated'], terminated: ['terminated'] };
|
|
52
|
+
function buildTransferMutation(input: Record<string, unknown>, existing?: TransferExisting, now = new Date()): Validation<TransferMutation> {
|
|
53
|
+
const destination = hasOwn(input, 'destinationCountry') ? nonEmptyText(input.destinationCountry, 'destinationCountry', 200) : existing ? nonEmptyText(existing.destinationCountry, 'stored destinationCountry', 200) : { ok: false as const, error: 'destinationCountry is required' }; if (!destination.ok) return destination;
|
|
54
|
+
const recipient = hasOwn(input, 'recipientName') ? nonEmptyText(input.recipientName, 'recipientName', 500) : existing ? nonEmptyText(existing.recipientName, 'stored recipientName', 500) : { ok: false as const, error: 'recipientName is required' }; if (!recipient.ok) return recipient;
|
|
55
|
+
const mechanismValue = hasOwn(input, 'transferMechanism') ? input.transferMechanism : existing?.transferMechanism; if (!isMechanism(mechanismValue)) return { ok: false, error: 'transferMechanism is not supported by the NDPA transfer register' };
|
|
56
|
+
const safeguards = hasOwn(input, 'safeguards') ? nonEmptyText(input.safeguards, 'safeguards') : existing ? nonEmptyText(existing.safeguards, 'stored safeguards') : { ok: false as const, error: 'safeguards is required' }; if (!safeguards.ok) return safeguards;
|
|
57
|
+
const categories = nonEmptyStringArray(hasOwn(input, 'dataCategories') ? input.dataCategories : existing?.dataCategories, 'dataCategories'); if (!categories.ok) return categories;
|
|
58
|
+
const adequacyValue = hasOwn(input, 'adequacyStatus') ? input.adequacyStatus : existing?.adequacyStatus; if (!isAdequacyStatus(adequacyValue)) return { ok: false, error: 'adequacyStatus is not supported' };
|
|
59
|
+
if (mechanismValue === 'adequacy_decision' && adequacyValue !== 'adequate') return { ok: false, error: 'adequacy_decision can only be used for a destination marked adequate' };
|
|
60
|
+
let approvalReference: string | null; const referenceValue = hasOwn(input, 'ndpcApprovalReference') ? input.ndpcApprovalReference : existing?.ndpcApprovalReference ?? null;
|
|
61
|
+
if (referenceValue === null || referenceValue === '') approvalReference = null; else { const reference = nonEmptyText(referenceValue, 'ndpcApprovalReference', 500); if (!reference.ok) return reference; approvalReference = reference.value; }
|
|
62
|
+
const required = approvalRequired(mechanismValue); if (!required && approvalReference) return { ok: false, error: 'ndpcApprovalReference is only accepted for a mechanism that requires NDPC approval' };
|
|
63
|
+
const explicitStatus = hasOwn(input, 'status'); if (explicitStatus && !isTransferStatus(input.status)) return { ok: false, error: 'status is not supported' };
|
|
64
|
+
let status: TransferStatus;
|
|
65
|
+
if (isTransferStatus(input.status)) status = input.status; else if (required && !approvalReference) status = 'pending_approval'; else if (existing && isTransferStatus(existing.status) && existing.status !== 'pending_approval') status = existing.status; else status = 'active';
|
|
66
|
+
if (required && !approvalReference && status === 'active') return { ok: false, error: 'An approval-dependent transfer cannot be active without an NDPC approval reference' };
|
|
67
|
+
if (!required && status === 'pending_approval') return { ok: false, error: 'pending_approval is only valid when the selected mechanism requires NDPC approval' };
|
|
68
|
+
if (approvalReference && status === 'pending_approval') return { ok: false, error: 'A transfer with an NDPC approval reference cannot remain pending_approval' };
|
|
69
|
+
if (existing) { if (!isTransferStatus(existing.status)) return { ok: false, error: 'Stored transfer status is invalid' }; if (!STATUS_TRANSITIONS[existing.status].includes(status)) return { ok: false, error: `Transfer status cannot transition from ${existing.status} to ${status}` }; if (existing.status === 'terminated' && ['destinationCountry', 'recipientName', 'transferMechanism', 'safeguards', 'dataCategories', 'adequacyStatus', 'ndpcApprovalReference'].some((field) => hasOwn(input, field))) return { ok: false, error: 'A terminated transfer cannot have its compliance evidence changed' }; }
|
|
70
|
+
return { ok: true, value: { destinationCountry: destination.value, recipientName: recipient.value, transferMechanism: mechanismValue, safeguards: safeguards.value, dataCategories: categories.value, adequacyStatus: adequacyValue, ndpcApprovalRequired: required, ndpcApprovalReference: approvalReference, riskLevel: deriveRiskLevel(adequacyValue, mechanismValue, required, approvalReference), status, updatedAt: now } };
|
|
71
|
+
}
|
|
72
|
+
function auditChanges(data: TransferMutation) { return { destinationCountry: data.destinationCountry, recipientName: data.recipientName, transferMechanism: data.transferMechanism, adequacyStatus: data.adequacyStatus, ndpcApprovalRequired: data.ndpcApprovalRequired, riskLevel: data.riskLevel, status: data.status }; }
|
|
73
|
+
|
|
74
|
+
export const crossBorderRouter = Router();
|
|
75
|
+
|
|
76
|
+
crossBorderRouter.get('/', async (req, res) => {
|
|
77
|
+
const context = await resolveNDPRRequestContext(req); const problem = getNDPRContextProblem(context, 'staff'); if (problem) return res.status(problem.status).json({ error: problem.error });
|
|
78
|
+
const riskLevel = typeof req.query.riskLevel === 'string' ? req.query.riskLevel : undefined; const status = typeof req.query.status === 'string' ? req.query.status : undefined; const destinationCountry = typeof req.query.destinationCountry === 'string' ? req.query.destinationCountry.trim() : undefined;
|
|
79
|
+
if (riskLevel !== undefined && !isRiskLevel(riskLevel)) return res.status(400).json({ error: 'riskLevel filter is not supported' }); if (status !== undefined && !isTransferStatus(status)) return res.status(400).json({ error: 'status filter is not supported' }); if (typeof req.query.destinationCountry === 'string' && !destinationCountry) return res.status(400).json({ error: 'destinationCountry filter cannot be empty' });
|
|
80
|
+
// {{#if ORM=prisma}}
|
|
81
|
+
const records = await prisma.crossBorderTransferRecord.findMany({ where: { tenantId: context.tenantId, removedAt: null, ...(riskLevel ? { riskLevel } : {}), ...(status ? { status } : {}), ...(destinationCountry ? { destinationCountry } : {}) }, orderBy: { createdAt: 'desc' } });
|
|
82
|
+
// {{/if}}
|
|
83
|
+
// {{#if ORM=drizzle}}
|
|
84
|
+
const filters: SQL[] = [eq(crossBorderTransferRecords.tenantId, context.tenantId), isNull(crossBorderTransferRecords.removedAt)]; if (riskLevel) filters.push(eq(crossBorderTransferRecords.riskLevel, riskLevel)); if (status) filters.push(eq(crossBorderTransferRecords.status, status)); if (destinationCountry) filters.push(eq(crossBorderTransferRecords.destinationCountry, destinationCountry)); const records = await db.select().from(crossBorderTransferRecords).where(and(...filters)).orderBy(desc(crossBorderTransferRecords.createdAt));
|
|
85
|
+
// {{/if}}
|
|
86
|
+
// {{#if ORM=none}}
|
|
87
|
+
const records = [...transferStore.values()].filter((row) => row.tenantId === context.tenantId && row.removedAt === null && (!riskLevel || row.riskLevel === riskLevel) && (!status || row.status === status) && (!destinationCountry || row.destinationCountry === destinationCountry)).sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
88
|
+
// {{/if}}
|
|
89
|
+
return res.json(records);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
crossBorderRouter.get('/:id', async (req, res) => {
|
|
93
|
+
const context = await resolveNDPRRequestContext(req); const problem = getNDPRContextProblem(context, 'staff'); if (problem) return res.status(problem.status).json({ error: problem.error });
|
|
94
|
+
// {{#if ORM=prisma}}
|
|
95
|
+
const record = await prisma.crossBorderTransferRecord.findFirst({ where: { id: req.params.id, tenantId: context.tenantId, removedAt: null } });
|
|
96
|
+
// {{/if}}
|
|
97
|
+
// {{#if ORM=drizzle}}
|
|
98
|
+
const [record] = await db.select().from(crossBorderTransferRecords).where(and(eq(crossBorderTransferRecords.id, req.params.id), eq(crossBorderTransferRecords.tenantId, context.tenantId), isNull(crossBorderTransferRecords.removedAt))).limit(1);
|
|
99
|
+
// {{/if}}
|
|
100
|
+
// {{#if ORM=none}}
|
|
101
|
+
const candidate = transferStore.get(req.params.id); const record = candidate?.tenantId === context.tenantId && candidate.removedAt === null ? candidate : null;
|
|
102
|
+
// {{/if}}
|
|
103
|
+
return record ? res.json(record) : res.status(404).json({ error: 'Transfer record not found' });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
crossBorderRouter.post('/', async (req, res) => {
|
|
107
|
+
const context = await resolveNDPRRequestContext(req); const problem = getNDPRContextProblem(context, 'staff'); if (problem) return res.status(problem.status).json({ error: problem.error }); const actorId = context.actorId as string;
|
|
108
|
+
if (!isRecord(req.body)) return res.status(400).json({ error: 'A JSON object is required' });
|
|
109
|
+
const unknown = rejectUnknownFields(req.body, ['destinationCountry', 'recipientName', 'transferMechanism', 'safeguards', 'dataCategories', 'adequacyStatus', 'ndpcApprovalReference', 'status']); if (unknown) return res.status(400).json({ error: unknown });
|
|
110
|
+
const normalized = buildTransferMutation(req.body); if (!normalized.ok) return res.status(400).json({ error: normalized.error }); const data = normalized.value;
|
|
111
|
+
// {{#if ORM=prisma}}
|
|
112
|
+
const record = await prisma.$transaction(async (tx) => { const created = await tx.crossBorderTransferRecord.create({ data: { ...data, tenantId: context.tenantId, removedAt: null } }); await tx.complianceAuditLog.create({ data: { tenantId: context.tenantId, module: 'cross-border', action: 'created', entityId: created.id, entityType: 'CrossBorderTransferRecord', performedBy: actorId, changes: auditChanges(data) } }); return created; });
|
|
113
|
+
// {{/if}}
|
|
114
|
+
// {{#if ORM=drizzle}}
|
|
115
|
+
const record = await db.transaction(async (tx) => { const [created] = await tx.insert(crossBorderTransferRecords).values({ ...data, tenantId: context.tenantId, removedAt: null }).returning(); await tx.insert(complianceAuditLog).values({ tenantId: context.tenantId, module: 'cross-border', action: 'created', entityId: created.id, entityType: 'CrossBorderTransferRecord', performedBy: actorId, changes: auditChanges(data) }); return created; });
|
|
116
|
+
// {{/if}}
|
|
117
|
+
// {{#if ORM=none}}
|
|
118
|
+
const record: TransferRow = { id: crypto.randomUUID(), ...data, tenantId: context.tenantId, createdAt: data.updatedAt, removedAt: null }; transferStore.set(record.id, record); auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'created', entityId: record.id, performedBy: actorId, changes: auditChanges(data), at: data.updatedAt });
|
|
119
|
+
// {{/if}}
|
|
120
|
+
return res.status(201).json(record);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
crossBorderRouter.put('/:id', async (req, res) => {
|
|
124
|
+
const context = await resolveNDPRRequestContext(req); const problem = getNDPRContextProblem(context, 'staff'); if (problem) return res.status(problem.status).json({ error: problem.error }); const actorId = context.actorId as string;
|
|
125
|
+
if (!isRecord(req.body)) return res.status(400).json({ error: 'A JSON object is required' });
|
|
126
|
+
const fields = ['destinationCountry', 'recipientName', 'transferMechanism', 'safeguards', 'dataCategories', 'adequacyStatus', 'ndpcApprovalReference', 'status'] as const; const unknown = rejectUnknownFields(req.body, fields); if (unknown) return res.status(400).json({ error: unknown }); if (!fields.some((field) => hasOwn(req.body, field))) return res.status(400).json({ error: 'Provide at least one allowlisted transfer field to update' });
|
|
127
|
+
const id = req.params.id; let validationError: string | null = null;
|
|
128
|
+
// {{#if ORM=prisma}}
|
|
129
|
+
const record = await prisma.$transaction(async (tx) => { const existing = await tx.crossBorderTransferRecord.findFirst({ where: { id, tenantId: context.tenantId, removedAt: null } }); if (!existing) return null; const normalized = buildTransferMutation(req.body, existing); if (!normalized.ok) { validationError = normalized.error; return null; } const data = normalized.value; await tx.crossBorderTransferRecord.updateMany({ where: { id, tenantId: context.tenantId, removedAt: null }, data }); const updated = await tx.crossBorderTransferRecord.findFirstOrThrow({ where: { id, tenantId: context.tenantId, removedAt: null } }); await tx.complianceAuditLog.create({ data: { tenantId: context.tenantId, module: 'cross-border', action: 'updated', entityId: id, entityType: 'CrossBorderTransferRecord', performedBy: actorId, changes: auditChanges(data) } }); return updated; });
|
|
130
|
+
// {{/if}}
|
|
131
|
+
// {{#if ORM=drizzle}}
|
|
132
|
+
const record = await db.transaction(async (tx) => { const [existing] = await tx.select().from(crossBorderTransferRecords).where(and(eq(crossBorderTransferRecords.id, id), eq(crossBorderTransferRecords.tenantId, context.tenantId), isNull(crossBorderTransferRecords.removedAt))).limit(1); if (!existing) return null; const normalized = buildTransferMutation(req.body, existing); if (!normalized.ok) { validationError = normalized.error; return null; } const data = normalized.value; const [updated] = await tx.update(crossBorderTransferRecords).set(data).where(and(eq(crossBorderTransferRecords.id, id), eq(crossBorderTransferRecords.tenantId, context.tenantId), isNull(crossBorderTransferRecords.removedAt))).returning(); await tx.insert(complianceAuditLog).values({ tenantId: context.tenantId, module: 'cross-border', action: 'updated', entityId: id, entityType: 'CrossBorderTransferRecord', performedBy: actorId, changes: auditChanges(data) }); return updated; });
|
|
133
|
+
// {{/if}}
|
|
134
|
+
// {{#if ORM=none}}
|
|
135
|
+
const existing = transferStore.get(id); let record: TransferRow | null = null; if (existing?.tenantId === context.tenantId && existing.removedAt === null) { const normalized = buildTransferMutation(req.body, existing); if (!normalized.ok) validationError = normalized.error; else { record = { ...existing, ...normalized.value, id }; transferStore.set(id, record); auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'updated', entityId: id, performedBy: actorId, changes: auditChanges(normalized.value), at: normalized.value.updatedAt }); } }
|
|
136
|
+
// {{/if}}
|
|
137
|
+
if (validationError) return res.status(400).json({ error: validationError }); return record ? res.json(record) : res.status(404).json({ error: 'Transfer record not found' });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
crossBorderRouter.delete('/:id', async (req, res) => {
|
|
141
|
+
const context = await resolveNDPRRequestContext(req); const problem = getNDPRContextProblem(context, 'staff'); if (problem) return res.status(problem.status).json({ error: problem.error }); const actorId = context.actorId as string; const id = req.params.id; const archivedAt = new Date();
|
|
142
|
+
// {{#if ORM=prisma}}
|
|
143
|
+
const archived = await prisma.$transaction(async (tx) => { const existing = await tx.crossBorderTransferRecord.findFirst({ where: { id, tenantId: context.tenantId, removedAt: null } }); if (!existing) return false; const result = await tx.crossBorderTransferRecord.updateMany({ where: { id, tenantId: context.tenantId, removedAt: null }, data: { removedAt: archivedAt, updatedAt: archivedAt } }); if (result.count !== 1) return false; await tx.complianceAuditLog.create({ data: { tenantId: context.tenantId, module: 'cross-border', action: 'archived', entityId: id, entityType: 'CrossBorderTransferRecord', performedBy: actorId, changes: { destinationCountry: existing.destinationCountry, recipientName: existing.recipientName, removedAt: archivedAt.toISOString() } } }); return true; });
|
|
144
|
+
// {{/if}}
|
|
145
|
+
// {{#if ORM=drizzle}}
|
|
146
|
+
const archived = await db.transaction(async (tx) => { const [existing] = await tx.select().from(crossBorderTransferRecords).where(and(eq(crossBorderTransferRecords.id, id), eq(crossBorderTransferRecords.tenantId, context.tenantId), isNull(crossBorderTransferRecords.removedAt))).limit(1); if (!existing) return false; const [updated] = await tx.update(crossBorderTransferRecords).set({ removedAt: archivedAt, updatedAt: archivedAt }).where(and(eq(crossBorderTransferRecords.id, id), eq(crossBorderTransferRecords.tenantId, context.tenantId), isNull(crossBorderTransferRecords.removedAt))).returning(); if (!updated) return false; await tx.insert(complianceAuditLog).values({ tenantId: context.tenantId, module: 'cross-border', action: 'archived', entityId: id, entityType: 'CrossBorderTransferRecord', performedBy: actorId, changes: { destinationCountry: existing.destinationCountry, recipientName: existing.recipientName, removedAt: archivedAt.toISOString() } }); return true; });
|
|
147
|
+
// {{/if}}
|
|
148
|
+
// {{#if ORM=none}}
|
|
149
|
+
const existing = transferStore.get(id); const archived = existing?.tenantId === context.tenantId && existing.removedAt === null; if (archived && existing) { transferStore.set(id, { ...existing, removedAt: archivedAt, updatedAt: archivedAt }); auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'archived', entityId: id, performedBy: actorId, changes: { destinationCountry: existing.destinationCountry, recipientName: existing.recipientName, removedAt: archivedAt.toISOString() }, at: archivedAt }); }
|
|
150
|
+
// {{/if}}
|
|
151
|
+
return archived ? res.json({ success: true, archived: true }) : res.status(404).json({ error: 'Transfer record not found' });
|
|
152
|
+
});
|