@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
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Express — Lawful Basis Router
|
|
3
|
+
* Generated by create-ndpr for: {{ORG_NAME}}
|
|
4
|
+
* DPO: {{DPO_EMAIL}}
|
|
5
|
+
*
|
|
6
|
+
* NDPA §25 — data processing must have a lawful basis. This router
|
|
7
|
+
* manages a register of processing activities and their lawful bases.
|
|
8
|
+
*
|
|
9
|
+
* Routes:
|
|
10
|
+
* GET /lawful-basis — List records (optional ?lawfulBasis= filter)
|
|
11
|
+
* GET /lawful-basis/:id — Get a single record
|
|
12
|
+
* POST /lawful-basis — Create a new lawful basis record
|
|
13
|
+
* PUT /lawful-basis/:id — Update an existing record
|
|
14
|
+
* DELETE /lawful-basis/:id — Delete a record
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* import { lawfulBasisRouter } from './routes/lawful-basis';
|
|
18
|
+
* app.use('/api/lawful-basis', lawfulBasisRouter);
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { Router } from 'express';
|
|
22
|
+
// {{#if ORM=prisma}}
|
|
23
|
+
import { PrismaClient } from '@prisma/client';
|
|
24
|
+
|
|
25
|
+
const prisma = new PrismaClient();
|
|
26
|
+
// {{/if}}
|
|
27
|
+
// {{#if ORM=drizzle}}
|
|
28
|
+
import { db } from '@/drizzle';
|
|
29
|
+
import { lawfulBasisRecords, complianceAuditLog } from '@/drizzle/ndpr-schema';
|
|
30
|
+
import { eq, desc } from 'drizzle-orm';
|
|
31
|
+
// {{/if}}
|
|
32
|
+
// {{#if ORM=none}}
|
|
33
|
+
// TODO ({{ORG_NAME}}): wire this to your persistent store of choice. The
|
|
34
|
+
// in-memory map below is enough to develop against locally but DOES NOT
|
|
35
|
+
// satisfy NDPA Section 44 (record-keeping). Replace `lawfulBasisStore`/
|
|
36
|
+
// `auditLog` with your DB / KV / API.
|
|
37
|
+
interface LawfulBasisRecord {
|
|
38
|
+
id: string;
|
|
39
|
+
activityName: string;
|
|
40
|
+
lawfulBasis: string;
|
|
41
|
+
justification: string;
|
|
42
|
+
dataCategories: string[];
|
|
43
|
+
purposes: string[];
|
|
44
|
+
assessedBy: string;
|
|
45
|
+
reviewDate: Date | null;
|
|
46
|
+
createdAt: Date;
|
|
47
|
+
updatedAt: Date;
|
|
48
|
+
}
|
|
49
|
+
const lawfulBasisStore = new Map<string, LawfulBasisRecord>();
|
|
50
|
+
const auditLog: Array<{ id: string; module: string; action: string; entityId: string; at: Date }> = [];
|
|
51
|
+
function newId() { return crypto.randomUUID(); }
|
|
52
|
+
// {{/if}}
|
|
53
|
+
|
|
54
|
+
export const lawfulBasisRouter = Router();
|
|
55
|
+
|
|
56
|
+
// GET /lawful-basis?lawfulBasis=consent
|
|
57
|
+
lawfulBasisRouter.get('/', async (req, res) => {
|
|
58
|
+
const { lawfulBasis } = req.query;
|
|
59
|
+
const basisFilter = typeof lawfulBasis === 'string' ? lawfulBasis : undefined;
|
|
60
|
+
|
|
61
|
+
// {{#if ORM=prisma}}
|
|
62
|
+
const records = await prisma.lawfulBasisRecord.findMany({
|
|
63
|
+
where: basisFilter ? { lawfulBasis: basisFilter } : undefined,
|
|
64
|
+
orderBy: { createdAt: 'desc' },
|
|
65
|
+
});
|
|
66
|
+
// {{/if}}
|
|
67
|
+
// {{#if ORM=drizzle}}
|
|
68
|
+
const records = basisFilter
|
|
69
|
+
? await db.select().from(lawfulBasisRecords).where(eq(lawfulBasisRecords.lawfulBasis, basisFilter)).orderBy(desc(lawfulBasisRecords.createdAt))
|
|
70
|
+
: await db.select().from(lawfulBasisRecords).orderBy(desc(lawfulBasisRecords.createdAt));
|
|
71
|
+
// {{/if}}
|
|
72
|
+
// {{#if ORM=none}}
|
|
73
|
+
const records = [...lawfulBasisStore.values()]
|
|
74
|
+
.filter((r) => (basisFilter ? r.lawfulBasis === basisFilter : true))
|
|
75
|
+
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
76
|
+
// {{/if}}
|
|
77
|
+
|
|
78
|
+
return res.json(records);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// GET /lawful-basis/:id
|
|
82
|
+
lawfulBasisRouter.get('/:id', async (req, res) => {
|
|
83
|
+
// {{#if ORM=prisma}}
|
|
84
|
+
const record = await prisma.lawfulBasisRecord.findUnique({ where: { id: req.params.id } });
|
|
85
|
+
// {{/if}}
|
|
86
|
+
// {{#if ORM=drizzle}}
|
|
87
|
+
const [record] = await db.select().from(lawfulBasisRecords).where(eq(lawfulBasisRecords.id, req.params.id)).limit(1);
|
|
88
|
+
// {{/if}}
|
|
89
|
+
// {{#if ORM=none}}
|
|
90
|
+
const record = lawfulBasisStore.get(req.params.id) ?? null;
|
|
91
|
+
// {{/if}}
|
|
92
|
+
|
|
93
|
+
if (!record) {
|
|
94
|
+
return res.status(404).json({ error: 'Lawful basis record not found' });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return res.json(record);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// POST /lawful-basis
|
|
101
|
+
lawfulBasisRouter.post('/', async (req, res) => {
|
|
102
|
+
const {
|
|
103
|
+
activityName,
|
|
104
|
+
lawfulBasis,
|
|
105
|
+
justification,
|
|
106
|
+
dataCategories,
|
|
107
|
+
purposes,
|
|
108
|
+
assessedBy,
|
|
109
|
+
reviewDate,
|
|
110
|
+
} = req.body;
|
|
111
|
+
|
|
112
|
+
if (!activityName || !lawfulBasis || !justification || !dataCategories || !purposes || !assessedBy) {
|
|
113
|
+
return res.status(400).json({
|
|
114
|
+
error: 'activityName, lawfulBasis, justification, dataCategories, purposes, and assessedBy are required',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!Array.isArray(dataCategories) || !Array.isArray(purposes)) {
|
|
119
|
+
return res.status(400).json({ error: 'dataCategories and purposes must be arrays' });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// {{#if ORM=prisma}}
|
|
123
|
+
const record = await prisma.lawfulBasisRecord.create({
|
|
124
|
+
data: {
|
|
125
|
+
activityName,
|
|
126
|
+
lawfulBasis,
|
|
127
|
+
justification,
|
|
128
|
+
dataCategories,
|
|
129
|
+
purposes,
|
|
130
|
+
assessedBy,
|
|
131
|
+
reviewDate: reviewDate ? new Date(reviewDate) : null,
|
|
132
|
+
},
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await prisma.complianceAuditLog.create({
|
|
136
|
+
data: {
|
|
137
|
+
module: 'lawful-basis',
|
|
138
|
+
action: 'created',
|
|
139
|
+
entityId: record.id,
|
|
140
|
+
entityType: 'LawfulBasisRecord',
|
|
141
|
+
changes: { activityName, lawfulBasis, assessedBy },
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
// {{/if}}
|
|
145
|
+
// {{#if ORM=drizzle}}
|
|
146
|
+
const [record] = await db.insert(lawfulBasisRecords).values({
|
|
147
|
+
activityName,
|
|
148
|
+
lawfulBasis,
|
|
149
|
+
justification,
|
|
150
|
+
dataCategories,
|
|
151
|
+
purposes,
|
|
152
|
+
assessedBy,
|
|
153
|
+
reviewDate: reviewDate ? new Date(reviewDate) : null,
|
|
154
|
+
}).returning();
|
|
155
|
+
|
|
156
|
+
await db.insert(complianceAuditLog).values({
|
|
157
|
+
module: 'lawful-basis',
|
|
158
|
+
action: 'created',
|
|
159
|
+
entityId: record.id,
|
|
160
|
+
entityType: 'LawfulBasisRecord',
|
|
161
|
+
changes: { activityName, lawfulBasis, assessedBy },
|
|
162
|
+
});
|
|
163
|
+
// {{/if}}
|
|
164
|
+
// {{#if ORM=none}}
|
|
165
|
+
const now = new Date();
|
|
166
|
+
const record: LawfulBasisRecord = {
|
|
167
|
+
id: newId(),
|
|
168
|
+
activityName,
|
|
169
|
+
lawfulBasis,
|
|
170
|
+
justification,
|
|
171
|
+
dataCategories,
|
|
172
|
+
purposes,
|
|
173
|
+
assessedBy,
|
|
174
|
+
reviewDate: reviewDate ? new Date(reviewDate) : null,
|
|
175
|
+
createdAt: now,
|
|
176
|
+
updatedAt: now,
|
|
177
|
+
};
|
|
178
|
+
lawfulBasisStore.set(record.id, record);
|
|
179
|
+
auditLog.push({ id: newId(), module: 'lawful-basis', action: 'created', entityId: record.id, at: new Date() });
|
|
180
|
+
// {{/if}}
|
|
181
|
+
|
|
182
|
+
return res.status(201).json(record);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// PUT /lawful-basis/:id
|
|
186
|
+
lawfulBasisRouter.put('/:id', async (req, res) => {
|
|
187
|
+
const { id } = req.params;
|
|
188
|
+
|
|
189
|
+
const data = { ...req.body };
|
|
190
|
+
if (data.reviewDate) {
|
|
191
|
+
data.reviewDate = new Date(data.reviewDate);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// {{#if ORM=prisma}}
|
|
195
|
+
const existing = await prisma.lawfulBasisRecord.findUnique({ where: { id } });
|
|
196
|
+
if (!existing) {
|
|
197
|
+
return res.status(404).json({ error: 'Lawful basis record not found' });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const record = await prisma.lawfulBasisRecord.update({
|
|
201
|
+
where: { id },
|
|
202
|
+
data,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
await prisma.complianceAuditLog.create({
|
|
206
|
+
data: {
|
|
207
|
+
module: 'lawful-basis',
|
|
208
|
+
action: 'updated',
|
|
209
|
+
entityId: record.id,
|
|
210
|
+
entityType: 'LawfulBasisRecord',
|
|
211
|
+
changes: data,
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
// {{/if}}
|
|
215
|
+
// {{#if ORM=drizzle}}
|
|
216
|
+
const [existing] = await db.select().from(lawfulBasisRecords).where(eq(lawfulBasisRecords.id, id)).limit(1);
|
|
217
|
+
if (!existing) {
|
|
218
|
+
return res.status(404).json({ error: 'Lawful basis record not found' });
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const [record] = await db.update(lawfulBasisRecords).set(data).where(eq(lawfulBasisRecords.id, id)).returning();
|
|
222
|
+
|
|
223
|
+
await db.insert(complianceAuditLog).values({
|
|
224
|
+
module: 'lawful-basis',
|
|
225
|
+
action: 'updated',
|
|
226
|
+
entityId: record.id,
|
|
227
|
+
entityType: 'LawfulBasisRecord',
|
|
228
|
+
changes: data,
|
|
229
|
+
});
|
|
230
|
+
// {{/if}}
|
|
231
|
+
// {{#if ORM=none}}
|
|
232
|
+
const existing = lawfulBasisStore.get(id);
|
|
233
|
+
if (!existing) {
|
|
234
|
+
return res.status(404).json({ error: 'Lawful basis record not found' });
|
|
235
|
+
}
|
|
236
|
+
const record: LawfulBasisRecord = { ...existing, ...data, id, updatedAt: new Date() };
|
|
237
|
+
lawfulBasisStore.set(id, record);
|
|
238
|
+
auditLog.push({ id: newId(), module: 'lawful-basis', action: 'updated', entityId: record.id, at: new Date() });
|
|
239
|
+
// {{/if}}
|
|
240
|
+
|
|
241
|
+
return res.json(record);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
// DELETE /lawful-basis/:id
|
|
245
|
+
lawfulBasisRouter.delete('/:id', async (req, res) => {
|
|
246
|
+
const { id } = req.params;
|
|
247
|
+
|
|
248
|
+
// {{#if ORM=prisma}}
|
|
249
|
+
const existing = await prisma.lawfulBasisRecord.findUnique({ where: { id } });
|
|
250
|
+
if (!existing) {
|
|
251
|
+
return res.status(404).json({ error: 'Lawful basis record not found' });
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
await prisma.lawfulBasisRecord.delete({ where: { id } });
|
|
255
|
+
|
|
256
|
+
await prisma.complianceAuditLog.create({
|
|
257
|
+
data: {
|
|
258
|
+
module: 'lawful-basis',
|
|
259
|
+
action: 'deleted',
|
|
260
|
+
entityId: id,
|
|
261
|
+
entityType: 'LawfulBasisRecord',
|
|
262
|
+
changes: { activityName: existing.activityName },
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
// {{/if}}
|
|
266
|
+
// {{#if ORM=drizzle}}
|
|
267
|
+
const [existing] = await db.select().from(lawfulBasisRecords).where(eq(lawfulBasisRecords.id, id)).limit(1);
|
|
268
|
+
if (!existing) {
|
|
269
|
+
return res.status(404).json({ error: 'Lawful basis record not found' });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
await db.delete(lawfulBasisRecords).where(eq(lawfulBasisRecords.id, id));
|
|
273
|
+
|
|
274
|
+
await db.insert(complianceAuditLog).values({
|
|
275
|
+
module: 'lawful-basis',
|
|
276
|
+
action: 'deleted',
|
|
277
|
+
entityId: id,
|
|
278
|
+
entityType: 'LawfulBasisRecord',
|
|
279
|
+
changes: { activityName: existing.activityName },
|
|
280
|
+
});
|
|
281
|
+
// {{/if}}
|
|
282
|
+
// {{#if ORM=none}}
|
|
283
|
+
const existing = lawfulBasisStore.get(id);
|
|
284
|
+
if (!existing) {
|
|
285
|
+
return res.status(404).json({ error: 'Lawful basis record not found' });
|
|
286
|
+
}
|
|
287
|
+
lawfulBasisStore.delete(id);
|
|
288
|
+
auditLog.push({ id: newId(), module: 'lawful-basis', action: 'deleted', entityId: id, at: new Date() });
|
|
289
|
+
// {{/if}}
|
|
290
|
+
|
|
291
|
+
return res.json({ success: true });
|
|
292
|
+
});
|
|
@@ -15,9 +15,12 @@
|
|
|
15
15
|
* app.use('/api/ndpr', createNDPRRouter());
|
|
16
16
|
*
|
|
17
17
|
* Routes exposed:
|
|
18
|
-
* GET/POST/DELETE /api/ndpr/consent
|
|
19
|
-
* GET/POST /api/ndpr/dsr
|
|
20
|
-
* GET/POST /api/ndpr/breach
|
|
18
|
+
* GET/POST/DELETE /api/ndpr/consent — NDPA §25–26
|
|
19
|
+
* GET/POST /api/ndpr/dsr — NDPA §34–38
|
|
20
|
+
* GET/POST /api/ndpr/breach — NDPA §40
|
|
21
|
+
* CRUD /api/ndpr/dpia — NDPA §34(3)
|
|
22
|
+
* CRUD /api/ndpr/lawful-basis — NDPA §25
|
|
23
|
+
* CRUD /api/ndpr/cross-border — NDPA §41–42
|
|
21
24
|
*/
|
|
22
25
|
|
|
23
26
|
import { Router } from 'express';
|
|
@@ -231,6 +234,259 @@ function breachRouter(): Router {
|
|
|
231
234
|
return router;
|
|
232
235
|
}
|
|
233
236
|
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// DPIA router — NDPA §34(3)
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
function dpiaRouter(): Router {
|
|
242
|
+
const router = Router();
|
|
243
|
+
|
|
244
|
+
// GET /dpia?status=draft
|
|
245
|
+
router.get('/', async (req, res) => {
|
|
246
|
+
const { status } = req.query;
|
|
247
|
+
const records = await prisma.dPIARecord.findMany({
|
|
248
|
+
where: status && typeof status === 'string' ? { status } : undefined,
|
|
249
|
+
orderBy: { createdAt: 'desc' },
|
|
250
|
+
});
|
|
251
|
+
return res.json(records);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// GET /dpia/:id
|
|
255
|
+
router.get('/:id', async (req, res) => {
|
|
256
|
+
const record = await prisma.dPIARecord.findUnique({ where: { id: req.params.id } });
|
|
257
|
+
if (!record) return res.status(404).json({ error: 'DPIA record not found' });
|
|
258
|
+
return res.json(record);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// POST /dpia
|
|
262
|
+
router.post('/', async (req, res) => {
|
|
263
|
+
const { projectName, description, dpiaData, overallRisk, score, conductedBy, approvedBy } = req.body;
|
|
264
|
+
if (!projectName || !description || !dpiaData || !overallRisk || score == null || !conductedBy) {
|
|
265
|
+
return res.status(400).json({
|
|
266
|
+
error: 'projectName, description, dpiaData, overallRisk, score, and conductedBy are required',
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
const record = await prisma.dPIARecord.create({
|
|
270
|
+
data: {
|
|
271
|
+
projectName, description, dpiaData, overallRisk, score,
|
|
272
|
+
status: 'draft', conductedBy, approvedBy: approvedBy ?? null,
|
|
273
|
+
},
|
|
274
|
+
});
|
|
275
|
+
await prisma.complianceAuditLog.create({
|
|
276
|
+
data: {
|
|
277
|
+
module: 'dpia', action: 'created', entityId: record.id,
|
|
278
|
+
entityType: 'DPIARecord', changes: { projectName, overallRisk, score, status: 'draft' },
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
return res.status(201).json(record);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// PUT /dpia/:id
|
|
285
|
+
router.put('/:id', async (req, res) => {
|
|
286
|
+
const { id } = req.params;
|
|
287
|
+
const existing = await prisma.dPIARecord.findUnique({ where: { id } });
|
|
288
|
+
if (!existing) return res.status(404).json({ error: 'DPIA record not found' });
|
|
289
|
+
const record = await prisma.dPIARecord.update({ where: { id }, data: req.body });
|
|
290
|
+
await prisma.complianceAuditLog.create({
|
|
291
|
+
data: { module: 'dpia', action: 'updated', entityId: record.id, entityType: 'DPIARecord', changes: req.body },
|
|
292
|
+
});
|
|
293
|
+
return res.json(record);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// DELETE /dpia/:id
|
|
297
|
+
router.delete('/:id', async (req, res) => {
|
|
298
|
+
const { id } = req.params;
|
|
299
|
+
const existing = await prisma.dPIARecord.findUnique({ where: { id } });
|
|
300
|
+
if (!existing) return res.status(404).json({ error: 'DPIA record not found' });
|
|
301
|
+
await prisma.dPIARecord.delete({ where: { id } });
|
|
302
|
+
await prisma.complianceAuditLog.create({
|
|
303
|
+
data: {
|
|
304
|
+
module: 'dpia', action: 'deleted', entityId: id,
|
|
305
|
+
entityType: 'DPIARecord', changes: { projectName: existing.projectName },
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
return res.json({ success: true });
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
return router;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// Lawful Basis router — NDPA §25
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
function lawfulBasisRouter(): Router {
|
|
319
|
+
const router = Router();
|
|
320
|
+
|
|
321
|
+
// GET /lawful-basis?lawfulBasis=consent
|
|
322
|
+
router.get('/', async (req, res) => {
|
|
323
|
+
const { lawfulBasis } = req.query;
|
|
324
|
+
const records = await prisma.lawfulBasisRecord.findMany({
|
|
325
|
+
where: lawfulBasis && typeof lawfulBasis === 'string' ? { lawfulBasis } : undefined,
|
|
326
|
+
orderBy: { createdAt: 'desc' },
|
|
327
|
+
});
|
|
328
|
+
return res.json(records);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// GET /lawful-basis/:id
|
|
332
|
+
router.get('/:id', async (req, res) => {
|
|
333
|
+
const record = await prisma.lawfulBasisRecord.findUnique({ where: { id: req.params.id } });
|
|
334
|
+
if (!record) return res.status(404).json({ error: 'Lawful basis record not found' });
|
|
335
|
+
return res.json(record);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// POST /lawful-basis
|
|
339
|
+
router.post('/', async (req, res) => {
|
|
340
|
+
const { activityName, lawfulBasis, justification, dataCategories, purposes, assessedBy, reviewDate } = req.body;
|
|
341
|
+
if (!activityName || !lawfulBasis || !justification || !dataCategories || !purposes || !assessedBy) {
|
|
342
|
+
return res.status(400).json({
|
|
343
|
+
error: 'activityName, lawfulBasis, justification, dataCategories, purposes, and assessedBy are required',
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
if (!Array.isArray(dataCategories) || !Array.isArray(purposes)) {
|
|
347
|
+
return res.status(400).json({ error: 'dataCategories and purposes must be arrays' });
|
|
348
|
+
}
|
|
349
|
+
const record = await prisma.lawfulBasisRecord.create({
|
|
350
|
+
data: {
|
|
351
|
+
activityName, lawfulBasis, justification, dataCategories, purposes,
|
|
352
|
+
assessedBy, reviewDate: reviewDate ? new Date(reviewDate) : null,
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
await prisma.complianceAuditLog.create({
|
|
356
|
+
data: {
|
|
357
|
+
module: 'lawful-basis', action: 'created', entityId: record.id,
|
|
358
|
+
entityType: 'LawfulBasisRecord', changes: { activityName, lawfulBasis, assessedBy },
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
return res.status(201).json(record);
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
// PUT /lawful-basis/:id
|
|
365
|
+
router.put('/:id', async (req, res) => {
|
|
366
|
+
const { id } = req.params;
|
|
367
|
+
const existing = await prisma.lawfulBasisRecord.findUnique({ where: { id } });
|
|
368
|
+
if (!existing) return res.status(404).json({ error: 'Lawful basis record not found' });
|
|
369
|
+
const data = { ...req.body };
|
|
370
|
+
if (data.reviewDate) data.reviewDate = new Date(data.reviewDate);
|
|
371
|
+
const record = await prisma.lawfulBasisRecord.update({ where: { id }, data });
|
|
372
|
+
await prisma.complianceAuditLog.create({
|
|
373
|
+
data: {
|
|
374
|
+
module: 'lawful-basis', action: 'updated', entityId: record.id,
|
|
375
|
+
entityType: 'LawfulBasisRecord', changes: data,
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
return res.json(record);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// DELETE /lawful-basis/:id
|
|
382
|
+
router.delete('/:id', async (req, res) => {
|
|
383
|
+
const { id } = req.params;
|
|
384
|
+
const existing = await prisma.lawfulBasisRecord.findUnique({ where: { id } });
|
|
385
|
+
if (!existing) return res.status(404).json({ error: 'Lawful basis record not found' });
|
|
386
|
+
await prisma.lawfulBasisRecord.delete({ where: { id } });
|
|
387
|
+
await prisma.complianceAuditLog.create({
|
|
388
|
+
data: {
|
|
389
|
+
module: 'lawful-basis', action: 'deleted', entityId: id,
|
|
390
|
+
entityType: 'LawfulBasisRecord', changes: { activityName: existing.activityName },
|
|
391
|
+
},
|
|
392
|
+
});
|
|
393
|
+
return res.json({ success: true });
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
return router;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
// Cross-Border Transfer router — NDPA §41–42
|
|
401
|
+
// ---------------------------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
function crossBorderRouter(): Router {
|
|
404
|
+
const router = Router();
|
|
405
|
+
|
|
406
|
+
// GET /cross-border?riskLevel=high&destinationCountry=US
|
|
407
|
+
router.get('/', async (req, res) => {
|
|
408
|
+
const { riskLevel, destinationCountry } = req.query;
|
|
409
|
+
const where: Record<string, string> = {};
|
|
410
|
+
if (riskLevel && typeof riskLevel === 'string') where.riskLevel = riskLevel;
|
|
411
|
+
if (destinationCountry && typeof destinationCountry === 'string') where.destinationCountry = destinationCountry;
|
|
412
|
+
const records = await prisma.crossBorderTransferRecord.findMany({
|
|
413
|
+
where: Object.keys(where).length > 0 ? where : undefined,
|
|
414
|
+
orderBy: { createdAt: 'desc' },
|
|
415
|
+
});
|
|
416
|
+
return res.json(records);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// GET /cross-border/:id
|
|
420
|
+
router.get('/:id', async (req, res) => {
|
|
421
|
+
const record = await prisma.crossBorderTransferRecord.findUnique({ where: { id: req.params.id } });
|
|
422
|
+
if (!record) return res.status(404).json({ error: 'Cross-border transfer record not found' });
|
|
423
|
+
return res.json(record);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
// POST /cross-border
|
|
427
|
+
router.post('/', async (req, res) => {
|
|
428
|
+
const {
|
|
429
|
+
destinationCountry, recipientName, transferMechanism, safeguards,
|
|
430
|
+
dataCategories, adequacyStatus, ndpcApprovalRequired, ndpcApprovalReference, riskLevel,
|
|
431
|
+
} = req.body;
|
|
432
|
+
if (!destinationCountry || !recipientName || !transferMechanism || !safeguards || !dataCategories || !adequacyStatus || !riskLevel) {
|
|
433
|
+
return res.status(400).json({
|
|
434
|
+
error: 'destinationCountry, recipientName, transferMechanism, safeguards, dataCategories, adequacyStatus, and riskLevel are required',
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
if (!Array.isArray(dataCategories)) {
|
|
438
|
+
return res.status(400).json({ error: 'dataCategories must be an array' });
|
|
439
|
+
}
|
|
440
|
+
const record = await prisma.crossBorderTransferRecord.create({
|
|
441
|
+
data: {
|
|
442
|
+
destinationCountry, recipientName, transferMechanism, safeguards, dataCategories,
|
|
443
|
+
adequacyStatus, ndpcApprovalRequired: ndpcApprovalRequired ?? false,
|
|
444
|
+
ndpcApprovalReference: ndpcApprovalReference ?? null, riskLevel,
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
await prisma.complianceAuditLog.create({
|
|
448
|
+
data: {
|
|
449
|
+
module: 'cross-border', action: 'created', entityId: record.id,
|
|
450
|
+
entityType: 'CrossBorderTransferRecord', changes: { destinationCountry, recipientName, riskLevel },
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
return res.status(201).json(record);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// PUT /cross-border/:id
|
|
457
|
+
router.put('/:id', async (req, res) => {
|
|
458
|
+
const { id } = req.params;
|
|
459
|
+
const existing = await prisma.crossBorderTransferRecord.findUnique({ where: { id } });
|
|
460
|
+
if (!existing) return res.status(404).json({ error: 'Cross-border transfer record not found' });
|
|
461
|
+
const record = await prisma.crossBorderTransferRecord.update({ where: { id }, data: req.body });
|
|
462
|
+
await prisma.complianceAuditLog.create({
|
|
463
|
+
data: {
|
|
464
|
+
module: 'cross-border', action: 'updated', entityId: record.id,
|
|
465
|
+
entityType: 'CrossBorderTransferRecord', changes: req.body,
|
|
466
|
+
},
|
|
467
|
+
});
|
|
468
|
+
return res.json(record);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// DELETE /cross-border/:id
|
|
472
|
+
router.delete('/:id', async (req, res) => {
|
|
473
|
+
const { id } = req.params;
|
|
474
|
+
const existing = await prisma.crossBorderTransferRecord.findUnique({ where: { id } });
|
|
475
|
+
if (!existing) return res.status(404).json({ error: 'Cross-border transfer record not found' });
|
|
476
|
+
await prisma.crossBorderTransferRecord.delete({ where: { id } });
|
|
477
|
+
await prisma.complianceAuditLog.create({
|
|
478
|
+
data: {
|
|
479
|
+
module: 'cross-border', action: 'deleted', entityId: id,
|
|
480
|
+
entityType: 'CrossBorderTransferRecord',
|
|
481
|
+
changes: { destinationCountry: existing.destinationCountry, recipientName: existing.recipientName },
|
|
482
|
+
},
|
|
483
|
+
});
|
|
484
|
+
return res.json({ success: true });
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
return router;
|
|
488
|
+
}
|
|
489
|
+
|
|
234
490
|
// ---------------------------------------------------------------------------
|
|
235
491
|
// Main factory
|
|
236
492
|
// ---------------------------------------------------------------------------
|
|
@@ -246,5 +502,8 @@ export function createNDPRRouter(): Router {
|
|
|
246
502
|
router.use('/consent', consentRouter());
|
|
247
503
|
router.use('/dsr', dsrRouter());
|
|
248
504
|
router.use('/breach', breachRouter());
|
|
505
|
+
router.use('/dpia', dpiaRouter());
|
|
506
|
+
router.use('/lawful-basis', lawfulBasisRouter());
|
|
507
|
+
router.use('/cross-border', crossBorderRouter());
|
|
249
508
|
return router;
|
|
250
509
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# NDPA 2023 / NDPC GAID 2025 compliance gate — generated by create-ndpr for {{ORG_NAME}}.
|
|
2
|
+
#
|
|
3
|
+
# Runs `ndpr audit` on every push and pull request. The audit scores your
|
|
4
|
+
# compliance config (consent, DSR, DPIA, breach, policy, lawful basis,
|
|
5
|
+
# cross-border, RoPA) plus your GAID 2025 DCPMI registration tier, and exits
|
|
6
|
+
# non-zero when the overall score drops below the threshold — so a regression
|
|
7
|
+
# fails CI the same way a broken test would.
|
|
8
|
+
#
|
|
9
|
+
# Edit ndpr.audit.json to reflect your real posture, then raise --min-score
|
|
10
|
+
# over time as you close gaps. Docs: https://ndprtoolkit.com.ng/docs/guides/audit-cli
|
|
11
|
+
name: NDPA compliance audit
|
|
12
|
+
|
|
13
|
+
on:
|
|
14
|
+
push:
|
|
15
|
+
branches: [main]
|
|
16
|
+
pull_request:
|
|
17
|
+
|
|
18
|
+
jobs:
|
|
19
|
+
ndpr-audit:
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@v4
|
|
23
|
+
- uses: actions/setup-node@v4
|
|
24
|
+
with:
|
|
25
|
+
node-version: 20
|
|
26
|
+
# The `ndpr` CLI ships with the toolkit. --no-save keeps it out of your
|
|
27
|
+
# package.json if you don't already depend on it directly.
|
|
28
|
+
- name: Install the NDPR toolkit (provides the `ndpr` CLI)
|
|
29
|
+
run: npm install --no-save @tantainnovative/ndpr-toolkit@^5.5.1
|
|
30
|
+
- name: Run the NDPA compliance audit
|
|
31
|
+
run: npx ndpr audit --min-score 70
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"minScore": 70,
|
|
3
|
+
"compliance": {
|
|
4
|
+
"consent": { "hasConsentMechanism": true, "hasPurposeSpecification": true, "hasWithdrawalMechanism": true, "hasMinorProtection": false, "consentRecordsRetained": true },
|
|
5
|
+
"dsr": { "hasRequestMechanism": true, "supportsAccess": true, "supportsRectification": true, "supportsErasure": false, "supportsPortability": false, "supportsObjection": false, "responseTimelineDays": 30 },
|
|
6
|
+
"dpia": { "conductedForHighRisk": true, "documentedRisks": true, "mitigationMeasures": true },
|
|
7
|
+
"breach": { "hasNotificationProcess": true, "notifiesWithin72Hours": true, "hasRiskAssessment": true, "hasRecordKeeping": true },
|
|
8
|
+
"policy": { "hasPrivacyPolicy": true, "isPubliclyAccessible": true, "lastUpdated": "2026-01-01", "coversAllSections": true },
|
|
9
|
+
"lawfulBasis": { "documentedForAllProcessing": true, "hasLegitimateInterestAssessment": false },
|
|
10
|
+
"crossBorder": { "hasTransferMechanisms": true, "adequacyAssessed": true, "ndpcApprovalObtained": false },
|
|
11
|
+
"ropa": { "maintained": true, "includesAllProcessing": true, "lastReviewed": "2026-01-01" }
|
|
12
|
+
},
|
|
13
|
+
"dcpmi": { "dataSubjectsInSixMonths": 0 }
|
|
14
|
+
}
|