@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,317 +1,152 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Express — Cross-Border Transfer Router
|
|
3
|
-
* Generated by create-ndpr for: {{ORG_NAME}}
|
|
4
|
-
* DPO: {{DPO_EMAIL}}
|
|
5
|
-
*
|
|
6
|
-
* NDPA §41–43 — transfers of personal data outside Nigeria require
|
|
7
|
-
* adequate safeguards and, in some cases, NDPC approval.
|
|
8
|
-
*
|
|
9
|
-
* Routes:
|
|
10
|
-
* GET /cross-border — List transfers (optional ?riskLevel= or ?destinationCountry= filter)
|
|
11
|
-
* GET /cross-border/:id — Get a single transfer record
|
|
12
|
-
* POST /cross-border — Register a new cross-border transfer
|
|
13
|
-
* PUT /cross-border/:id — Update an existing transfer record
|
|
14
|
-
* DELETE /cross-border/:id — Delete a transfer record
|
|
15
|
-
*
|
|
16
|
-
* Usage:
|
|
17
|
-
* import { crossBorderRouter } from './routes/cross-border';
|
|
18
|
-
* app.use('/api/cross-border', crossBorderRouter);
|
|
19
|
-
*/
|
|
20
|
-
|
|
1
|
+
/** Express — staff-only, tenant-scoped cross-border transfer register for {{ORG_NAME_COMMENT}}. */
|
|
21
2
|
import { Router } from 'express';
|
|
3
|
+
import { getNDPRContextProblem, resolveNDPRRequestContext } from '{{NDPR_CONTEXT_IMPORT}}';
|
|
22
4
|
// {{#if ORM=prisma}}
|
|
23
5
|
import { PrismaClient } from '@prisma/client';
|
|
24
|
-
|
|
25
6
|
const prisma = new PrismaClient();
|
|
26
7
|
// {{/if}}
|
|
27
8
|
// {{#if ORM=drizzle}}
|
|
28
|
-
import { db } from '
|
|
29
|
-
import {
|
|
30
|
-
import {
|
|
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';
|
|
31
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
|
+
|
|
32
26
|
// {{#if ORM=none}}
|
|
33
|
-
//
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
// with your DB / KV / API.
|
|
38
|
-
interface CrossBorderTransferRecord {
|
|
39
|
-
id: string;
|
|
40
|
-
destinationCountry: string;
|
|
41
|
-
recipientName: string;
|
|
42
|
-
transferMechanism: string;
|
|
43
|
-
safeguards: string;
|
|
44
|
-
dataCategories: string[];
|
|
45
|
-
adequacyStatus: string;
|
|
46
|
-
ndpcApprovalRequired: boolean;
|
|
47
|
-
ndpcApprovalReference: string | null;
|
|
48
|
-
riskLevel: string;
|
|
49
|
-
createdAt: Date;
|
|
50
|
-
updatedAt: Date;
|
|
51
|
-
}
|
|
52
|
-
const crossBorderStore = new Map<string, CrossBorderTransferRecord>();
|
|
53
|
-
const auditLog: Array<{ id: string; module: string; action: string; entityId: string; at: Date }> = [];
|
|
54
|
-
function newId() { return crypto.randomUUID(); }
|
|
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 }> = [];
|
|
55
31
|
// {{/if}}
|
|
56
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
|
+
|
|
57
74
|
export const crossBorderRouter = Router();
|
|
58
75
|
|
|
59
|
-
// GET /cross-border?riskLevel=high&destinationCountry=US
|
|
60
76
|
crossBorderRouter.get('/', async (req, res) => {
|
|
61
|
-
const
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
|
|
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' });
|
|
65
80
|
// {{#if ORM=prisma}}
|
|
66
|
-
const where:
|
|
67
|
-
if (riskLevelFilter) where.riskLevel = riskLevelFilter;
|
|
68
|
-
if (destinationCountryFilter) where.destinationCountry = destinationCountryFilter;
|
|
69
|
-
|
|
70
|
-
const records = await prisma.crossBorderTransferRecord.findMany({
|
|
71
|
-
where: Object.keys(where).length > 0 ? where : undefined,
|
|
72
|
-
orderBy: { createdAt: 'desc' },
|
|
73
|
-
});
|
|
81
|
+
const records = await prisma.crossBorderTransferRecord.findMany({ where: { tenantId: context.tenantId, removedAt: null, ...(riskLevel ? { riskLevel } : {}), ...(status ? { status } : {}), ...(destinationCountry ? { destinationCountry } : {}) }, orderBy: { createdAt: 'desc' } });
|
|
74
82
|
// {{/if}}
|
|
75
83
|
// {{#if ORM=drizzle}}
|
|
76
|
-
const filters: SQL[] = [];
|
|
77
|
-
if (riskLevelFilter) filters.push(eq(crossBorderTransferRecords.riskLevel, riskLevelFilter));
|
|
78
|
-
if (destinationCountryFilter) filters.push(eq(crossBorderTransferRecords.destinationCountry, destinationCountryFilter));
|
|
79
|
-
|
|
80
|
-
const records = filters.length > 0
|
|
81
|
-
? await db.select().from(crossBorderTransferRecords).where(and(...filters)).orderBy(desc(crossBorderTransferRecords.createdAt))
|
|
82
|
-
: await db.select().from(crossBorderTransferRecords).orderBy(desc(crossBorderTransferRecords.createdAt));
|
|
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));
|
|
83
85
|
// {{/if}}
|
|
84
86
|
// {{#if ORM=none}}
|
|
85
|
-
const records = [...
|
|
86
|
-
.filter((r) => (riskLevelFilter ? r.riskLevel === riskLevelFilter : true))
|
|
87
|
-
.filter((r) => (destinationCountryFilter ? r.destinationCountry === destinationCountryFilter : true))
|
|
88
|
-
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
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());
|
|
89
88
|
// {{/if}}
|
|
90
|
-
|
|
91
89
|
return res.json(records);
|
|
92
90
|
});
|
|
93
91
|
|
|
94
|
-
// GET /cross-border/:id
|
|
95
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 });
|
|
96
94
|
// {{#if ORM=prisma}}
|
|
97
|
-
const record = await prisma.crossBorderTransferRecord.
|
|
95
|
+
const record = await prisma.crossBorderTransferRecord.findFirst({ where: { id: req.params.id, tenantId: context.tenantId, removedAt: null } });
|
|
98
96
|
// {{/if}}
|
|
99
97
|
// {{#if ORM=drizzle}}
|
|
100
|
-
const [record] = await db.select().from(crossBorderTransferRecords).where(eq(crossBorderTransferRecords.id, req.params.id)).limit(1);
|
|
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);
|
|
101
99
|
// {{/if}}
|
|
102
100
|
// {{#if ORM=none}}
|
|
103
|
-
const
|
|
101
|
+
const candidate = transferStore.get(req.params.id); const record = candidate?.tenantId === context.tenantId && candidate.removedAt === null ? candidate : null;
|
|
104
102
|
// {{/if}}
|
|
105
|
-
|
|
106
|
-
if (!record) {
|
|
107
|
-
return res.status(404).json({ error: 'Cross-border transfer record not found' });
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
return res.json(record);
|
|
103
|
+
return record ? res.json(record) : res.status(404).json({ error: 'Transfer record not found' });
|
|
111
104
|
});
|
|
112
105
|
|
|
113
|
-
// POST /cross-border
|
|
114
106
|
crossBorderRouter.post('/', async (req, res) => {
|
|
115
|
-
const {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
safeguards,
|
|
120
|
-
dataCategories,
|
|
121
|
-
adequacyStatus,
|
|
122
|
-
ndpcApprovalRequired,
|
|
123
|
-
ndpcApprovalReference,
|
|
124
|
-
riskLevel,
|
|
125
|
-
} = req.body;
|
|
126
|
-
|
|
127
|
-
if (
|
|
128
|
-
!destinationCountry ||
|
|
129
|
-
!recipientName ||
|
|
130
|
-
!transferMechanism ||
|
|
131
|
-
!safeguards ||
|
|
132
|
-
!dataCategories ||
|
|
133
|
-
!adequacyStatus ||
|
|
134
|
-
!riskLevel
|
|
135
|
-
) {
|
|
136
|
-
return res.status(400).json({
|
|
137
|
-
error:
|
|
138
|
-
'destinationCountry, recipientName, transferMechanism, safeguards, dataCategories, adequacyStatus, and riskLevel are required',
|
|
139
|
-
});
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
if (!Array.isArray(dataCategories)) {
|
|
143
|
-
return res.status(400).json({ error: 'dataCategories must be an array' });
|
|
144
|
-
}
|
|
145
|
-
|
|
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;
|
|
146
111
|
// {{#if ORM=prisma}}
|
|
147
|
-
const record = await prisma.crossBorderTransferRecord.create({
|
|
148
|
-
data: {
|
|
149
|
-
destinationCountry,
|
|
150
|
-
recipientName,
|
|
151
|
-
transferMechanism,
|
|
152
|
-
safeguards,
|
|
153
|
-
dataCategories,
|
|
154
|
-
adequacyStatus,
|
|
155
|
-
ndpcApprovalRequired: ndpcApprovalRequired ?? false,
|
|
156
|
-
ndpcApprovalReference: ndpcApprovalReference ?? null,
|
|
157
|
-
riskLevel,
|
|
158
|
-
},
|
|
159
|
-
});
|
|
160
|
-
|
|
161
|
-
await prisma.complianceAuditLog.create({
|
|
162
|
-
data: {
|
|
163
|
-
module: 'cross-border',
|
|
164
|
-
action: 'created',
|
|
165
|
-
entityId: record.id,
|
|
166
|
-
entityType: 'CrossBorderTransferRecord',
|
|
167
|
-
changes: { destinationCountry, recipientName, riskLevel },
|
|
168
|
-
},
|
|
169
|
-
});
|
|
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; });
|
|
170
113
|
// {{/if}}
|
|
171
114
|
// {{#if ORM=drizzle}}
|
|
172
|
-
const
|
|
173
|
-
destinationCountry,
|
|
174
|
-
recipientName,
|
|
175
|
-
transferMechanism,
|
|
176
|
-
safeguards,
|
|
177
|
-
dataCategories,
|
|
178
|
-
adequacyStatus,
|
|
179
|
-
ndpcApprovalRequired: ndpcApprovalRequired ?? false,
|
|
180
|
-
ndpcApprovalReference: ndpcApprovalReference ?? null,
|
|
181
|
-
riskLevel,
|
|
182
|
-
}).returning();
|
|
183
|
-
|
|
184
|
-
await db.insert(complianceAuditLog).values({
|
|
185
|
-
module: 'cross-border',
|
|
186
|
-
action: 'created',
|
|
187
|
-
entityId: record.id,
|
|
188
|
-
entityType: 'CrossBorderTransferRecord',
|
|
189
|
-
changes: { destinationCountry, recipientName, riskLevel },
|
|
190
|
-
});
|
|
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; });
|
|
191
116
|
// {{/if}}
|
|
192
117
|
// {{#if ORM=none}}
|
|
193
|
-
const
|
|
194
|
-
const record: CrossBorderTransferRecord = {
|
|
195
|
-
id: newId(),
|
|
196
|
-
destinationCountry,
|
|
197
|
-
recipientName,
|
|
198
|
-
transferMechanism,
|
|
199
|
-
safeguards,
|
|
200
|
-
dataCategories,
|
|
201
|
-
adequacyStatus,
|
|
202
|
-
ndpcApprovalRequired: ndpcApprovalRequired ?? false,
|
|
203
|
-
ndpcApprovalReference: ndpcApprovalReference ?? null,
|
|
204
|
-
riskLevel,
|
|
205
|
-
createdAt: now,
|
|
206
|
-
updatedAt: now,
|
|
207
|
-
};
|
|
208
|
-
crossBorderStore.set(record.id, record);
|
|
209
|
-
auditLog.push({ id: newId(), module: 'cross-border', action: 'created', entityId: record.id, at: new Date() });
|
|
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 });
|
|
210
119
|
// {{/if}}
|
|
211
|
-
|
|
212
120
|
return res.status(201).json(record);
|
|
213
121
|
});
|
|
214
122
|
|
|
215
|
-
// PUT /cross-border/:id
|
|
216
123
|
crossBorderRouter.put('/:id', async (req, res) => {
|
|
217
|
-
const {
|
|
218
|
-
|
|
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;
|
|
219
128
|
// {{#if ORM=prisma}}
|
|
220
|
-
const existing = await
|
|
221
|
-
if (!existing) {
|
|
222
|
-
return res.status(404).json({ error: 'Cross-border transfer record not found' });
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
const record = await prisma.crossBorderTransferRecord.update({
|
|
226
|
-
where: { id },
|
|
227
|
-
data: req.body,
|
|
228
|
-
});
|
|
229
|
-
|
|
230
|
-
await prisma.complianceAuditLog.create({
|
|
231
|
-
data: {
|
|
232
|
-
module: 'cross-border',
|
|
233
|
-
action: 'updated',
|
|
234
|
-
entityId: record.id,
|
|
235
|
-
entityType: 'CrossBorderTransferRecord',
|
|
236
|
-
changes: req.body,
|
|
237
|
-
},
|
|
238
|
-
});
|
|
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; });
|
|
239
130
|
// {{/if}}
|
|
240
131
|
// {{#if ORM=drizzle}}
|
|
241
|
-
const [existing] = await
|
|
242
|
-
if (!existing) {
|
|
243
|
-
return res.status(404).json({ error: 'Cross-border transfer record not found' });
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
const [record] = await db.update(crossBorderTransferRecords).set(req.body).where(eq(crossBorderTransferRecords.id, id)).returning();
|
|
247
|
-
|
|
248
|
-
await db.insert(complianceAuditLog).values({
|
|
249
|
-
module: 'cross-border',
|
|
250
|
-
action: 'updated',
|
|
251
|
-
entityId: record.id,
|
|
252
|
-
entityType: 'CrossBorderTransferRecord',
|
|
253
|
-
changes: req.body,
|
|
254
|
-
});
|
|
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; });
|
|
255
133
|
// {{/if}}
|
|
256
134
|
// {{#if ORM=none}}
|
|
257
|
-
const existing =
|
|
258
|
-
if (!existing) {
|
|
259
|
-
return res.status(404).json({ error: 'Cross-border transfer record not found' });
|
|
260
|
-
}
|
|
261
|
-
const record: CrossBorderTransferRecord = { ...existing, ...req.body, id, updatedAt: new Date() };
|
|
262
|
-
crossBorderStore.set(id, record);
|
|
263
|
-
auditLog.push({ id: newId(), module: 'cross-border', action: 'updated', entityId: record.id, at: new Date() });
|
|
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 }); } }
|
|
264
136
|
// {{/if}}
|
|
265
|
-
|
|
266
|
-
return res.json(record);
|
|
137
|
+
if (validationError) return res.status(400).json({ error: validationError }); return record ? res.json(record) : res.status(404).json({ error: 'Transfer record not found' });
|
|
267
138
|
});
|
|
268
139
|
|
|
269
|
-
// DELETE /cross-border/:id
|
|
270
140
|
crossBorderRouter.delete('/:id', async (req, res) => {
|
|
271
|
-
const {
|
|
272
|
-
|
|
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();
|
|
273
142
|
// {{#if ORM=prisma}}
|
|
274
|
-
const existing = await
|
|
275
|
-
if (!existing) {
|
|
276
|
-
return res.status(404).json({ error: 'Cross-border transfer record not found' });
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
await prisma.crossBorderTransferRecord.delete({ where: { id } });
|
|
280
|
-
|
|
281
|
-
await prisma.complianceAuditLog.create({
|
|
282
|
-
data: {
|
|
283
|
-
module: 'cross-border',
|
|
284
|
-
action: 'deleted',
|
|
285
|
-
entityId: id,
|
|
286
|
-
entityType: 'CrossBorderTransferRecord',
|
|
287
|
-
changes: { destinationCountry: existing.destinationCountry, recipientName: existing.recipientName },
|
|
288
|
-
},
|
|
289
|
-
});
|
|
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; });
|
|
290
144
|
// {{/if}}
|
|
291
145
|
// {{#if ORM=drizzle}}
|
|
292
|
-
const [existing] = await
|
|
293
|
-
if (!existing) {
|
|
294
|
-
return res.status(404).json({ error: 'Cross-border transfer record not found' });
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
await db.delete(crossBorderTransferRecords).where(eq(crossBorderTransferRecords.id, id));
|
|
298
|
-
|
|
299
|
-
await db.insert(complianceAuditLog).values({
|
|
300
|
-
module: 'cross-border',
|
|
301
|
-
action: 'deleted',
|
|
302
|
-
entityId: id,
|
|
303
|
-
entityType: 'CrossBorderTransferRecord',
|
|
304
|
-
changes: { destinationCountry: existing.destinationCountry, recipientName: existing.recipientName },
|
|
305
|
-
});
|
|
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; });
|
|
306
147
|
// {{/if}}
|
|
307
148
|
// {{#if ORM=none}}
|
|
308
|
-
const existing =
|
|
309
|
-
if (!existing) {
|
|
310
|
-
return res.status(404).json({ error: 'Cross-border transfer record not found' });
|
|
311
|
-
}
|
|
312
|
-
crossBorderStore.delete(id);
|
|
313
|
-
auditLog.push({ id: newId(), module: 'cross-border', action: 'deleted', entityId: id, at: new Date() });
|
|
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 }); }
|
|
314
150
|
// {{/if}}
|
|
315
|
-
|
|
316
|
-
return res.json({ success: true });
|
|
151
|
+
return archived ? res.json({ success: true, archived: true }) : res.status(404).json({ error: 'Transfer record not found' });
|
|
317
152
|
});
|