@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,325 +1,314 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Next.js App Router — Cross-Border Transfer Route
|
|
3
|
-
* Generated by create-ndpr for: {{ORG_NAME}}
|
|
4
|
-
*
|
|
5
|
-
* NDPA §41–43 — transfers of personal data outside Nigeria require
|
|
6
|
-
* adequate safeguards and, in some cases, NDPC approval.
|
|
7
|
-
*
|
|
8
|
-
* Endpoints:
|
|
9
|
-
* GET /api/cross-border — List transfers (optional ?riskLevel= or ?destinationCountry= filter)
|
|
10
|
-
* GET /api/cross-border?id=xxx — Get a single transfer record
|
|
11
|
-
* POST /api/cross-border — Register a new cross-border transfer
|
|
12
|
-
* PUT /api/cross-border — Update an existing transfer record
|
|
13
|
-
* DELETE /api/cross-border?id=xxx — Delete a transfer record
|
|
14
|
-
*/
|
|
15
|
-
|
|
1
|
+
/** Next.js App Router — staff-only, tenant-scoped transfer register for {{ORG_NAME_COMMENT}}. */
|
|
16
2
|
import { NextRequest, NextResponse } from 'next/server';
|
|
3
|
+
import { getNDPRContextProblem, resolveNDPRRequestContext } from '{{NDPR_CONTEXT_IMPORT}}';
|
|
17
4
|
// {{#if ORM=prisma}}
|
|
18
5
|
import { PrismaClient } from '@prisma/client';
|
|
19
|
-
|
|
20
6
|
const prisma = new PrismaClient();
|
|
21
7
|
// {{/if}}
|
|
22
8
|
// {{#if ORM=drizzle}}
|
|
23
|
-
import { db } from '
|
|
24
|
-
import {
|
|
25
|
-
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';
|
|
26
12
|
// {{/if}}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
+
|
|
24
|
+
interface TransferExisting {
|
|
35
25
|
destinationCountry: string;
|
|
36
26
|
recipientName: string;
|
|
37
27
|
transferMechanism: string;
|
|
38
28
|
safeguards: string;
|
|
39
|
-
dataCategories:
|
|
29
|
+
dataCategories: unknown;
|
|
40
30
|
adequacyStatus: string;
|
|
31
|
+
ndpcApprovalReference: string | null;
|
|
32
|
+
status: string;
|
|
33
|
+
}
|
|
34
|
+
interface TransferMutation {
|
|
35
|
+
destinationCountry: string;
|
|
36
|
+
recipientName: string;
|
|
37
|
+
transferMechanism: TransferMechanism;
|
|
38
|
+
safeguards: string;
|
|
39
|
+
dataCategories: string[];
|
|
40
|
+
adequacyStatus: AdequacyStatus;
|
|
41
41
|
ndpcApprovalRequired: boolean;
|
|
42
42
|
ndpcApprovalReference: string | null;
|
|
43
|
-
riskLevel:
|
|
44
|
-
|
|
43
|
+
riskLevel: RiskLevel;
|
|
44
|
+
status: TransferStatus;
|
|
45
45
|
updatedAt: Date;
|
|
46
46
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
|
|
48
|
+
// {{#if ORM=none}}
|
|
49
|
+
// DEVELOPMENT ONLY: replace both stores with one durable transactional store.
|
|
50
|
+
interface TransferRow extends TransferMutation { id: string; tenantId: string; createdAt: Date; removedAt: Date | null }
|
|
51
|
+
const transferStore = new Map<string, TransferRow>();
|
|
52
|
+
const auditLog: Array<{ id: string; tenantId: string; action: string; entityId: string; performedBy: string; changes?: unknown; at: Date }> = [];
|
|
50
53
|
// {{/if}}
|
|
51
54
|
|
|
52
|
-
|
|
55
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
56
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
57
|
+
}
|
|
58
|
+
function hasOwn(value: Record<string, unknown>, key: string): boolean {
|
|
59
|
+
return Object.prototype.hasOwnProperty.call(value, key);
|
|
60
|
+
}
|
|
61
|
+
function rejectUnknownFields(value: Record<string, unknown>, allowed: readonly string[]): string | null {
|
|
62
|
+
const allowedSet = new Set(allowed);
|
|
63
|
+
const unknown = Object.keys(value).filter((key) => !allowedSet.has(key));
|
|
64
|
+
return unknown.length > 0 ? `Unsupported field(s): ${unknown.join(', ')}` : null;
|
|
65
|
+
}
|
|
66
|
+
function nonEmptyText(value: unknown, field: string, maxLength = 10_000): Validation<string> {
|
|
67
|
+
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` };
|
|
68
|
+
return { ok: true, value: value.trim() };
|
|
69
|
+
}
|
|
70
|
+
function nonEmptyStringArray(value: unknown, field: string): Validation<string[]> {
|
|
71
|
+
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` };
|
|
72
|
+
const result: string[] = [];
|
|
73
|
+
for (const item of value) {
|
|
74
|
+
if (typeof item !== 'string' || !item.trim()) return { ok: false, error: `${field} must contain only non-empty strings` };
|
|
75
|
+
if (!result.includes(item.trim())) result.push(item.trim());
|
|
76
|
+
}
|
|
77
|
+
return { ok: true, value: result };
|
|
78
|
+
}
|
|
79
|
+
function isMechanism(value: unknown): value is TransferMechanism { return typeof value === 'string' && (TRANSFER_MECHANISMS as readonly string[]).includes(value); }
|
|
80
|
+
function isAdequacyStatus(value: unknown): value is AdequacyStatus { return typeof value === 'string' && (ADEQUACY_STATUSES as readonly string[]).includes(value); }
|
|
81
|
+
function isRiskLevel(value: unknown): value is RiskLevel { return typeof value === 'string' && (RISK_LEVELS as readonly string[]).includes(value); }
|
|
82
|
+
function isTransferStatus(value: unknown): value is TransferStatus { return typeof value === 'string' && (TRANSFER_STATUSES as readonly string[]).includes(value); }
|
|
83
|
+
function approvalRequired(mechanism: TransferMechanism): boolean {
|
|
84
|
+
return mechanism === 'binding_corporate_rules' || mechanism === 'ndpc_authorization';
|
|
85
|
+
}
|
|
86
|
+
function deriveRiskLevel(adequacy: AdequacyStatus, mechanism: TransferMechanism, required: boolean, reference: string | null): RiskLevel {
|
|
87
|
+
if ((required && !reference) || adequacy === 'inadequate') return 'high';
|
|
88
|
+
const derogation = ['explicit_consent', 'contract_performance', 'public_interest', 'legal_claims', 'vital_interests'].includes(mechanism);
|
|
89
|
+
if (adequacy === 'unknown' || adequacy === 'pending_review' || derogation) return 'medium';
|
|
90
|
+
return 'low';
|
|
91
|
+
}
|
|
92
|
+
const STATUS_TRANSITIONS: Record<TransferStatus, readonly TransferStatus[]> = {
|
|
93
|
+
active: ['active', 'suspended', 'terminated', 'pending_approval'],
|
|
94
|
+
suspended: ['suspended', 'active', 'terminated', 'pending_approval'],
|
|
95
|
+
pending_approval: ['pending_approval', 'active', 'suspended', 'terminated'],
|
|
96
|
+
terminated: ['terminated'],
|
|
97
|
+
};
|
|
98
|
+
function buildTransferMutation(input: Record<string, unknown>, existing?: TransferExisting, now = new Date()): Validation<TransferMutation> {
|
|
99
|
+
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' };
|
|
100
|
+
if (!destination.ok) return destination;
|
|
101
|
+
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' };
|
|
102
|
+
if (!recipient.ok) return recipient;
|
|
103
|
+
const mechanismValue = hasOwn(input, 'transferMechanism') ? input.transferMechanism : existing?.transferMechanism;
|
|
104
|
+
if (!isMechanism(mechanismValue)) return { ok: false, error: 'transferMechanism is not supported by the NDPA transfer register' };
|
|
105
|
+
const safeguards = hasOwn(input, 'safeguards') ? nonEmptyText(input.safeguards, 'safeguards') : existing ? nonEmptyText(existing.safeguards, 'stored safeguards') : { ok: false as const, error: 'safeguards is required' };
|
|
106
|
+
if (!safeguards.ok) return safeguards;
|
|
107
|
+
const categories = nonEmptyStringArray(hasOwn(input, 'dataCategories') ? input.dataCategories : existing?.dataCategories, 'dataCategories');
|
|
108
|
+
if (!categories.ok) return categories;
|
|
109
|
+
const adequacyValue = hasOwn(input, 'adequacyStatus') ? input.adequacyStatus : existing?.adequacyStatus;
|
|
110
|
+
if (!isAdequacyStatus(adequacyValue)) return { ok: false, error: 'adequacyStatus is not supported' };
|
|
111
|
+
if (mechanismValue === 'adequacy_decision' && adequacyValue !== 'adequate') return { ok: false, error: 'adequacy_decision can only be used for a destination marked adequate' };
|
|
112
|
+
let approvalReference: string | null;
|
|
113
|
+
const referenceValue = hasOwn(input, 'ndpcApprovalReference') ? input.ndpcApprovalReference : existing?.ndpcApprovalReference ?? null;
|
|
114
|
+
if (referenceValue === null || referenceValue === '') approvalReference = null;
|
|
115
|
+
else {
|
|
116
|
+
const reference = nonEmptyText(referenceValue, 'ndpcApprovalReference', 500);
|
|
117
|
+
if (!reference.ok) return reference;
|
|
118
|
+
approvalReference = reference.value;
|
|
119
|
+
}
|
|
120
|
+
const required = approvalRequired(mechanismValue);
|
|
121
|
+
if (!required && approvalReference) return { ok: false, error: 'ndpcApprovalReference is only accepted for a mechanism that requires NDPC approval' };
|
|
122
|
+
const explicitStatus = hasOwn(input, 'status');
|
|
123
|
+
if (explicitStatus && !isTransferStatus(input.status)) return { ok: false, error: 'status is not supported' };
|
|
124
|
+
let status: TransferStatus;
|
|
125
|
+
if (isTransferStatus(input.status)) status = input.status;
|
|
126
|
+
else if (required && !approvalReference) status = 'pending_approval';
|
|
127
|
+
else if (existing && isTransferStatus(existing.status) && existing.status !== 'pending_approval') status = existing.status;
|
|
128
|
+
else status = 'active';
|
|
129
|
+
if (required && !approvalReference && status === 'active') return { ok: false, error: 'An approval-dependent transfer cannot be active without an NDPC approval reference' };
|
|
130
|
+
if (!required && status === 'pending_approval') return { ok: false, error: 'pending_approval is only valid when the selected mechanism requires NDPC approval' };
|
|
131
|
+
if (approvalReference && status === 'pending_approval') return { ok: false, error: 'A transfer with an NDPC approval reference cannot remain pending_approval' };
|
|
132
|
+
if (existing) {
|
|
133
|
+
if (!isTransferStatus(existing.status)) return { ok: false, error: 'Stored transfer status is invalid' };
|
|
134
|
+
if (!STATUS_TRANSITIONS[existing.status].includes(status)) return { ok: false, error: `Transfer status cannot transition from ${existing.status} to ${status}` };
|
|
135
|
+
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' };
|
|
136
|
+
}
|
|
137
|
+
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 } };
|
|
138
|
+
}
|
|
139
|
+
function auditChanges(data: TransferMutation) {
|
|
140
|
+
return { destinationCountry: data.destinationCountry, recipientName: data.recipientName, transferMechanism: data.transferMechanism, adequacyStatus: data.adequacyStatus, ndpcApprovalRequired: data.ndpcApprovalRequired, riskLevel: data.riskLevel, status: data.status };
|
|
141
|
+
}
|
|
142
|
+
async function staffContext(req: NextRequest) {
|
|
143
|
+
const context = await resolveNDPRRequestContext(req);
|
|
144
|
+
return { context, problem: getNDPRContextProblem(context, 'staff') };
|
|
145
|
+
}
|
|
146
|
+
|
|
53
147
|
export async function GET(req: NextRequest) {
|
|
148
|
+
const { context, problem } = await staffContext(req);
|
|
149
|
+
if (problem) return NextResponse.json({ error: problem.error }, { status: problem.status });
|
|
54
150
|
const id = req.nextUrl.searchParams.get('id');
|
|
55
|
-
|
|
56
151
|
if (id) {
|
|
57
152
|
// {{#if ORM=prisma}}
|
|
58
|
-
const record = await prisma.crossBorderTransferRecord.
|
|
153
|
+
const record = await prisma.crossBorderTransferRecord.findFirst({ where: { id, tenantId: context.tenantId, removedAt: null } });
|
|
59
154
|
// {{/if}}
|
|
60
155
|
// {{#if ORM=drizzle}}
|
|
61
|
-
const [record] = await db.select().from(crossBorderTransferRecords).where(eq(crossBorderTransferRecords.id, id)).limit(1);
|
|
156
|
+
const [record] = await db.select().from(crossBorderTransferRecords).where(and(eq(crossBorderTransferRecords.id, id), eq(crossBorderTransferRecords.tenantId, context.tenantId), isNull(crossBorderTransferRecords.removedAt))).limit(1);
|
|
62
157
|
// {{/if}}
|
|
63
158
|
// {{#if ORM=none}}
|
|
64
|
-
const
|
|
159
|
+
const candidate = transferStore.get(id);
|
|
160
|
+
const record = candidate?.tenantId === context.tenantId && candidate.removedAt === null ? candidate : null;
|
|
65
161
|
// {{/if}}
|
|
66
|
-
|
|
67
|
-
if (!record) {
|
|
68
|
-
return NextResponse.json({ error: 'Cross-border transfer record not found' }, { status: 404 });
|
|
69
|
-
}
|
|
70
|
-
return NextResponse.json(record);
|
|
162
|
+
return record ? NextResponse.json(record) : NextResponse.json({ error: 'Transfer record not found' }, { status: 404 });
|
|
71
163
|
}
|
|
72
|
-
|
|
73
|
-
const
|
|
74
|
-
const
|
|
75
|
-
|
|
164
|
+
const riskValue = req.nextUrl.searchParams.get('riskLevel');
|
|
165
|
+
const statusValue = req.nextUrl.searchParams.get('status');
|
|
166
|
+
const destinationValue = req.nextUrl.searchParams.get('destinationCountry');
|
|
167
|
+
if (riskValue !== null && !isRiskLevel(riskValue)) return NextResponse.json({ error: 'riskLevel filter is not supported' }, { status: 400 });
|
|
168
|
+
if (statusValue !== null && !isTransferStatus(statusValue)) return NextResponse.json({ error: 'status filter is not supported' }, { status: 400 });
|
|
169
|
+
if (destinationValue !== null && !destinationValue.trim()) return NextResponse.json({ error: 'destinationCountry filter cannot be empty' }, { status: 400 });
|
|
170
|
+
const riskLevel = riskValue ?? undefined;
|
|
171
|
+
const status = statusValue ?? undefined;
|
|
172
|
+
const destinationCountry = destinationValue?.trim() || undefined;
|
|
76
173
|
// {{#if ORM=prisma}}
|
|
77
|
-
const where:
|
|
78
|
-
if (riskLevel) where.riskLevel = riskLevel;
|
|
79
|
-
if (destinationCountry) where.destinationCountry = destinationCountry;
|
|
80
|
-
|
|
81
|
-
const records = await prisma.crossBorderTransferRecord.findMany({
|
|
82
|
-
where: Object.keys(where).length > 0 ? where : undefined,
|
|
83
|
-
orderBy: { createdAt: 'desc' },
|
|
84
|
-
});
|
|
174
|
+
const records = await prisma.crossBorderTransferRecord.findMany({ where: { tenantId: context.tenantId, removedAt: null, ...(riskLevel ? { riskLevel } : {}), ...(status ? { status } : {}), ...(destinationCountry ? { destinationCountry } : {}) }, orderBy: { createdAt: 'desc' } });
|
|
85
175
|
// {{/if}}
|
|
86
176
|
// {{#if ORM=drizzle}}
|
|
87
|
-
const filters: SQL[] = [];
|
|
177
|
+
const filters: SQL[] = [eq(crossBorderTransferRecords.tenantId, context.tenantId), isNull(crossBorderTransferRecords.removedAt)];
|
|
88
178
|
if (riskLevel) filters.push(eq(crossBorderTransferRecords.riskLevel, riskLevel));
|
|
179
|
+
if (status) filters.push(eq(crossBorderTransferRecords.status, status));
|
|
89
180
|
if (destinationCountry) filters.push(eq(crossBorderTransferRecords.destinationCountry, destinationCountry));
|
|
90
|
-
|
|
91
|
-
const records = filters.length > 0
|
|
92
|
-
? await db.select().from(crossBorderTransferRecords).where(and(...filters)).orderBy(desc(crossBorderTransferRecords.createdAt))
|
|
93
|
-
: await db.select().from(crossBorderTransferRecords).orderBy(desc(crossBorderTransferRecords.createdAt));
|
|
181
|
+
const records = await db.select().from(crossBorderTransferRecords).where(and(...filters)).orderBy(desc(crossBorderTransferRecords.createdAt));
|
|
94
182
|
// {{/if}}
|
|
95
183
|
// {{#if ORM=none}}
|
|
96
|
-
const records = [...
|
|
97
|
-
.filter((r) => (riskLevel ? r.riskLevel === riskLevel : true))
|
|
98
|
-
.filter((r) => (destinationCountry ? r.destinationCountry === destinationCountry : true))
|
|
99
|
-
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
184
|
+
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());
|
|
100
185
|
// {{/if}}
|
|
101
|
-
|
|
102
186
|
return NextResponse.json(records);
|
|
103
187
|
}
|
|
104
188
|
|
|
105
|
-
// POST /api/cross-border
|
|
106
189
|
export async function POST(req: NextRequest) {
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
riskLevel,
|
|
118
|
-
} = body;
|
|
119
|
-
|
|
120
|
-
if (
|
|
121
|
-
!destinationCountry ||
|
|
122
|
-
!recipientName ||
|
|
123
|
-
!transferMechanism ||
|
|
124
|
-
!safeguards ||
|
|
125
|
-
!dataCategories ||
|
|
126
|
-
!adequacyStatus ||
|
|
127
|
-
!riskLevel
|
|
128
|
-
) {
|
|
129
|
-
return NextResponse.json(
|
|
130
|
-
{
|
|
131
|
-
error:
|
|
132
|
-
'destinationCountry, recipientName, transferMechanism, safeguards, dataCategories, adequacyStatus, and riskLevel are required',
|
|
133
|
-
},
|
|
134
|
-
{ status: 400 },
|
|
135
|
-
);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
if (!Array.isArray(dataCategories)) {
|
|
139
|
-
return NextResponse.json(
|
|
140
|
-
{ error: 'dataCategories must be an array' },
|
|
141
|
-
{ status: 400 },
|
|
142
|
-
);
|
|
143
|
-
}
|
|
144
|
-
|
|
190
|
+
const { context, problem } = await staffContext(req);
|
|
191
|
+
if (problem) return NextResponse.json({ error: problem.error }, { status: problem.status });
|
|
192
|
+
const actorId = context.actorId as string;
|
|
193
|
+
const body: unknown = await req.json().catch(() => null);
|
|
194
|
+
if (!isRecord(body)) return NextResponse.json({ error: 'A JSON object is required' }, { status: 400 });
|
|
195
|
+
const unknown = rejectUnknownFields(body, ['destinationCountry', 'recipientName', 'transferMechanism', 'safeguards', 'dataCategories', 'adequacyStatus', 'ndpcApprovalReference', 'status']);
|
|
196
|
+
if (unknown) return NextResponse.json({ error: unknown }, { status: 400 });
|
|
197
|
+
const normalized = buildTransferMutation(body);
|
|
198
|
+
if (!normalized.ok) return NextResponse.json({ error: normalized.error }, { status: 400 });
|
|
199
|
+
const data = normalized.value;
|
|
145
200
|
// {{#if ORM=prisma}}
|
|
146
|
-
const record = await prisma
|
|
147
|
-
data: {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
transferMechanism,
|
|
151
|
-
safeguards,
|
|
152
|
-
dataCategories,
|
|
153
|
-
adequacyStatus,
|
|
154
|
-
ndpcApprovalRequired: ndpcApprovalRequired ?? false,
|
|
155
|
-
ndpcApprovalReference: ndpcApprovalReference ?? null,
|
|
156
|
-
riskLevel,
|
|
157
|
-
},
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
await prisma.complianceAuditLog.create({
|
|
161
|
-
data: {
|
|
162
|
-
module: 'cross-border',
|
|
163
|
-
action: 'created',
|
|
164
|
-
entityId: record.id,
|
|
165
|
-
entityType: 'CrossBorderTransferRecord',
|
|
166
|
-
changes: { destinationCountry, recipientName, riskLevel },
|
|
167
|
-
},
|
|
201
|
+
const record = await prisma.$transaction(async (tx) => {
|
|
202
|
+
const created = await tx.crossBorderTransferRecord.create({ data: { ...data, tenantId: context.tenantId, removedAt: null } });
|
|
203
|
+
await tx.complianceAuditLog.create({ data: { tenantId: context.tenantId, module: 'cross-border', action: 'created', entityId: created.id, entityType: 'CrossBorderTransferRecord', performedBy: actorId, changes: auditChanges(data) } });
|
|
204
|
+
return created;
|
|
168
205
|
});
|
|
169
206
|
// {{/if}}
|
|
170
207
|
// {{#if ORM=drizzle}}
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
safeguards,
|
|
176
|
-
dataCategories,
|
|
177
|
-
adequacyStatus,
|
|
178
|
-
ndpcApprovalRequired: ndpcApprovalRequired ?? false,
|
|
179
|
-
ndpcApprovalReference: ndpcApprovalReference ?? null,
|
|
180
|
-
riskLevel,
|
|
181
|
-
}).returning();
|
|
182
|
-
|
|
183
|
-
await db.insert(complianceAuditLog).values({
|
|
184
|
-
module: 'cross-border',
|
|
185
|
-
action: 'created',
|
|
186
|
-
entityId: record.id,
|
|
187
|
-
entityType: 'CrossBorderTransferRecord',
|
|
188
|
-
changes: { destinationCountry, recipientName, riskLevel },
|
|
208
|
+
const record = await db.transaction(async (tx) => {
|
|
209
|
+
const [created] = await tx.insert(crossBorderTransferRecords).values({ ...data, tenantId: context.tenantId, removedAt: null }).returning();
|
|
210
|
+
await tx.insert(complianceAuditLog).values({ tenantId: context.tenantId, module: 'cross-border', action: 'created', entityId: created.id, entityType: 'CrossBorderTransferRecord', performedBy: actorId, changes: auditChanges(data) });
|
|
211
|
+
return created;
|
|
189
212
|
});
|
|
190
213
|
// {{/if}}
|
|
191
214
|
// {{#if ORM=none}}
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
destinationCountry,
|
|
196
|
-
recipientName,
|
|
197
|
-
transferMechanism,
|
|
198
|
-
safeguards,
|
|
199
|
-
dataCategories,
|
|
200
|
-
adequacyStatus,
|
|
201
|
-
ndpcApprovalRequired: ndpcApprovalRequired ?? false,
|
|
202
|
-
ndpcApprovalReference: ndpcApprovalReference ?? null,
|
|
203
|
-
riskLevel,
|
|
204
|
-
createdAt: now,
|
|
205
|
-
updatedAt: now,
|
|
206
|
-
};
|
|
207
|
-
crossBorderStore.set(record.id, record);
|
|
208
|
-
auditLog.push({ id: newId(), module: 'cross-border', action: 'created', entityId: record.id, at: new Date() });
|
|
215
|
+
const record: TransferRow = { id: crypto.randomUUID(), ...data, tenantId: context.tenantId, createdAt: data.updatedAt, removedAt: null };
|
|
216
|
+
transferStore.set(record.id, record);
|
|
217
|
+
auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'created', entityId: record.id, performedBy: actorId, changes: auditChanges(data), at: data.updatedAt });
|
|
209
218
|
// {{/if}}
|
|
210
|
-
|
|
211
219
|
return NextResponse.json(record, { status: 201 });
|
|
212
220
|
}
|
|
213
221
|
|
|
214
|
-
// PUT /api/cross-border
|
|
215
222
|
export async function PUT(req: NextRequest) {
|
|
216
|
-
const
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
+
const { context, problem } = await staffContext(req);
|
|
224
|
+
if (problem) return NextResponse.json({ error: problem.error }, { status: problem.status });
|
|
225
|
+
const actorId = context.actorId as string;
|
|
226
|
+
const body: unknown = await req.json().catch(() => null);
|
|
227
|
+
if (!isRecord(body)) return NextResponse.json({ error: 'A JSON object is required' }, { status: 400 });
|
|
228
|
+
const fields = ['destinationCountry', 'recipientName', 'transferMechanism', 'safeguards', 'dataCategories', 'adequacyStatus', 'ndpcApprovalReference', 'status'] as const;
|
|
229
|
+
const unknown = rejectUnknownFields(body, ['id', ...fields]);
|
|
230
|
+
if (unknown) return NextResponse.json({ error: unknown }, { status: 400 });
|
|
231
|
+
const idResult = nonEmptyText(body.id, 'id', 200);
|
|
232
|
+
if (!idResult.ok) return NextResponse.json({ error: idResult.error }, { status: 400 });
|
|
233
|
+
if (!fields.some((field) => hasOwn(body, field))) return NextResponse.json({ error: 'Provide at least one allowlisted transfer field to update' }, { status: 400 });
|
|
234
|
+
const id = idResult.value;
|
|
235
|
+
let validationError: string | null = null;
|
|
223
236
|
// {{#if ORM=prisma}}
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
where: { id },
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
await prisma.complianceAuditLog.create({
|
|
235
|
-
data: {
|
|
236
|
-
module: 'cross-border',
|
|
237
|
-
action: 'updated',
|
|
238
|
-
entityId: record.id,
|
|
239
|
-
entityType: 'CrossBorderTransferRecord',
|
|
240
|
-
changes: data,
|
|
241
|
-
},
|
|
237
|
+
const record = await prisma.$transaction(async (tx) => {
|
|
238
|
+
const existing = await tx.crossBorderTransferRecord.findFirst({ where: { id, tenantId: context.tenantId, removedAt: null } });
|
|
239
|
+
if (!existing) return null;
|
|
240
|
+
const normalized = buildTransferMutation(body, existing);
|
|
241
|
+
if (!normalized.ok) { validationError = normalized.error; return null; }
|
|
242
|
+
const data = normalized.value;
|
|
243
|
+
await tx.crossBorderTransferRecord.updateMany({ where: { id, tenantId: context.tenantId, removedAt: null }, data });
|
|
244
|
+
const updated = await tx.crossBorderTransferRecord.findFirstOrThrow({ where: { id, tenantId: context.tenantId, removedAt: null } });
|
|
245
|
+
await tx.complianceAuditLog.create({ data: { tenantId: context.tenantId, module: 'cross-border', action: 'updated', entityId: id, entityType: 'CrossBorderTransferRecord', performedBy: actorId, changes: auditChanges(data) } });
|
|
246
|
+
return updated;
|
|
242
247
|
});
|
|
243
248
|
// {{/if}}
|
|
244
249
|
// {{#if ORM=drizzle}}
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
action: 'updated',
|
|
255
|
-
entityId: record.id,
|
|
256
|
-
entityType: 'CrossBorderTransferRecord',
|
|
257
|
-
changes: data,
|
|
250
|
+
const record = await db.transaction(async (tx) => {
|
|
251
|
+
const [existing] = await tx.select().from(crossBorderTransferRecords).where(and(eq(crossBorderTransferRecords.id, id), eq(crossBorderTransferRecords.tenantId, context.tenantId), isNull(crossBorderTransferRecords.removedAt))).limit(1);
|
|
252
|
+
if (!existing) return null;
|
|
253
|
+
const normalized = buildTransferMutation(body, existing);
|
|
254
|
+
if (!normalized.ok) { validationError = normalized.error; return null; }
|
|
255
|
+
const data = normalized.value;
|
|
256
|
+
const [updated] = await tx.update(crossBorderTransferRecords).set(data).where(and(eq(crossBorderTransferRecords.id, id), eq(crossBorderTransferRecords.tenantId, context.tenantId), isNull(crossBorderTransferRecords.removedAt))).returning();
|
|
257
|
+
await tx.insert(complianceAuditLog).values({ tenantId: context.tenantId, module: 'cross-border', action: 'updated', entityId: id, entityType: 'CrossBorderTransferRecord', performedBy: actorId, changes: auditChanges(data) });
|
|
258
|
+
return updated;
|
|
258
259
|
});
|
|
259
260
|
// {{/if}}
|
|
260
261
|
// {{#if ORM=none}}
|
|
261
|
-
const existing =
|
|
262
|
-
|
|
263
|
-
|
|
262
|
+
const existing = transferStore.get(id);
|
|
263
|
+
let record: TransferRow | null = null;
|
|
264
|
+
if (existing?.tenantId === context.tenantId && existing.removedAt === null) {
|
|
265
|
+
const normalized = buildTransferMutation(body, existing);
|
|
266
|
+
if (!normalized.ok) validationError = normalized.error;
|
|
267
|
+
else {
|
|
268
|
+
record = { ...existing, ...normalized.value, id };
|
|
269
|
+
transferStore.set(id, record);
|
|
270
|
+
auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'updated', entityId: id, performedBy: actorId, changes: auditChanges(normalized.value), at: normalized.value.updatedAt });
|
|
271
|
+
}
|
|
264
272
|
}
|
|
265
|
-
const record: CrossBorderTransferRecord = { ...existing, ...data, id, updatedAt: new Date() };
|
|
266
|
-
crossBorderStore.set(id, record);
|
|
267
|
-
auditLog.push({ id: newId(), module: 'cross-border', action: 'updated', entityId: record.id, at: new Date() });
|
|
268
273
|
// {{/if}}
|
|
269
|
-
|
|
270
|
-
return NextResponse.json(record);
|
|
274
|
+
if (validationError) return NextResponse.json({ error: validationError }, { status: 400 });
|
|
275
|
+
return record ? NextResponse.json(record) : NextResponse.json({ error: 'Transfer record not found' }, { status: 404 });
|
|
271
276
|
}
|
|
272
277
|
|
|
273
|
-
// DELETE /api/cross-border?id=xxx
|
|
274
278
|
export async function DELETE(req: NextRequest) {
|
|
279
|
+
const { context, problem } = await staffContext(req);
|
|
280
|
+
if (problem) return NextResponse.json({ error: problem.error }, { status: problem.status });
|
|
281
|
+
const actorId = context.actorId as string;
|
|
275
282
|
const id = req.nextUrl.searchParams.get('id');
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
return NextResponse.json({ error: 'id is required' }, { status: 400 });
|
|
279
|
-
}
|
|
280
|
-
|
|
283
|
+
if (!id?.trim()) return NextResponse.json({ error: 'id is required' }, { status: 400 });
|
|
284
|
+
const archivedAt = new Date();
|
|
281
285
|
// {{#if ORM=prisma}}
|
|
282
|
-
const
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
await prisma.complianceAuditLog.create({
|
|
290
|
-
data: {
|
|
291
|
-
module: 'cross-border',
|
|
292
|
-
action: 'deleted',
|
|
293
|
-
entityId: id,
|
|
294
|
-
entityType: 'CrossBorderTransferRecord',
|
|
295
|
-
changes: { destinationCountry: existing.destinationCountry, recipientName: existing.recipientName },
|
|
296
|
-
},
|
|
286
|
+
const archived = await prisma.$transaction(async (tx) => {
|
|
287
|
+
const existing = await tx.crossBorderTransferRecord.findFirst({ where: { id, tenantId: context.tenantId, removedAt: null } });
|
|
288
|
+
if (!existing) return false;
|
|
289
|
+
const result = await tx.crossBorderTransferRecord.updateMany({ where: { id, tenantId: context.tenantId, removedAt: null }, data: { removedAt: archivedAt, updatedAt: archivedAt } });
|
|
290
|
+
if (result.count !== 1) return false;
|
|
291
|
+
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() } } });
|
|
292
|
+
return true;
|
|
297
293
|
});
|
|
298
294
|
// {{/if}}
|
|
299
295
|
// {{#if ORM=drizzle}}
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
await db.insert(complianceAuditLog).values({
|
|
308
|
-
module: 'cross-border',
|
|
309
|
-
action: 'deleted',
|
|
310
|
-
entityId: id,
|
|
311
|
-
entityType: 'CrossBorderTransferRecord',
|
|
312
|
-
changes: { destinationCountry: existing.destinationCountry, recipientName: existing.recipientName },
|
|
296
|
+
const archived = await db.transaction(async (tx) => {
|
|
297
|
+
const [existing] = await tx.select().from(crossBorderTransferRecords).where(and(eq(crossBorderTransferRecords.id, id), eq(crossBorderTransferRecords.tenantId, context.tenantId), isNull(crossBorderTransferRecords.removedAt))).limit(1);
|
|
298
|
+
if (!existing) return false;
|
|
299
|
+
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();
|
|
300
|
+
if (!updated) return false;
|
|
301
|
+
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() } });
|
|
302
|
+
return true;
|
|
313
303
|
});
|
|
314
304
|
// {{/if}}
|
|
315
305
|
// {{#if ORM=none}}
|
|
316
|
-
const existing =
|
|
317
|
-
|
|
318
|
-
|
|
306
|
+
const existing = transferStore.get(id);
|
|
307
|
+
const archived = existing?.tenantId === context.tenantId && existing.removedAt === null;
|
|
308
|
+
if (archived && existing) {
|
|
309
|
+
transferStore.set(id, { ...existing, removedAt: archivedAt, updatedAt: archivedAt });
|
|
310
|
+
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 });
|
|
319
311
|
}
|
|
320
|
-
crossBorderStore.delete(id);
|
|
321
|
-
auditLog.push({ id: newId(), module: 'cross-border', action: 'deleted', entityId: id, at: new Date() });
|
|
322
312
|
// {{/if}}
|
|
323
|
-
|
|
324
|
-
return NextResponse.json({ success: true });
|
|
313
|
+
return archived ? NextResponse.json({ success: true, archived: true }) : NextResponse.json({ error: 'Transfer record not found' }, { status: 404 });
|
|
325
314
|
}
|