@tantainnovative/create-ndpr 0.1.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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 { PrismaClient } from '@prisma/client';
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 { subjectId } = req.query;
27
-
28
- if (!subjectId || typeof subjectId !== 'string') {
29
- return res.status(400).json({ error: 'subjectId required' });
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
- return res.json(record);
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 { subjectId, consents, version, method, lawfulBasis } = req.body;
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
- if (!subjectId || !consents || !version) {
45
- return res.status(400).json({ error: 'subjectId, consents, and version are required' });
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
- // Revoke any previously active records (immutable-audit pattern — NDPA §44).
49
- await prisma.consentRecord.updateMany({
50
- where: { subjectId, revokedAt: null },
51
- data: { revokedAt: new Date() },
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
- const record = await prisma.consentRecord.create({
55
- data: {
56
- subjectId,
57
- consents,
58
- version,
59
- method: method ?? 'api',
60
- lawfulBasis: lawfulBasis ?? null,
61
- ipAddress:
62
- (req.headers['x-forwarded-for'] as string)?.split(',')[0].trim() ??
63
- req.socket.remoteAddress ??
64
- null,
65
- userAgent: req.headers['user-agent'] ?? null,
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
- await prisma.complianceAuditLog.create({
70
- data: {
71
- module: 'consent',
72
- action: 'created',
73
- entityId: record.id,
74
- entityType: 'ConsentRecord',
75
- changes: { subjectId, version, consents },
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
- return res.status(201).json(record);
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 { subjectId } = req.query;
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 (!subjectId || typeof subjectId !== 'string') {
87
- return res.status(400).json({ error: 'subjectId required' });
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
- await prisma.consentRecord.updateMany({
91
- where: { subjectId, revokedAt: null },
92
- data: { revokedAt: new Date() },
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
- await prisma.complianceAuditLog.create({
96
- data: {
97
- module: 'consent',
98
- action: 'revoked',
99
- entityId: subjectId,
100
- entityType: 'ConsentRecord',
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
- return res.json({ success: true });
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
+ });