@tantainnovative/create-ndpr 0.1.0 → 0.4.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 +28 -5
- package/bin/index.mjs +146 -11
- package/package.json +14 -4
- package/templates/drizzle-schema.ts +92 -7
- package/templates/express-consent-route.ts +111 -5
- package/templates/express-cross-border-route.ts +317 -0
- package/templates/express-dpia-route.ts +287 -0
- package/templates/express-lawful-basis-route.ts +292 -0
- package/templates/express-setup.ts +262 -3
- package/templates/github-ndpr-audit.yml +31 -0
- package/templates/ndpr-audit.json +14 -0
- package/templates/nextjs-breach-route.ts +133 -1
- package/templates/nextjs-consent-route.ts +93 -28
- package/templates/nextjs-cross-border-route.ts +325 -0
- package/templates/nextjs-dpia-route.ts +290 -0
- package/templates/nextjs-dsr-route.ts +84 -3
- package/templates/nextjs-lawful-basis-route.ts +297 -0
- package/templates/nextjs-layout.tsx +12 -3
- package/templates/prisma-schema.prisma +63 -5
|
@@ -11,9 +11,45 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { NextRequest, NextResponse } from 'next/server';
|
|
14
|
+
import { assessBreachNotification } from '@tantainnovative/ndpr-toolkit/server';
|
|
15
|
+
// {{#if ORM=prisma}}
|
|
14
16
|
import { PrismaClient } from '@prisma/client';
|
|
15
17
|
|
|
16
18
|
const prisma = new PrismaClient();
|
|
19
|
+
// {{/if}}
|
|
20
|
+
// {{#if ORM=drizzle}}
|
|
21
|
+
import { db } from '@/drizzle';
|
|
22
|
+
import { breachReports, complianceAuditLog } from '@/drizzle/ndpr-schema';
|
|
23
|
+
import { eq, desc } from 'drizzle-orm';
|
|
24
|
+
// {{/if}}
|
|
25
|
+
// {{#if ORM=none}}
|
|
26
|
+
// TODO ({{ORG_NAME}}): wire this to your persistent store of choice. The
|
|
27
|
+
// in-memory map below is enough to develop against locally but DOES NOT
|
|
28
|
+
// satisfy NDPA Section 44 (record-keeping) or the 72-hour NDPC notification
|
|
29
|
+
// requirement in Section 40. Replace `breachStore`/`auditLog` with your real
|
|
30
|
+
// store and connect it to your incident-response tooling.
|
|
31
|
+
interface BreachReport {
|
|
32
|
+
id: string;
|
|
33
|
+
title: string;
|
|
34
|
+
description: string;
|
|
35
|
+
category: string;
|
|
36
|
+
severity: 'critical' | 'high' | 'medium' | 'low';
|
|
37
|
+
status: string;
|
|
38
|
+
discoveredAt: Date;
|
|
39
|
+
occurredAt: Date | null;
|
|
40
|
+
reportedAt: Date;
|
|
41
|
+
reporterName: string;
|
|
42
|
+
reporterEmail: string;
|
|
43
|
+
reporterDepartment: string | null;
|
|
44
|
+
affectedSystems: string[];
|
|
45
|
+
dataTypes: string[];
|
|
46
|
+
estimatedAffected: number | null;
|
|
47
|
+
initialActions: string | null;
|
|
48
|
+
}
|
|
49
|
+
const breachStore = new Map<string, BreachReport>();
|
|
50
|
+
const auditLog: Array<{ id: string; module: string; action: string; entityId: string; at: Date }> = [];
|
|
51
|
+
function newId() { return crypto.randomUUID(); }
|
|
52
|
+
// {{/if}}
|
|
17
53
|
|
|
18
54
|
function calculateSeverity(
|
|
19
55
|
category: string,
|
|
@@ -32,10 +68,22 @@ function calculateSeverity(
|
|
|
32
68
|
export async function GET(req: NextRequest) {
|
|
33
69
|
const status = req.nextUrl.searchParams.get('status');
|
|
34
70
|
|
|
71
|
+
// {{#if ORM=prisma}}
|
|
35
72
|
const reports = await prisma.breachReport.findMany({
|
|
36
73
|
where: status ? { status } : undefined,
|
|
37
74
|
orderBy: { reportedAt: 'desc' },
|
|
38
75
|
});
|
|
76
|
+
// {{/if}}
|
|
77
|
+
// {{#if ORM=drizzle}}
|
|
78
|
+
const reports = status
|
|
79
|
+
? await db.select().from(breachReports).where(eq(breachReports.status, status)).orderBy(desc(breachReports.reportedAt))
|
|
80
|
+
: await db.select().from(breachReports).orderBy(desc(breachReports.reportedAt));
|
|
81
|
+
// {{/if}}
|
|
82
|
+
// {{#if ORM=none}}
|
|
83
|
+
const reports = [...breachStore.values()]
|
|
84
|
+
.filter((r) => (status ? r.status === status : true))
|
|
85
|
+
.sort((a, b) => b.reportedAt.getTime() - a.reportedAt.getTime());
|
|
86
|
+
// {{/if}}
|
|
39
87
|
|
|
40
88
|
return NextResponse.json(reports);
|
|
41
89
|
}
|
|
@@ -74,6 +122,7 @@ export async function POST(req: NextRequest) {
|
|
|
74
122
|
|
|
75
123
|
const severity = calculateSeverity(category, estimatedAffected);
|
|
76
124
|
|
|
125
|
+
// {{#if ORM=prisma}}
|
|
77
126
|
const report = await prisma.breachReport.create({
|
|
78
127
|
data: {
|
|
79
128
|
title,
|
|
@@ -102,6 +151,89 @@ export async function POST(req: NextRequest) {
|
|
|
102
151
|
changes: { title, category, severity, status: 'ongoing' },
|
|
103
152
|
},
|
|
104
153
|
});
|
|
154
|
+
// {{/if}}
|
|
155
|
+
// {{#if ORM=drizzle}}
|
|
156
|
+
const [report] = await db.insert(breachReports).values({
|
|
157
|
+
title,
|
|
158
|
+
description,
|
|
159
|
+
category,
|
|
160
|
+
severity,
|
|
161
|
+
status: 'ongoing',
|
|
162
|
+
discoveredAt: new Date(discoveredAt),
|
|
163
|
+
occurredAt: occurredAt ? new Date(occurredAt) : null,
|
|
164
|
+
reporterName,
|
|
165
|
+
reporterEmail,
|
|
166
|
+
reporterDepartment: reporterDepartment ?? null,
|
|
167
|
+
affectedSystems,
|
|
168
|
+
dataTypes,
|
|
169
|
+
estimatedAffected: estimatedAffected ?? null,
|
|
170
|
+
initialActions: initialActions ?? null,
|
|
171
|
+
}).returning();
|
|
105
172
|
|
|
106
|
-
|
|
173
|
+
await db.insert(complianceAuditLog).values({
|
|
174
|
+
module: 'breach',
|
|
175
|
+
action: 'reported',
|
|
176
|
+
entityId: report.id,
|
|
177
|
+
entityType: 'BreachReport',
|
|
178
|
+
changes: { title, category, severity, status: 'ongoing' },
|
|
179
|
+
});
|
|
180
|
+
// {{/if}}
|
|
181
|
+
// {{#if ORM=none}}
|
|
182
|
+
const report: BreachReport = {
|
|
183
|
+
id: newId(),
|
|
184
|
+
title,
|
|
185
|
+
description,
|
|
186
|
+
category,
|
|
187
|
+
severity,
|
|
188
|
+
status: 'ongoing',
|
|
189
|
+
discoveredAt: new Date(discoveredAt),
|
|
190
|
+
occurredAt: occurredAt ? new Date(occurredAt) : null,
|
|
191
|
+
reportedAt: new Date(),
|
|
192
|
+
reporterName,
|
|
193
|
+
reporterEmail,
|
|
194
|
+
reporterDepartment: reporterDepartment ?? null,
|
|
195
|
+
affectedSystems,
|
|
196
|
+
dataTypes,
|
|
197
|
+
estimatedAffected: estimatedAffected ?? null,
|
|
198
|
+
initialActions: initialActions ?? null,
|
|
199
|
+
};
|
|
200
|
+
breachStore.set(report.id, report);
|
|
201
|
+
auditLog.push({ id: newId(), module: 'breach', action: 'reported', entityId: report.id, at: new Date() });
|
|
202
|
+
// {{/if}}
|
|
203
|
+
|
|
204
|
+
// NDPC readiness — NDPA S.40 / GAID 2025 Art. 33(5). Advisory and
|
|
205
|
+
// non-blocking: you must record every incident, but this surfaces which
|
|
206
|
+
// mandated notification fields are still missing (and how long is left on
|
|
207
|
+
// the 72-hour clock) before you file with the Commission. Collect the
|
|
208
|
+
// missing fields — likely consequences, mitigation measures, data-subject
|
|
209
|
+
// categories, record count — as your investigation progresses.
|
|
210
|
+
const ndpcReadiness = assessBreachNotification({
|
|
211
|
+
id: report.id,
|
|
212
|
+
title,
|
|
213
|
+
description,
|
|
214
|
+
category,
|
|
215
|
+
discoveredAt: new Date(discoveredAt).getTime(),
|
|
216
|
+
occurredAt: occurredAt ? new Date(occurredAt).getTime() : undefined,
|
|
217
|
+
reportedAt: Date.now(),
|
|
218
|
+
reporter: { name: reporterName, email: reporterEmail, department: reporterDepartment ?? '' },
|
|
219
|
+
affectedSystems,
|
|
220
|
+
dataTypes,
|
|
221
|
+
estimatedAffectedSubjects: estimatedAffected ?? undefined,
|
|
222
|
+
initialActions: initialActions ?? undefined,
|
|
223
|
+
dpoContact: { name: '{{ORG_NAME}} DPO', email: '{{DPO_EMAIL}}' },
|
|
224
|
+
status: 'ongoing',
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
return NextResponse.json(
|
|
228
|
+
{
|
|
229
|
+
...report,
|
|
230
|
+
ndpcReadiness: {
|
|
231
|
+
complete: ndpcReadiness.complete,
|
|
232
|
+
completeness: ndpcReadiness.completeness,
|
|
233
|
+
missing: ndpcReadiness.missing,
|
|
234
|
+
hoursRemaining: ndpcReadiness.timing.hoursRemaining,
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
{ status: 201 },
|
|
238
|
+
);
|
|
107
239
|
}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Next.js App Router — Consent API Route
|
|
3
3
|
* Generated by create-ndpr for: {{ORG_NAME}}
|
|
4
4
|
*
|
|
5
|
-
* NDPA
|
|
5
|
+
* NDPA Section 26 (consent) and Section 25 (lawful basis). Records the
|
|
6
|
+
* subject's affirmative choice with the audit metadata NDPC asks for.
|
|
6
7
|
*
|
|
7
8
|
* Endpoints:
|
|
8
9
|
* GET /api/consent?subjectId=xxx — Load the active consent record
|
|
@@ -11,9 +12,37 @@
|
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import { NextRequest, NextResponse } from 'next/server';
|
|
15
|
+
// {{#if ORM=prisma}}
|
|
14
16
|
import { PrismaClient } from '@prisma/client';
|
|
15
17
|
|
|
16
18
|
const prisma = new PrismaClient();
|
|
19
|
+
// {{/if}}
|
|
20
|
+
// {{#if ORM=drizzle}}
|
|
21
|
+
import { db } from '@/drizzle';
|
|
22
|
+
import { consentRecords, complianceAuditLog } from '@/drizzle/ndpr-schema';
|
|
23
|
+
import { eq, and, isNull, desc } from 'drizzle-orm';
|
|
24
|
+
// {{/if}}
|
|
25
|
+
// {{#if ORM=none}}
|
|
26
|
+
// TODO ({{ORG_NAME}}): wire this to your persistent store of choice. The
|
|
27
|
+
// in-memory map below is enough to develop against locally but DOES NOT
|
|
28
|
+
// satisfy NDPA Section 44 (record-keeping) or the auditability NDPC asks
|
|
29
|
+
// about. Replace `consentStore`/`auditLog` with your DB / KV / API.
|
|
30
|
+
interface ConsentRecord {
|
|
31
|
+
id: string;
|
|
32
|
+
subjectId: string;
|
|
33
|
+
consents: Record<string, boolean>;
|
|
34
|
+
version: string;
|
|
35
|
+
method: string;
|
|
36
|
+
lawfulBasis: string | null;
|
|
37
|
+
ipAddress: string | null;
|
|
38
|
+
userAgent: string | null;
|
|
39
|
+
createdAt: Date;
|
|
40
|
+
revokedAt: Date | null;
|
|
41
|
+
}
|
|
42
|
+
const consentStore = new Map<string, ConsentRecord>();
|
|
43
|
+
const auditLog: Array<{ id: string; module: string; action: string; entityId: string; at: Date }> = [];
|
|
44
|
+
function newId() { return crypto.randomUUID(); }
|
|
45
|
+
// {{/if}}
|
|
17
46
|
|
|
18
47
|
// GET /api/consent?subjectId=xxx
|
|
19
48
|
export async function GET(req: NextRequest) {
|
|
@@ -23,12 +52,27 @@ export async function GET(req: NextRequest) {
|
|
|
23
52
|
return NextResponse.json({ error: 'subjectId required' }, { status: 400 });
|
|
24
53
|
}
|
|
25
54
|
|
|
55
|
+
// {{#if ORM=prisma}}
|
|
26
56
|
const record = await prisma.consentRecord.findFirst({
|
|
27
57
|
where: { subjectId, revokedAt: null },
|
|
28
58
|
orderBy: { createdAt: 'desc' },
|
|
29
59
|
});
|
|
30
|
-
|
|
31
|
-
|
|
60
|
+
// {{/if}}
|
|
61
|
+
// {{#if ORM=drizzle}}
|
|
62
|
+
const [record] = await db
|
|
63
|
+
.select()
|
|
64
|
+
.from(consentRecords)
|
|
65
|
+
.where(and(eq(consentRecords.subjectId, subjectId), isNull(consentRecords.revokedAt)))
|
|
66
|
+
.orderBy(desc(consentRecords.createdAt))
|
|
67
|
+
.limit(1);
|
|
68
|
+
// {{/if}}
|
|
69
|
+
// {{#if ORM=none}}
|
|
70
|
+
const record = [...consentStore.values()]
|
|
71
|
+
.filter((r) => r.subjectId === subjectId && r.revokedAt === null)
|
|
72
|
+
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0];
|
|
73
|
+
// {{/if}}
|
|
74
|
+
|
|
75
|
+
return NextResponse.json(record ?? null);
|
|
32
76
|
}
|
|
33
77
|
|
|
34
78
|
// POST /api/consent
|
|
@@ -43,38 +87,53 @@ export async function POST(req: NextRequest) {
|
|
|
43
87
|
);
|
|
44
88
|
}
|
|
45
89
|
|
|
46
|
-
|
|
90
|
+
const ipAddress = req.headers.get('x-forwarded-for');
|
|
91
|
+
const userAgent = req.headers.get('user-agent');
|
|
92
|
+
|
|
93
|
+
// {{#if ORM=prisma}}
|
|
94
|
+
// Revoke previously active records (immutable-audit pattern — Section 44).
|
|
47
95
|
await prisma.consentRecord.updateMany({
|
|
48
96
|
where: { subjectId, revokedAt: null },
|
|
49
97
|
data: { revokedAt: new Date() },
|
|
50
98
|
});
|
|
51
99
|
|
|
52
100
|
const record = await prisma.consentRecord.create({
|
|
53
|
-
data: {
|
|
54
|
-
subjectId,
|
|
55
|
-
consents,
|
|
56
|
-
version,
|
|
57
|
-
method: method ?? 'api',
|
|
58
|
-
lawfulBasis: lawfulBasis ?? null,
|
|
59
|
-
ipAddress: req.headers.get('x-forwarded-for'),
|
|
60
|
-
userAgent: req.headers.get('user-agent'),
|
|
61
|
-
},
|
|
101
|
+
data: { subjectId, consents, version, method: method ?? 'api', lawfulBasis: lawfulBasis ?? null, ipAddress, userAgent },
|
|
62
102
|
});
|
|
63
103
|
|
|
64
104
|
await prisma.complianceAuditLog.create({
|
|
65
|
-
data: {
|
|
66
|
-
module: 'consent',
|
|
67
|
-
action: 'created',
|
|
68
|
-
entityId: record.id,
|
|
69
|
-
entityType: 'ConsentRecord',
|
|
70
|
-
changes: { subjectId, version, consents },
|
|
71
|
-
},
|
|
105
|
+
data: { module: 'consent', action: 'created', entityId: record.id, entityType: 'ConsentRecord', changes: { subjectId, version, consents } },
|
|
72
106
|
});
|
|
107
|
+
// {{/if}}
|
|
108
|
+
// {{#if ORM=drizzle}}
|
|
109
|
+
await db.update(consentRecords).set({ revokedAt: new Date() }).where(and(eq(consentRecords.subjectId, subjectId), isNull(consentRecords.revokedAt)));
|
|
110
|
+
|
|
111
|
+
const [record] = await db.insert(consentRecords).values({
|
|
112
|
+
subjectId, consents, version, method: method ?? 'api', lawfulBasis: lawfulBasis ?? null, ipAddress, userAgent,
|
|
113
|
+
}).returning();
|
|
114
|
+
|
|
115
|
+
await db.insert(complianceAuditLog).values({
|
|
116
|
+
module: 'consent', action: 'created', entityId: record.id, entityType: 'ConsentRecord', changes: { subjectId, version, consents },
|
|
117
|
+
});
|
|
118
|
+
// {{/if}}
|
|
119
|
+
// {{#if ORM=none}}
|
|
120
|
+
// Revoke previously active records for this subject
|
|
121
|
+
for (const r of consentStore.values()) {
|
|
122
|
+
if (r.subjectId === subjectId && r.revokedAt === null) r.revokedAt = new Date();
|
|
123
|
+
}
|
|
124
|
+
const record: ConsentRecord = {
|
|
125
|
+
id: newId(), subjectId, consents, version, method: method ?? 'api',
|
|
126
|
+
lawfulBasis: lawfulBasis ?? null, ipAddress, userAgent,
|
|
127
|
+
createdAt: new Date(), revokedAt: null,
|
|
128
|
+
};
|
|
129
|
+
consentStore.set(record.id, record);
|
|
130
|
+
auditLog.push({ id: newId(), module: 'consent', action: 'created', entityId: record.id, at: new Date() });
|
|
131
|
+
// {{/if}}
|
|
73
132
|
|
|
74
133
|
return NextResponse.json(record, { status: 201 });
|
|
75
134
|
}
|
|
76
135
|
|
|
77
|
-
// DELETE /api/consent?subjectId=xxx
|
|
136
|
+
// DELETE /api/consent?subjectId=xxx — Section 26 withdrawal
|
|
78
137
|
export async function DELETE(req: NextRequest) {
|
|
79
138
|
const subjectId = req.nextUrl.searchParams.get('subjectId');
|
|
80
139
|
|
|
@@ -82,19 +141,25 @@ export async function DELETE(req: NextRequest) {
|
|
|
82
141
|
return NextResponse.json({ error: 'subjectId required' }, { status: 400 });
|
|
83
142
|
}
|
|
84
143
|
|
|
144
|
+
// {{#if ORM=prisma}}
|
|
85
145
|
await prisma.consentRecord.updateMany({
|
|
86
146
|
where: { subjectId, revokedAt: null },
|
|
87
147
|
data: { revokedAt: new Date() },
|
|
88
148
|
});
|
|
89
|
-
|
|
90
149
|
await prisma.complianceAuditLog.create({
|
|
91
|
-
data: {
|
|
92
|
-
module: 'consent',
|
|
93
|
-
action: 'revoked',
|
|
94
|
-
entityId: subjectId,
|
|
95
|
-
entityType: 'ConsentRecord',
|
|
96
|
-
},
|
|
150
|
+
data: { module: 'consent', action: 'revoked', entityId: subjectId, entityType: 'ConsentRecord' },
|
|
97
151
|
});
|
|
152
|
+
// {{/if}}
|
|
153
|
+
// {{#if ORM=drizzle}}
|
|
154
|
+
await db.update(consentRecords).set({ revokedAt: new Date() }).where(and(eq(consentRecords.subjectId, subjectId), isNull(consentRecords.revokedAt)));
|
|
155
|
+
await db.insert(complianceAuditLog).values({ module: 'consent', action: 'revoked', entityId: subjectId, entityType: 'ConsentRecord' });
|
|
156
|
+
// {{/if}}
|
|
157
|
+
// {{#if ORM=none}}
|
|
158
|
+
for (const r of consentStore.values()) {
|
|
159
|
+
if (r.subjectId === subjectId && r.revokedAt === null) r.revokedAt = new Date();
|
|
160
|
+
}
|
|
161
|
+
auditLog.push({ id: newId(), module: 'consent', action: 'revoked', entityId: subjectId, at: new Date() });
|
|
162
|
+
// {{/if}}
|
|
98
163
|
|
|
99
164
|
return NextResponse.json({ success: true });
|
|
100
165
|
}
|
|
@@ -0,0 +1,325 @@
|
|
|
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
|
+
|
|
16
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
17
|
+
// {{#if ORM=prisma}}
|
|
18
|
+
import { PrismaClient } from '@prisma/client';
|
|
19
|
+
|
|
20
|
+
const prisma = new PrismaClient();
|
|
21
|
+
// {{/if}}
|
|
22
|
+
// {{#if ORM=drizzle}}
|
|
23
|
+
import { db } from '@/drizzle';
|
|
24
|
+
import { crossBorderTransferRecords, complianceAuditLog } from '@/drizzle/ndpr-schema';
|
|
25
|
+
import { eq, and, desc, type SQL } from 'drizzle-orm';
|
|
26
|
+
// {{/if}}
|
|
27
|
+
// {{#if ORM=none}}
|
|
28
|
+
// TODO ({{ORG_NAME}}): wire this to your persistent store of choice. The
|
|
29
|
+
// in-memory map below is enough to develop against locally but DOES NOT
|
|
30
|
+
// satisfy NDPA Section 44 (record-keeping) or the cross-border register
|
|
31
|
+
// NDPC inspects under Sections 41–43. Replace `crossBorderStore`/`auditLog`
|
|
32
|
+
// with your DB / KV / API.
|
|
33
|
+
interface CrossBorderTransferRecord {
|
|
34
|
+
id: string;
|
|
35
|
+
destinationCountry: string;
|
|
36
|
+
recipientName: string;
|
|
37
|
+
transferMechanism: string;
|
|
38
|
+
safeguards: string;
|
|
39
|
+
dataCategories: string[];
|
|
40
|
+
adequacyStatus: string;
|
|
41
|
+
ndpcApprovalRequired: boolean;
|
|
42
|
+
ndpcApprovalReference: string | null;
|
|
43
|
+
riskLevel: string;
|
|
44
|
+
createdAt: Date;
|
|
45
|
+
updatedAt: Date;
|
|
46
|
+
}
|
|
47
|
+
const crossBorderStore = new Map<string, CrossBorderTransferRecord>();
|
|
48
|
+
const auditLog: Array<{ id: string; module: string; action: string; entityId: string; at: Date }> = [];
|
|
49
|
+
function newId() { return crypto.randomUUID(); }
|
|
50
|
+
// {{/if}}
|
|
51
|
+
|
|
52
|
+
// GET /api/cross-border?riskLevel=high OR /api/cross-border?id=xxx
|
|
53
|
+
export async function GET(req: NextRequest) {
|
|
54
|
+
const id = req.nextUrl.searchParams.get('id');
|
|
55
|
+
|
|
56
|
+
if (id) {
|
|
57
|
+
// {{#if ORM=prisma}}
|
|
58
|
+
const record = await prisma.crossBorderTransferRecord.findUnique({ where: { id } });
|
|
59
|
+
// {{/if}}
|
|
60
|
+
// {{#if ORM=drizzle}}
|
|
61
|
+
const [record] = await db.select().from(crossBorderTransferRecords).where(eq(crossBorderTransferRecords.id, id)).limit(1);
|
|
62
|
+
// {{/if}}
|
|
63
|
+
// {{#if ORM=none}}
|
|
64
|
+
const record = crossBorderStore.get(id) ?? null;
|
|
65
|
+
// {{/if}}
|
|
66
|
+
|
|
67
|
+
if (!record) {
|
|
68
|
+
return NextResponse.json({ error: 'Cross-border transfer record not found' }, { status: 404 });
|
|
69
|
+
}
|
|
70
|
+
return NextResponse.json(record);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const riskLevel = req.nextUrl.searchParams.get('riskLevel');
|
|
74
|
+
const destinationCountry = req.nextUrl.searchParams.get('destinationCountry');
|
|
75
|
+
|
|
76
|
+
// {{#if ORM=prisma}}
|
|
77
|
+
const where: Record<string, string> = {};
|
|
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
|
+
});
|
|
85
|
+
// {{/if}}
|
|
86
|
+
// {{#if ORM=drizzle}}
|
|
87
|
+
const filters: SQL[] = [];
|
|
88
|
+
if (riskLevel) filters.push(eq(crossBorderTransferRecords.riskLevel, riskLevel));
|
|
89
|
+
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));
|
|
94
|
+
// {{/if}}
|
|
95
|
+
// {{#if ORM=none}}
|
|
96
|
+
const records = [...crossBorderStore.values()]
|
|
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());
|
|
100
|
+
// {{/if}}
|
|
101
|
+
|
|
102
|
+
return NextResponse.json(records);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// POST /api/cross-border
|
|
106
|
+
export async function POST(req: NextRequest) {
|
|
107
|
+
const body = await req.json();
|
|
108
|
+
const {
|
|
109
|
+
destinationCountry,
|
|
110
|
+
recipientName,
|
|
111
|
+
transferMechanism,
|
|
112
|
+
safeguards,
|
|
113
|
+
dataCategories,
|
|
114
|
+
adequacyStatus,
|
|
115
|
+
ndpcApprovalRequired,
|
|
116
|
+
ndpcApprovalReference,
|
|
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
|
+
|
|
145
|
+
// {{#if ORM=prisma}}
|
|
146
|
+
const record = await prisma.crossBorderTransferRecord.create({
|
|
147
|
+
data: {
|
|
148
|
+
destinationCountry,
|
|
149
|
+
recipientName,
|
|
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
|
+
},
|
|
168
|
+
});
|
|
169
|
+
// {{/if}}
|
|
170
|
+
// {{#if ORM=drizzle}}
|
|
171
|
+
const [record] = await db.insert(crossBorderTransferRecords).values({
|
|
172
|
+
destinationCountry,
|
|
173
|
+
recipientName,
|
|
174
|
+
transferMechanism,
|
|
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 },
|
|
189
|
+
});
|
|
190
|
+
// {{/if}}
|
|
191
|
+
// {{#if ORM=none}}
|
|
192
|
+
const now = new Date();
|
|
193
|
+
const record: CrossBorderTransferRecord = {
|
|
194
|
+
id: newId(),
|
|
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() });
|
|
209
|
+
// {{/if}}
|
|
210
|
+
|
|
211
|
+
return NextResponse.json(record, { status: 201 });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// PUT /api/cross-border
|
|
215
|
+
export async function PUT(req: NextRequest) {
|
|
216
|
+
const body = await req.json();
|
|
217
|
+
const { id, ...data } = body;
|
|
218
|
+
|
|
219
|
+
if (!id) {
|
|
220
|
+
return NextResponse.json({ error: 'id is required' }, { status: 400 });
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// {{#if ORM=prisma}}
|
|
224
|
+
const existing = await prisma.crossBorderTransferRecord.findUnique({ where: { id } });
|
|
225
|
+
if (!existing) {
|
|
226
|
+
return NextResponse.json({ error: 'Cross-border transfer record not found' }, { status: 404 });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const record = await prisma.crossBorderTransferRecord.update({
|
|
230
|
+
where: { id },
|
|
231
|
+
data,
|
|
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
|
+
},
|
|
242
|
+
});
|
|
243
|
+
// {{/if}}
|
|
244
|
+
// {{#if ORM=drizzle}}
|
|
245
|
+
const [existing] = await db.select().from(crossBorderTransferRecords).where(eq(crossBorderTransferRecords.id, id)).limit(1);
|
|
246
|
+
if (!existing) {
|
|
247
|
+
return NextResponse.json({ error: 'Cross-border transfer record not found' }, { status: 404 });
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const [record] = await db.update(crossBorderTransferRecords).set(data).where(eq(crossBorderTransferRecords.id, id)).returning();
|
|
251
|
+
|
|
252
|
+
await db.insert(complianceAuditLog).values({
|
|
253
|
+
module: 'cross-border',
|
|
254
|
+
action: 'updated',
|
|
255
|
+
entityId: record.id,
|
|
256
|
+
entityType: 'CrossBorderTransferRecord',
|
|
257
|
+
changes: data,
|
|
258
|
+
});
|
|
259
|
+
// {{/if}}
|
|
260
|
+
// {{#if ORM=none}}
|
|
261
|
+
const existing = crossBorderStore.get(id);
|
|
262
|
+
if (!existing) {
|
|
263
|
+
return NextResponse.json({ error: 'Cross-border transfer record not found' }, { status: 404 });
|
|
264
|
+
}
|
|
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
|
+
// {{/if}}
|
|
269
|
+
|
|
270
|
+
return NextResponse.json(record);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// DELETE /api/cross-border?id=xxx
|
|
274
|
+
export async function DELETE(req: NextRequest) {
|
|
275
|
+
const id = req.nextUrl.searchParams.get('id');
|
|
276
|
+
|
|
277
|
+
if (!id) {
|
|
278
|
+
return NextResponse.json({ error: 'id is required' }, { status: 400 });
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// {{#if ORM=prisma}}
|
|
282
|
+
const existing = await prisma.crossBorderTransferRecord.findUnique({ where: { id } });
|
|
283
|
+
if (!existing) {
|
|
284
|
+
return NextResponse.json({ error: 'Cross-border transfer record not found' }, { status: 404 });
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
await prisma.crossBorderTransferRecord.delete({ where: { id } });
|
|
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
|
+
},
|
|
297
|
+
});
|
|
298
|
+
// {{/if}}
|
|
299
|
+
// {{#if ORM=drizzle}}
|
|
300
|
+
const [existing] = await db.select().from(crossBorderTransferRecords).where(eq(crossBorderTransferRecords.id, id)).limit(1);
|
|
301
|
+
if (!existing) {
|
|
302
|
+
return NextResponse.json({ error: 'Cross-border transfer record not found' }, { status: 404 });
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
await db.delete(crossBorderTransferRecords).where(eq(crossBorderTransferRecords.id, id));
|
|
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 },
|
|
313
|
+
});
|
|
314
|
+
// {{/if}}
|
|
315
|
+
// {{#if ORM=none}}
|
|
316
|
+
const existing = crossBorderStore.get(id);
|
|
317
|
+
if (!existing) {
|
|
318
|
+
return NextResponse.json({ error: 'Cross-border transfer record not found' }, { status: 404 });
|
|
319
|
+
}
|
|
320
|
+
crossBorderStore.delete(id);
|
|
321
|
+
auditLog.push({ id: newId(), module: 'cross-border', action: 'deleted', entityId: id, at: new Date() });
|
|
322
|
+
// {{/if}}
|
|
323
|
+
|
|
324
|
+
return NextResponse.json({ success: true });
|
|
325
|
+
}
|