@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # create-ndpr
2
2
 
3
- CLI scaffolder for [NDPA](https://ndpr.gov.ng/) compliance using [`@tantainnovative/ndpr-toolkit`](https://github.com/tantainnovative/ndpr-toolkit).
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://ndpr-toolkit.tantainnovative.com
129
- - GitHub: https://github.com/tantainnovative/ndpr-toolkit
130
- - NDPC: https://ndpr.gov.ng
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
- generate(`${appDir}/api/consent/route.ts`, 'nextjs-consent-route.ts', vars);
487
+ generateRoute(`${appDir}/api/consent/route.ts`, 'nextjs-consent-route.ts');
413
488
  }
414
489
  if (selectedModules.includes('dsr')) {
415
- generate(`${appDir}/api/dsr/route.ts`, 'nextjs-dsr-route.ts', vars);
490
+ generateRoute(`${appDir}/api/dsr/route.ts`, 'nextjs-dsr-route.ts');
416
491
  }
417
492
  if (selectedModules.includes('breach')) {
418
- generate(`${appDir}/api/breach/route.ts`, 'nextjs-breach-route.ts', vars);
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
- generate('pages/api/consent.ts', 'nextjs-consent-route.ts', vars);
507
+ generateRoute('pages/api/consent.ts', 'nextjs-consent-route.ts');
424
508
  }
425
509
  if (selectedModules.includes('dsr')) {
426
- generate('pages/api/dsr.ts', 'nextjs-dsr-route.ts', vars);
510
+ generateRoute('pages/api/dsr.ts', 'nextjs-dsr-route.ts');
427
511
  }
428
512
  if (selectedModules.includes('breach')) {
429
- generate('pages/api/breach.ts', 'nextjs-breach-route.ts', vars);
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
- generate('src/ndpr/index.ts', 'express-setup.ts', vars);
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
- generate('src/ndpr/routes/consent.ts', 'express-consent-route.ts', vars);
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
- console.log(dim(' Documentation: https://ndpr-toolkit.tantainnovative.com'));
492
- console.log(dim(' Repository: https://github.com/tantainnovative/ndpr-toolkit'));
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.1.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": "./bin/index.mjs"
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 — 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
- * - auditLog — §44 audit trail
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 = typeof processingRecords.$inferSelect;
163
- export type AuditLogEntry = typeof auditLog.$inferSelect;
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
- (req.headers['x-forwarded-for'] as string)?.split(',')[0].trim() ??
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
  });