@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
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# create-ndpr
|
|
2
2
|
|
|
3
|
-
CLI scaffolder for [NDPA](https://
|
|
3
|
+
CLI scaffolder for Nigeria [NDPA 2023](https://ndpc.gov.ng/) / NDPC GAID 2025 compliance using [`@tantainnovative/ndpr-toolkit`](https://github.com/mr-tanta/ndpr-toolkit).
|
|
4
4
|
|
|
5
5
|
## Usage
|
|
6
6
|
|
|
@@ -43,11 +43,18 @@ Run this from the root of an existing project. The CLI detects your stack and ge
|
|
|
43
43
|
| `pages/api/consent.ts` | Next.js Pages Router + consent |
|
|
44
44
|
| `pages/api/dsr.ts` | Next.js Pages Router + dsr |
|
|
45
45
|
| `pages/api/breach.ts` | Next.js Pages Router + breach |
|
|
46
|
-
| `src/ndpr/index.ts` | Express |
|
|
46
|
+
| `src/ndpr/index.ts` | Express (Prisma only) |
|
|
47
47
|
| `src/ndpr/routes/consent.ts` | Express + consent module |
|
|
48
|
+
| `ndpr.audit.json` | Always — config for the `ndpr audit` compliance gate |
|
|
49
|
+
| `.github/workflows/ndpr-audit.yml` | Always — CI workflow that fails on a compliance regression |
|
|
48
50
|
|
|
49
51
|
All generated files use `{{ORG_NAME}}` and `{{DPO_EMAIL}}` substituted with your answers.
|
|
50
52
|
|
|
53
|
+
The generated breach route (`app/api/breach/route.ts`) returns an `ndpcReadiness`
|
|
54
|
+
summary on every report — which GAID 2025 Article 33(5) notification fields are
|
|
55
|
+
still missing, and how many hours remain on the 72-hour clock — so you know what
|
|
56
|
+
to collect before filing with the NDPC.
|
|
57
|
+
|
|
51
58
|
## Modules
|
|
52
59
|
|
|
53
60
|
| Module | NDPA reference | Description |
|
|
@@ -118,6 +125,22 @@ app.use(express.json());
|
|
|
118
125
|
app.use('/api/ndpr', createNDPRRouter());
|
|
119
126
|
```
|
|
120
127
|
|
|
128
|
+
### Compliance as code (GAID 2025)
|
|
129
|
+
|
|
130
|
+
Every scaffold ships an `ndpr.audit.json` config and a GitHub Actions workflow
|
|
131
|
+
that runs the toolkit's `ndpr audit` CLI. The audit scores your compliance
|
|
132
|
+
posture (consent, DSR, DPIA, breach, policy, lawful basis, cross-border, RoPA)
|
|
133
|
+
plus your GAID 2025 DCPMI registration tier, and **exits non-zero when the score
|
|
134
|
+
drops below the threshold** — so a regression fails CI like a broken test.
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
# Run it locally any time:
|
|
138
|
+
npx ndpr audit --min-score 70
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Edit `ndpr.audit.json` to reflect your real posture, then raise `--min-score` as
|
|
142
|
+
you close gaps. See the [audit CLI guide](https://ndprtoolkit.com.ng/docs/guides/audit-cli).
|
|
143
|
+
|
|
121
144
|
## Requirements
|
|
122
145
|
|
|
123
146
|
- Node.js 18+
|
|
@@ -125,6 +148,6 @@ app.use('/api/ndpr', createNDPRRouter());
|
|
|
125
148
|
|
|
126
149
|
## Links
|
|
127
150
|
|
|
128
|
-
- Toolkit docs: https://
|
|
129
|
-
- GitHub: https://github.com/
|
|
130
|
-
- NDPC: https://
|
|
151
|
+
- Toolkit docs: https://ndprtoolkit.com.ng
|
|
152
|
+
- GitHub: https://github.com/mr-tanta/ndpr-toolkit
|
|
153
|
+
- NDPC: https://ndpc.gov.ng
|
package/bin/index.mjs
CHANGED
|
@@ -196,12 +196,49 @@ async function askYesNo(rl, question, defaultYes = true) {
|
|
|
196
196
|
// Template rendering
|
|
197
197
|
// ---------------------------------------------------------------------------
|
|
198
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Render a template with `{{KEY}}` variable substitution and minimal
|
|
201
|
+
* conditional-block support.
|
|
202
|
+
*
|
|
203
|
+
* Conditional syntax:
|
|
204
|
+
*
|
|
205
|
+
* // {{#if ORM=prisma}}
|
|
206
|
+
* import { PrismaClient } from '@prisma/client';
|
|
207
|
+
* // {{/if}}
|
|
208
|
+
*
|
|
209
|
+
* // {{#if ORM=drizzle}}
|
|
210
|
+
* import { db } from '@/drizzle';
|
|
211
|
+
* // {{/if}}
|
|
212
|
+
*
|
|
213
|
+
* // {{#if ORM=none}}
|
|
214
|
+
* // TODO: wire to your storage of choice
|
|
215
|
+
* const store = new Map();
|
|
216
|
+
* // {{/if}}
|
|
217
|
+
*
|
|
218
|
+
* The block is kept when `vars[KEY] === value` (e.g. `vars.ORM === 'prisma'`),
|
|
219
|
+
* otherwise the block and its markers are stripped. Marker comments (with
|
|
220
|
+
* optional leading `//` and any trailing whitespace) are always removed.
|
|
221
|
+
*/
|
|
199
222
|
function renderTemplate(templateName, vars) {
|
|
200
223
|
const templatePath = join(TEMPLATES_DIR, templateName);
|
|
201
224
|
if (!existsSync(templatePath)) {
|
|
202
225
|
throw new Error(`Template not found: ${templateName}`);
|
|
203
226
|
}
|
|
204
227
|
let content = readFileSync(templatePath, 'utf8');
|
|
228
|
+
|
|
229
|
+
// Conditional blocks first (so variable substitution doesn't munge the
|
|
230
|
+
// marker syntax). Pattern allows an optional `// ` prefix on the markers
|
|
231
|
+
// so templates can comment them out and remain valid TypeScript.
|
|
232
|
+
const ifPattern =
|
|
233
|
+
/(?:[ \t]*\/\/[ \t]*)?\{\{#if[ \t]+([A-Z_]+)=([a-zA-Z0-9_-]+)\}\}\s*([\s\S]*?)(?:[ \t]*\/\/[ \t]*)?\{\{\/if\}\}[ \t]*\n?/g;
|
|
234
|
+
content = content.replace(ifPattern, (_match, key, expected, body) => {
|
|
235
|
+
if (vars[key] === expected) {
|
|
236
|
+
// Keep the body; strip a trailing newline if it leaves a blank line
|
|
237
|
+
return body.endsWith('\n') ? body : body + '\n';
|
|
238
|
+
}
|
|
239
|
+
return '';
|
|
240
|
+
});
|
|
241
|
+
|
|
205
242
|
for (const [key, value] of Object.entries(vars)) {
|
|
206
243
|
content = content.replaceAll(`{{${key}}}`, value);
|
|
207
244
|
}
|
|
@@ -379,6 +416,10 @@ async function main() {
|
|
|
379
416
|
const vars = {
|
|
380
417
|
ORG_NAME: orgName,
|
|
381
418
|
DPO_EMAIL: dpoEmail,
|
|
419
|
+
// Drives `{{#if ORM=prisma}}` / `{{#if ORM=drizzle}}` / `{{#if ORM=none}}`
|
|
420
|
+
// conditional blocks in the route templates.
|
|
421
|
+
ORM: orm,
|
|
422
|
+
FRAMEWORK: framework,
|
|
382
423
|
};
|
|
383
424
|
|
|
384
425
|
// .env.example
|
|
@@ -400,6 +441,40 @@ async function main() {
|
|
|
400
441
|
}
|
|
401
442
|
}
|
|
402
443
|
|
|
444
|
+
// Routes that already support all 3 ORMs via per-{{#if ORM=...}} blocks.
|
|
445
|
+
// Add a template to this set as it gets ORM-aware. Other route templates
|
|
446
|
+
// still hard-code Prisma, so we skip them when orm === 'none' to avoid
|
|
447
|
+
// emitting code with a broken `@prisma/client` import.
|
|
448
|
+
//
|
|
449
|
+
// All Next.js and Express route templates went ORM-aware in 3.6.2.
|
|
450
|
+
// The express-setup.ts wrapper still assumes Prisma — see the
|
|
451
|
+
// separate skip handling below.
|
|
452
|
+
const ORM_AWARE_TEMPLATES = new Set([
|
|
453
|
+
'nextjs-consent-route.ts',
|
|
454
|
+
'nextjs-dsr-route.ts',
|
|
455
|
+
'nextjs-breach-route.ts',
|
|
456
|
+
'nextjs-dpia-route.ts',
|
|
457
|
+
'nextjs-lawful-basis-route.ts',
|
|
458
|
+
'nextjs-cross-border-route.ts',
|
|
459
|
+
'express-consent-route.ts',
|
|
460
|
+
'express-dpia-route.ts',
|
|
461
|
+
'express-lawful-basis-route.ts',
|
|
462
|
+
'express-cross-border-route.ts',
|
|
463
|
+
]);
|
|
464
|
+
const generateRoute = (dest, templateName) => {
|
|
465
|
+
if (orm === 'none' && !ORM_AWARE_TEMPLATES.has(templateName)) {
|
|
466
|
+
skip(
|
|
467
|
+
dest,
|
|
468
|
+
`template hardcodes Prisma; skipping because you chose ORM=none. ` +
|
|
469
|
+
`Re-run create-ndpr with ORM=prisma|drizzle or copy the template from ` +
|
|
470
|
+
`https://github.com/mr-tanta/ndpr-toolkit/tree/main/packages/create-ndpr/templates/${templateName} ` +
|
|
471
|
+
`and adapt it to your storage layer.`,
|
|
472
|
+
);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
generate(dest, templateName, vars);
|
|
476
|
+
};
|
|
477
|
+
|
|
403
478
|
// Framework-specific files
|
|
404
479
|
if (framework === 'nextjs-app') {
|
|
405
480
|
const appDir = existsSync(join(CWD, 'src', 'app')) ? 'src/app' : 'app';
|
|
@@ -409,32 +484,86 @@ async function main() {
|
|
|
409
484
|
|
|
410
485
|
// API routes per selected module
|
|
411
486
|
if (selectedModules.includes('consent')) {
|
|
412
|
-
|
|
487
|
+
generateRoute(`${appDir}/api/consent/route.ts`, 'nextjs-consent-route.ts');
|
|
413
488
|
}
|
|
414
489
|
if (selectedModules.includes('dsr')) {
|
|
415
|
-
|
|
490
|
+
generateRoute(`${appDir}/api/dsr/route.ts`, 'nextjs-dsr-route.ts');
|
|
416
491
|
}
|
|
417
492
|
if (selectedModules.includes('breach')) {
|
|
418
|
-
|
|
493
|
+
generateRoute(`${appDir}/api/breach/route.ts`, 'nextjs-breach-route.ts');
|
|
494
|
+
}
|
|
495
|
+
if (selectedModules.includes('dpia')) {
|
|
496
|
+
generateRoute(`${appDir}/api/dpia/route.ts`, 'nextjs-dpia-route.ts');
|
|
497
|
+
}
|
|
498
|
+
if (selectedModules.includes('lawful-basis')) {
|
|
499
|
+
generateRoute(`${appDir}/api/lawful-basis/route.ts`, 'nextjs-lawful-basis-route.ts');
|
|
500
|
+
}
|
|
501
|
+
if (selectedModules.includes('cross-border')) {
|
|
502
|
+
generateRoute(`${appDir}/api/cross-border/route.ts`, 'nextjs-cross-border-route.ts');
|
|
419
503
|
}
|
|
420
504
|
} else if (framework === 'nextjs-pages') {
|
|
421
505
|
console.log(yellow(' Note: Pages Router API routes generated under pages/api/'));
|
|
422
506
|
if (selectedModules.includes('consent')) {
|
|
423
|
-
|
|
507
|
+
generateRoute('pages/api/consent.ts', 'nextjs-consent-route.ts');
|
|
424
508
|
}
|
|
425
509
|
if (selectedModules.includes('dsr')) {
|
|
426
|
-
|
|
510
|
+
generateRoute('pages/api/dsr.ts', 'nextjs-dsr-route.ts');
|
|
427
511
|
}
|
|
428
512
|
if (selectedModules.includes('breach')) {
|
|
429
|
-
|
|
513
|
+
generateRoute('pages/api/breach.ts', 'nextjs-breach-route.ts');
|
|
514
|
+
}
|
|
515
|
+
if (selectedModules.includes('dpia')) {
|
|
516
|
+
generateRoute('pages/api/dpia.ts', 'nextjs-dpia-route.ts');
|
|
517
|
+
}
|
|
518
|
+
if (selectedModules.includes('lawful-basis')) {
|
|
519
|
+
generateRoute('pages/api/lawful-basis.ts', 'nextjs-lawful-basis-route.ts');
|
|
520
|
+
}
|
|
521
|
+
if (selectedModules.includes('cross-border')) {
|
|
522
|
+
generateRoute('pages/api/cross-border.ts', 'nextjs-cross-border-route.ts');
|
|
430
523
|
}
|
|
431
524
|
} else if (framework === 'express') {
|
|
432
|
-
|
|
525
|
+
// express-setup.ts wires up a PrismaClient directly and is not yet
|
|
526
|
+
// ORM-aware, so it only produces working code for Prisma. Skip it (with
|
|
527
|
+
// a pointer) for Drizzle and None rather than emit a broken import.
|
|
528
|
+
if (orm === 'prisma') {
|
|
529
|
+
generate('src/ndpr/index.ts', 'express-setup.ts', vars);
|
|
530
|
+
} else {
|
|
531
|
+
skip(
|
|
532
|
+
'src/ndpr/index.ts',
|
|
533
|
+
`Express setup template currently assumes Prisma; skipping for ORM=${orm}. ` +
|
|
534
|
+
'See https://github.com/mr-tanta/ndpr-toolkit/tree/main/packages/create-ndpr/templates/express-setup.ts',
|
|
535
|
+
);
|
|
536
|
+
}
|
|
433
537
|
if (selectedModules.includes('consent')) {
|
|
434
|
-
|
|
538
|
+
generateRoute('src/ndpr/routes/consent.ts', 'express-consent-route.ts');
|
|
539
|
+
}
|
|
540
|
+
if (selectedModules.includes('dpia')) {
|
|
541
|
+
generateRoute('src/ndpr/routes/dpia.ts', 'express-dpia-route.ts');
|
|
542
|
+
}
|
|
543
|
+
if (selectedModules.includes('lawful-basis')) {
|
|
544
|
+
generateRoute('src/ndpr/routes/lawful-basis.ts', 'express-lawful-basis-route.ts');
|
|
545
|
+
}
|
|
546
|
+
if (selectedModules.includes('cross-border')) {
|
|
547
|
+
generateRoute('src/ndpr/routes/cross-border.ts', 'express-cross-border-route.ts');
|
|
435
548
|
}
|
|
436
549
|
}
|
|
437
550
|
|
|
551
|
+
// -------------------------------------------------------------------------
|
|
552
|
+
// Compliance-as-code: ndpr audit config + CI gate (GAID 2025)
|
|
553
|
+
// -------------------------------------------------------------------------
|
|
554
|
+
// Always scaffolded, regardless of framework/ORM — the audit runs against a
|
|
555
|
+
// declarative config, not your database, so it works for every project.
|
|
556
|
+
if (existsSync(join(CWD, 'ndpr.audit.json'))) {
|
|
557
|
+
skip('ndpr.audit.json', 'already exists — keep your edits');
|
|
558
|
+
} else {
|
|
559
|
+
generateRaw('ndpr.audit.json', readFileSync(join(TEMPLATES_DIR, 'ndpr-audit.json'), 'utf8'));
|
|
560
|
+
}
|
|
561
|
+
if (existsSync(join(CWD, '.github', 'workflows', 'ndpr-audit.yml'))) {
|
|
562
|
+
skip('.github/workflows/ndpr-audit.yml', 'already exists');
|
|
563
|
+
} else {
|
|
564
|
+
generate('.github/workflows/ndpr-audit.yml', 'github-ndpr-audit.yml', vars);
|
|
565
|
+
}
|
|
566
|
+
|
|
438
567
|
// -------------------------------------------------------------------------
|
|
439
568
|
// Summary
|
|
440
569
|
// -------------------------------------------------------------------------
|
|
@@ -471,7 +600,7 @@ async function main() {
|
|
|
471
600
|
}
|
|
472
601
|
|
|
473
602
|
console.log(` ${cyan(`${orm === 'none' ? '1' : '3'}`.padStart(1))}. Install the ndpr-toolkit:`);
|
|
474
|
-
console.log(dim(' pnpm add @tantainnovative/ndpr-toolkit'));
|
|
603
|
+
console.log(dim(' pnpm add @tantainnovative/ndpr-toolkit@^5.5.1'));
|
|
475
604
|
console.log();
|
|
476
605
|
|
|
477
606
|
if (framework === 'nextjs-app' || framework === 'nextjs-pages') {
|
|
@@ -488,8 +617,14 @@ async function main() {
|
|
|
488
617
|
console.log();
|
|
489
618
|
}
|
|
490
619
|
|
|
491
|
-
|
|
492
|
-
console.log(
|
|
620
|
+
const auditStep = framework === 'none' ? (orm === 'none' ? 3 : 5) : orm === 'none' ? 3 : 5;
|
|
621
|
+
console.log(` ${cyan(`${auditStep}`)}. Keep compliance from regressing — edit ${bold('ndpr.audit.json')} to match`);
|
|
622
|
+
console.log(dim(' your real posture, then run the audit (also wired into CI for you):'));
|
|
623
|
+
console.log(dim(' npx ndpr audit --min-score 70'));
|
|
624
|
+
console.log();
|
|
625
|
+
|
|
626
|
+
console.log(dim(' Documentation: https://ndprtoolkit.com.ng'));
|
|
627
|
+
console.log(dim(' Repository: https://github.com/mr-tanta/ndpr-toolkit'));
|
|
493
628
|
console.log();
|
|
494
629
|
|
|
495
630
|
} finally {
|
package/package.json
CHANGED
|
@@ -1,22 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tantainnovative/create-ndpr",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "CLI scaffolder for NDPA compliance with @tantainnovative/ndpr-toolkit",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "CLI scaffolder for Nigeria NDPA 2023 / NDPC GAID 2025 compliance with @tantainnovative/ndpr-toolkit",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
7
7
|
"name": "Abraham Esandayinze Tanta",
|
|
8
8
|
"url": "https://linkedin.com/in/mr-tanta"
|
|
9
9
|
},
|
|
10
|
+
"homepage": "https://ndprtoolkit.com.ng",
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/mr-tanta/ndpr-toolkit.git",
|
|
14
|
+
"directory": "packages/create-ndpr"
|
|
15
|
+
},
|
|
10
16
|
"bin": {
|
|
11
|
-
"create-ndpr": "
|
|
17
|
+
"create-ndpr": "bin/index.mjs"
|
|
12
18
|
},
|
|
13
19
|
"files": [
|
|
14
20
|
"bin/",
|
|
15
|
-
"templates/"
|
|
21
|
+
"templates/",
|
|
22
|
+
"README.md"
|
|
16
23
|
],
|
|
17
24
|
"keywords": [
|
|
18
25
|
"ndpa",
|
|
19
26
|
"ndpr",
|
|
27
|
+
"gaid-2025",
|
|
28
|
+
"dcpmi",
|
|
29
|
+
"compliance-audit-return",
|
|
20
30
|
"cli",
|
|
21
31
|
"scaffold",
|
|
22
32
|
"nigeria",
|
|
@@ -4,11 +4,14 @@
|
|
|
4
4
|
* DPO: {{DPO_EMAIL}}
|
|
5
5
|
*
|
|
6
6
|
* Covers:
|
|
7
|
-
* - consentRecords
|
|
8
|
-
* - dsrRequests
|
|
9
|
-
* - breachReports
|
|
10
|
-
* - processingRecords
|
|
11
|
-
* -
|
|
7
|
+
* - consentRecords — NDPA §25–26 (consent & withdrawal)
|
|
8
|
+
* - dsrRequests — NDPA Part IV §34–38 (data subject rights)
|
|
9
|
+
* - breachReports — NDPA §40 (72-hour breach notification)
|
|
10
|
+
* - processingRecords — ROPA (accountability principle)
|
|
11
|
+
* - dpiaRecords — NDPA §34(3) (impact assessments)
|
|
12
|
+
* - lawfulBasisRecords — NDPA §25 (lawful basis register)
|
|
13
|
+
* - crossBorderTransferRecords — NDPA §41–42 (cross-border transfers)
|
|
14
|
+
* - auditLog — §44 audit trail
|
|
12
15
|
*
|
|
13
16
|
* Prerequisites: drizzle-orm, @paralleldrive/cuid2
|
|
14
17
|
* Run `drizzle-kit push` to apply.
|
|
@@ -128,6 +131,82 @@ export const processingRecords = pgTable('ndpr_processing_records', {
|
|
|
128
131
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
129
132
|
});
|
|
130
133
|
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// ndpr_dpia_records — NDPA §34(3)
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
export const dpiaRecords = pgTable(
|
|
139
|
+
'ndpr_dpia_records',
|
|
140
|
+
{
|
|
141
|
+
id: text('id').primaryKey().$defaultFn(() => createId()),
|
|
142
|
+
projectName: text('project_name').notNull(),
|
|
143
|
+
description: text('description').notNull(),
|
|
144
|
+
dpiaData: json('dpia_data').notNull(),
|
|
145
|
+
overallRisk: text('overall_risk').notNull(),
|
|
146
|
+
score: integer('score').notNull(),
|
|
147
|
+
status: text('status').notNull().default('draft'),
|
|
148
|
+
conductedBy: text('conducted_by').notNull(),
|
|
149
|
+
approvedBy: text('approved_by'),
|
|
150
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
151
|
+
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
152
|
+
},
|
|
153
|
+
(t) => ({
|
|
154
|
+
statusIdx: index('dpia_status_idx').on(t.status),
|
|
155
|
+
conductedByIdx: index('dpia_conducted_by_idx').on(t.conductedBy),
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// ndpr_lawful_basis_records — NDPA §25
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
export const lawfulBasisRecords = pgTable(
|
|
164
|
+
'ndpr_lawful_basis_records',
|
|
165
|
+
{
|
|
166
|
+
id: text('id').primaryKey().$defaultFn(() => createId()),
|
|
167
|
+
activityName: text('activity_name').notNull(),
|
|
168
|
+
lawfulBasis: text('lawful_basis').notNull(),
|
|
169
|
+
justification: text('justification').notNull(),
|
|
170
|
+
dataCategories: json('data_categories').notNull(),
|
|
171
|
+
purposes: json('purposes').notNull(),
|
|
172
|
+
assessedBy: text('assessed_by').notNull(),
|
|
173
|
+
assessedAt: timestamp('assessed_at').defaultNow().notNull(),
|
|
174
|
+
reviewDate: timestamp('review_date'),
|
|
175
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
176
|
+
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
177
|
+
},
|
|
178
|
+
(t) => ({
|
|
179
|
+
lawfulBasisIdx: index('lawful_basis_idx').on(t.lawfulBasis),
|
|
180
|
+
assessedByIdx: index('lawful_basis_assessed_by_idx').on(t.assessedBy),
|
|
181
|
+
}),
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// ndpr_cross_border_transfer_records — NDPA §41–42
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
export const crossBorderTransferRecords = pgTable(
|
|
189
|
+
'ndpr_cross_border_transfer_records',
|
|
190
|
+
{
|
|
191
|
+
id: text('id').primaryKey().$defaultFn(() => createId()),
|
|
192
|
+
destinationCountry: text('destination_country').notNull(),
|
|
193
|
+
recipientName: text('recipient_name').notNull(),
|
|
194
|
+
transferMechanism: text('transfer_mechanism').notNull(),
|
|
195
|
+
safeguards: text('safeguards').notNull(),
|
|
196
|
+
dataCategories: json('data_categories').notNull(),
|
|
197
|
+
adequacyStatus: text('adequacy_status').notNull(),
|
|
198
|
+
ndpcApprovalRequired: boolean('ndpc_approval_required').notNull().default(false),
|
|
199
|
+
ndpcApprovalReference: text('ndpc_approval_reference'),
|
|
200
|
+
riskLevel: text('risk_level').notNull(),
|
|
201
|
+
createdAt: timestamp('created_at').defaultNow().notNull(),
|
|
202
|
+
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
|
203
|
+
},
|
|
204
|
+
(t) => ({
|
|
205
|
+
destinationCountryIdx: index('cross_border_destination_idx').on(t.destinationCountry),
|
|
206
|
+
riskLevelIdx: index('cross_border_risk_level_idx').on(t.riskLevel),
|
|
207
|
+
}),
|
|
208
|
+
);
|
|
209
|
+
|
|
131
210
|
// ---------------------------------------------------------------------------
|
|
132
211
|
// ndpr_audit_log — §44 accountability
|
|
133
212
|
// ---------------------------------------------------------------------------
|
|
@@ -159,5 +238,11 @@ export type DSRRequest = typeof dsrRequests.$inferSelect;
|
|
|
159
238
|
export type NewDSRRequest = typeof dsrRequests.$inferInsert;
|
|
160
239
|
export type BreachReport = typeof breachReports.$inferSelect;
|
|
161
240
|
export type NewBreachReport = typeof breachReports.$inferInsert;
|
|
162
|
-
export type ProcessingRecord
|
|
163
|
-
export type
|
|
241
|
+
export type ProcessingRecord = typeof processingRecords.$inferSelect;
|
|
242
|
+
export type DPIARecord = typeof dpiaRecords.$inferSelect;
|
|
243
|
+
export type NewDPIARecord = typeof dpiaRecords.$inferInsert;
|
|
244
|
+
export type LawfulBasisRecord = typeof lawfulBasisRecords.$inferSelect;
|
|
245
|
+
export type NewLawfulBasisRecord = typeof lawfulBasisRecords.$inferInsert;
|
|
246
|
+
export type CrossBorderTransfer = typeof crossBorderTransferRecords.$inferSelect;
|
|
247
|
+
export type NewCrossBorderTransfer = typeof crossBorderTransferRecords.$inferInsert;
|
|
248
|
+
export type AuditLogEntry = typeof auditLog.$inferSelect;
|
|
@@ -16,9 +16,38 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { Router } from 'express';
|
|
19
|
+
// {{#if ORM=prisma}}
|
|
19
20
|
import { PrismaClient } from '@prisma/client';
|
|
20
21
|
|
|
21
22
|
const prisma = new PrismaClient();
|
|
23
|
+
// {{/if}}
|
|
24
|
+
// {{#if ORM=drizzle}}
|
|
25
|
+
import { db } from '@/drizzle';
|
|
26
|
+
import { consentRecords, complianceAuditLog } from '@/drizzle/ndpr-schema';
|
|
27
|
+
import { eq, and, isNull, desc } from 'drizzle-orm';
|
|
28
|
+
// {{/if}}
|
|
29
|
+
// {{#if ORM=none}}
|
|
30
|
+
// TODO ({{ORG_NAME}}): wire this to your persistent store of choice. The
|
|
31
|
+
// in-memory map below is enough to develop against locally but DOES NOT
|
|
32
|
+
// satisfy NDPA Section 44 (record-keeping) or the auditability NDPC asks
|
|
33
|
+
// about. Replace `consentStore`/`auditLog` with your DB / KV / API.
|
|
34
|
+
interface ConsentRecord {
|
|
35
|
+
id: string;
|
|
36
|
+
subjectId: string;
|
|
37
|
+
consents: Record<string, boolean>;
|
|
38
|
+
version: string;
|
|
39
|
+
method: string;
|
|
40
|
+
lawfulBasis: string | null;
|
|
41
|
+
ipAddress: string | null;
|
|
42
|
+
userAgent: string | null;
|
|
43
|
+
createdAt: Date;
|
|
44
|
+
revokedAt: Date | null;
|
|
45
|
+
}
|
|
46
|
+
const consentStore = new Map<string, ConsentRecord>();
|
|
47
|
+
const auditLog: Array<{ id: string; module: string; action: string; entityId: string; at: Date }> = [];
|
|
48
|
+
function newId() { return crypto.randomUUID(); }
|
|
49
|
+
// {{/if}}
|
|
50
|
+
|
|
22
51
|
export const consentRouter = Router();
|
|
23
52
|
|
|
24
53
|
// GET /consent?subjectId=xxx
|
|
@@ -29,10 +58,25 @@ consentRouter.get('/', async (req, res) => {
|
|
|
29
58
|
return res.status(400).json({ error: 'subjectId required' });
|
|
30
59
|
}
|
|
31
60
|
|
|
61
|
+
// {{#if ORM=prisma}}
|
|
32
62
|
const record = await prisma.consentRecord.findFirst({
|
|
33
63
|
where: { subjectId, revokedAt: null },
|
|
34
64
|
orderBy: { createdAt: 'desc' },
|
|
35
65
|
});
|
|
66
|
+
// {{/if}}
|
|
67
|
+
// {{#if ORM=drizzle}}
|
|
68
|
+
const [record] = await db
|
|
69
|
+
.select()
|
|
70
|
+
.from(consentRecords)
|
|
71
|
+
.where(and(eq(consentRecords.subjectId, subjectId), isNull(consentRecords.revokedAt)))
|
|
72
|
+
.orderBy(desc(consentRecords.createdAt))
|
|
73
|
+
.limit(1);
|
|
74
|
+
// {{/if}}
|
|
75
|
+
// {{#if ORM=none}}
|
|
76
|
+
const record = [...consentStore.values()]
|
|
77
|
+
.filter((r) => r.subjectId === subjectId && r.revokedAt === null)
|
|
78
|
+
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0] ?? null;
|
|
79
|
+
// {{/if}}
|
|
36
80
|
|
|
37
81
|
return res.json(record);
|
|
38
82
|
});
|
|
@@ -45,6 +89,13 @@ consentRouter.post('/', async (req, res) => {
|
|
|
45
89
|
return res.status(400).json({ error: 'subjectId, consents, and version are required' });
|
|
46
90
|
}
|
|
47
91
|
|
|
92
|
+
const ipAddress =
|
|
93
|
+
(req.headers['x-forwarded-for'] as string)?.split(',')[0].trim() ??
|
|
94
|
+
req.socket.remoteAddress ??
|
|
95
|
+
null;
|
|
96
|
+
const userAgent = req.headers['user-agent'] ?? null;
|
|
97
|
+
|
|
98
|
+
// {{#if ORM=prisma}}
|
|
48
99
|
// Revoke any previously active records (immutable-audit pattern — NDPA §44).
|
|
49
100
|
await prisma.consentRecord.updateMany({
|
|
50
101
|
where: { subjectId, revokedAt: null },
|
|
@@ -58,11 +109,8 @@ consentRouter.post('/', async (req, res) => {
|
|
|
58
109
|
version,
|
|
59
110
|
method: method ?? 'api',
|
|
60
111
|
lawfulBasis: lawfulBasis ?? null,
|
|
61
|
-
ipAddress
|
|
62
|
-
|
|
63
|
-
req.socket.remoteAddress ??
|
|
64
|
-
null,
|
|
65
|
-
userAgent: req.headers['user-agent'] ?? null,
|
|
112
|
+
ipAddress,
|
|
113
|
+
userAgent,
|
|
66
114
|
},
|
|
67
115
|
});
|
|
68
116
|
|
|
@@ -75,6 +123,47 @@ consentRouter.post('/', async (req, res) => {
|
|
|
75
123
|
changes: { subjectId, version, consents },
|
|
76
124
|
},
|
|
77
125
|
});
|
|
126
|
+
// {{/if}}
|
|
127
|
+
// {{#if ORM=drizzle}}
|
|
128
|
+
await db.update(consentRecords).set({ revokedAt: new Date() }).where(and(eq(consentRecords.subjectId, subjectId), isNull(consentRecords.revokedAt)));
|
|
129
|
+
|
|
130
|
+
const [record] = await db.insert(consentRecords).values({
|
|
131
|
+
subjectId,
|
|
132
|
+
consents,
|
|
133
|
+
version,
|
|
134
|
+
method: method ?? 'api',
|
|
135
|
+
lawfulBasis: lawfulBasis ?? null,
|
|
136
|
+
ipAddress,
|
|
137
|
+
userAgent,
|
|
138
|
+
}).returning();
|
|
139
|
+
|
|
140
|
+
await db.insert(complianceAuditLog).values({
|
|
141
|
+
module: 'consent',
|
|
142
|
+
action: 'created',
|
|
143
|
+
entityId: record.id,
|
|
144
|
+
entityType: 'ConsentRecord',
|
|
145
|
+
changes: { subjectId, version, consents },
|
|
146
|
+
});
|
|
147
|
+
// {{/if}}
|
|
148
|
+
// {{#if ORM=none}}
|
|
149
|
+
for (const r of consentStore.values()) {
|
|
150
|
+
if (r.subjectId === subjectId && r.revokedAt === null) r.revokedAt = new Date();
|
|
151
|
+
}
|
|
152
|
+
const record: ConsentRecord = {
|
|
153
|
+
id: newId(),
|
|
154
|
+
subjectId,
|
|
155
|
+
consents,
|
|
156
|
+
version,
|
|
157
|
+
method: method ?? 'api',
|
|
158
|
+
lawfulBasis: lawfulBasis ?? null,
|
|
159
|
+
ipAddress,
|
|
160
|
+
userAgent,
|
|
161
|
+
createdAt: new Date(),
|
|
162
|
+
revokedAt: null,
|
|
163
|
+
};
|
|
164
|
+
consentStore.set(record.id, record);
|
|
165
|
+
auditLog.push({ id: newId(), module: 'consent', action: 'created', entityId: record.id, at: new Date() });
|
|
166
|
+
// {{/if}}
|
|
78
167
|
|
|
79
168
|
return res.status(201).json(record);
|
|
80
169
|
});
|
|
@@ -87,6 +176,7 @@ consentRouter.delete('/', async (req, res) => {
|
|
|
87
176
|
return res.status(400).json({ error: 'subjectId required' });
|
|
88
177
|
}
|
|
89
178
|
|
|
179
|
+
// {{#if ORM=prisma}}
|
|
90
180
|
await prisma.consentRecord.updateMany({
|
|
91
181
|
where: { subjectId, revokedAt: null },
|
|
92
182
|
data: { revokedAt: new Date() },
|
|
@@ -100,6 +190,22 @@ consentRouter.delete('/', async (req, res) => {
|
|
|
100
190
|
entityType: 'ConsentRecord',
|
|
101
191
|
},
|
|
102
192
|
});
|
|
193
|
+
// {{/if}}
|
|
194
|
+
// {{#if ORM=drizzle}}
|
|
195
|
+
await db.update(consentRecords).set({ revokedAt: new Date() }).where(and(eq(consentRecords.subjectId, subjectId), isNull(consentRecords.revokedAt)));
|
|
196
|
+
await db.insert(complianceAuditLog).values({
|
|
197
|
+
module: 'consent',
|
|
198
|
+
action: 'revoked',
|
|
199
|
+
entityId: subjectId,
|
|
200
|
+
entityType: 'ConsentRecord',
|
|
201
|
+
});
|
|
202
|
+
// {{/if}}
|
|
203
|
+
// {{#if ORM=none}}
|
|
204
|
+
for (const r of consentStore.values()) {
|
|
205
|
+
if (r.subjectId === subjectId && r.revokedAt === null) r.revokedAt = new Date();
|
|
206
|
+
}
|
|
207
|
+
auditLog.push({ id: newId(), module: 'consent', action: 'revoked', entityId: subjectId, at: new Date() });
|
|
208
|
+
// {{/if}}
|
|
103
209
|
|
|
104
210
|
return res.json({ success: true });
|
|
105
211
|
});
|