@tantainnovative/ndpr-recipes 0.1.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.
@@ -0,0 +1,484 @@
1
+ /**
2
+ * Drizzle ORM schema for NDPA compliance tables.
3
+ *
4
+ * This mirrors the Prisma schema in `prisma/schema.prisma` using Drizzle's
5
+ * `pgTable` API. Copy this file into your project and use it with your Drizzle
6
+ * database instance. It defines all five compliance tables required for
7
+ * full NDPA coverage:
8
+ *
9
+ * - ndpr_consent_records — Immutable consent audit trail (NDPA §25–26)
10
+ * - ndpr_dsr_requests — Data subject rights requests (NDPA Part IV)
11
+ * - ndpr_breach_reports — Breach incident records (NDPA §40)
12
+ * - ndpr_processing_records — Record of Processing Activities / ROPA
13
+ * - ndpr_audit_log — General compliance audit log
14
+ *
15
+ * Prerequisites
16
+ * -------------
17
+ * - `drizzle-orm` must be installed in your project.
18
+ * - `@paralleldrive/cuid2` must be installed (`pnpm add @paralleldrive/cuid2`).
19
+ * - Run `drizzle-kit push` or generate migrations to apply the schema.
20
+ *
21
+ * @module drizzle/schema
22
+ */
23
+
24
+ import {
25
+ pgTable,
26
+ text,
27
+ timestamp,
28
+ json,
29
+ boolean,
30
+ integer,
31
+ index,
32
+ } from 'drizzle-orm/pg-core';
33
+ import { createId } from '@paralleldrive/cuid2';
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // ndpr_consent_records
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * Immutable consent audit trail.
41
+ *
42
+ * Records are NEVER deleted. Revocation sets `revokedAt` on the existing row.
43
+ * At most one row per `subjectId` should have `revokedAt = NULL` at any time —
44
+ * this invariant is maintained by the drizzleConsentAdapter.
45
+ *
46
+ * NDPA reference: Sections 25–26 (consent and consent withdrawal)
47
+ */
48
+ export const consentRecords = pgTable(
49
+ 'ndpr_consent_records',
50
+ {
51
+ /** CUID2 primary key — collision-resistant, URL-safe, sortable */
52
+ id: text('id')
53
+ .primaryKey()
54
+ .$defaultFn(() => createId()),
55
+
56
+ /**
57
+ * Stable identifier for the data subject.
58
+ * Use your application's user ID, session ID, or hashed email address.
59
+ * Never store raw PII here if you can avoid it — a pseudonymous ID is preferred.
60
+ */
61
+ subjectId: text('subject_id').notNull(),
62
+
63
+ /**
64
+ * Map of consent category → boolean.
65
+ * Stored as JSON so the schema does not need to change when new
66
+ * consent categories are added to the toolkit.
67
+ *
68
+ * Example: { "analytics": true, "marketing": false, "functional": true }
69
+ */
70
+ consents: json('consents').notNull(),
71
+
72
+ /** The consent policy version the subject agreed to (e.g. "1.0", "2024-01"). */
73
+ version: text('version').notNull(),
74
+
75
+ /** How consent was captured: "banner", "api", "form", "import", etc. */
76
+ method: text('method').notNull(),
77
+
78
+ /**
79
+ * NDPA lawful basis for processing.
80
+ * One of: "consent" | "contract" | "legal_obligation" | "vital_interests" |
81
+ * "public_task" | "legitimate_interest"
82
+ */
83
+ lawfulBasis: text('lawful_basis'),
84
+
85
+ /** IP address at the time of consent — retained as evidence for regulators. */
86
+ ipAddress: text('ip_address'),
87
+
88
+ /** User-agent string at the time of consent — provides device/browser context. */
89
+ userAgent: text('user_agent'),
90
+
91
+ /** Timestamp when the consent record was created. */
92
+ createdAt: timestamp('created_at').defaultNow().notNull(),
93
+
94
+ /**
95
+ * Timestamp when the consent was revoked.
96
+ * NULL means the record is currently active. Non-NULL means it has been
97
+ * superseded or explicitly withdrawn by the data subject.
98
+ */
99
+ revokedAt: timestamp('revoked_at'),
100
+ },
101
+ (table) => ({
102
+ /** Index on subjectId to make per-subject queries fast. */
103
+ subjectIdIdx: index('consent_subject_id_idx').on(table.subjectId),
104
+ }),
105
+ );
106
+
107
+ // ---------------------------------------------------------------------------
108
+ // ndpr_dsr_requests
109
+ // ---------------------------------------------------------------------------
110
+
111
+ /**
112
+ * Data Subject Rights (DSR) request tracking.
113
+ *
114
+ * Records every DSR request submitted by a data subject. Status transitions
115
+ * follow the NDPA 30-day response window (NDPA Section 29–36):
116
+ *
117
+ * pending → in_progress → completed
118
+ * → rejected
119
+ *
120
+ * NDPA reference: Part IV, Sections 29–36
121
+ */
122
+ export const dsrRequests = pgTable(
123
+ 'ndpr_dsr_requests',
124
+ {
125
+ id: text('id')
126
+ .primaryKey()
127
+ .$defaultFn(() => createId()),
128
+
129
+ /**
130
+ * DSR type — one of:
131
+ * "access" | "erasure" | "portability" | "rectification" |
132
+ * "restriction" | "objection" | "automated_decision"
133
+ */
134
+ type: text('type').notNull(),
135
+
136
+ /**
137
+ * Processing status.
138
+ * Default is "pending" when a new request comes in.
139
+ */
140
+ status: text('status').notNull().default('pending'),
141
+
142
+ /** Full name of the data subject making the request. */
143
+ subjectName: text('subject_name').notNull(),
144
+
145
+ /** Email address of the data subject — used to communicate outcomes. */
146
+ subjectEmail: text('subject_email').notNull(),
147
+
148
+ /** Optional phone number for the data subject. */
149
+ subjectPhone: text('subject_phone'),
150
+
151
+ /**
152
+ * Type of identifier used to locate the subject's data in your systems.
153
+ * E.g. "email", "account_id", "national_id".
154
+ */
155
+ identifierType: text('identifier_type').notNull(),
156
+
157
+ /** The actual identifier value corresponding to `identifierType`. */
158
+ identifierValue: text('identifier_value').notNull(),
159
+
160
+ /** Optional free-text description / reason provided by the subject. */
161
+ description: text('description'),
162
+
163
+ /** Internal staff notes — never expose these to the data subject. */
164
+ internalNotes: text('internal_notes'),
165
+
166
+ /** Email of the staff member assigned to handle this request. */
167
+ assignedTo: text('assigned_to'),
168
+
169
+ /** When the request was submitted. */
170
+ submittedAt: timestamp('submitted_at').defaultNow().notNull(),
171
+
172
+ /** When the request was acknowledged to the subject. */
173
+ acknowledgedAt: timestamp('acknowledged_at'),
174
+
175
+ /** When the request was fully completed. */
176
+ completedAt: timestamp('completed_at'),
177
+
178
+ /**
179
+ * NDPA mandates a 30-day response window from submission.
180
+ * This should be set to `submittedAt + 30 days` on creation.
181
+ */
182
+ dueAt: timestamp('due_at').notNull(),
183
+ },
184
+ (table) => ({
185
+ statusIdx: index('dsr_status_idx').on(table.status),
186
+ subjectEmailIdx: index('dsr_subject_email_idx').on(table.subjectEmail),
187
+ }),
188
+ );
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // ndpr_breach_reports
192
+ // ---------------------------------------------------------------------------
193
+
194
+ /**
195
+ * Personal data breach incident reports.
196
+ *
197
+ * Under NDPA Section 40, data controllers must notify the NDPC within 72 hours
198
+ * of becoming aware of a breach. This table tracks every breach incident and
199
+ * its notification status.
200
+ *
201
+ * Records are never deleted — status transitions from "ongoing" to "resolved"
202
+ * once the incident is closed.
203
+ *
204
+ * NDPA reference: Section 40 (breach notification to NDPC)
205
+ */
206
+ export const breachReports = pgTable(
207
+ 'ndpr_breach_reports',
208
+ {
209
+ id: text('id')
210
+ .primaryKey()
211
+ .$defaultFn(() => createId()),
212
+
213
+ /** Short title describing the breach (e.g. "Customer database exposed"). */
214
+ title: text('title').notNull(),
215
+
216
+ /** Detailed description of what happened, how, and what data was affected. */
217
+ description: text('description').notNull(),
218
+
219
+ /**
220
+ * Breach category — classifies the type of incident:
221
+ * "confidentiality" | "integrity" | "availability" | "combined"
222
+ */
223
+ category: text('category').notNull(),
224
+
225
+ /**
226
+ * Risk severity level.
227
+ * One of: "low" | "medium" | "high" | "critical"
228
+ */
229
+ severity: text('severity').notNull(),
230
+
231
+ /**
232
+ * Current status of the incident.
233
+ * One of: "ongoing" | "contained" | "resolved"
234
+ */
235
+ status: text('status').notNull().default('ongoing'),
236
+
237
+ /** When the breach was first discovered by your organisation. */
238
+ discoveredAt: timestamp('discovered_at').notNull(),
239
+
240
+ /** When the breach actually occurred (may differ from discoveredAt). */
241
+ occurredAt: timestamp('occurred_at'),
242
+
243
+ /** When the breach was formally logged in this system. */
244
+ reportedAt: timestamp('reported_at').defaultNow().notNull(),
245
+
246
+ /**
247
+ * When the NDPC was notified.
248
+ * NULL means notification has not yet been sent.
249
+ * The 72-hour window starts from `discoveredAt`.
250
+ */
251
+ ndpcNotifiedAt: timestamp('ndpc_notified_at'),
252
+
253
+ /** Full name of the person who reported the breach internally. */
254
+ reporterName: text('reporter_name').notNull(),
255
+
256
+ /** Email address of the breach reporter. */
257
+ reporterEmail: text('reporter_email').notNull(),
258
+
259
+ /** Department or business unit of the reporter. */
260
+ reporterDepartment: text('reporter_department'),
261
+
262
+ /**
263
+ * List of systems or services that were affected.
264
+ * Stored as a JSON array of strings.
265
+ * Example: ["user-auth-service", "payments-db"]
266
+ */
267
+ affectedSystems: json('affected_systems').notNull(),
268
+
269
+ /**
270
+ * Categories of personal data involved in the breach.
271
+ * Stored as a JSON array of strings.
272
+ * Example: ["email", "national_id", "financial_data"]
273
+ */
274
+ dataTypes: json('data_types').notNull(),
275
+
276
+ /** Estimated number of data subjects affected. */
277
+ estimatedAffected: integer('estimated_affected'),
278
+
279
+ /** Description of any immediate containment actions taken. */
280
+ initialActions: text('initial_actions'),
281
+
282
+ /**
283
+ * Whether the mandatory NDPC notification has been sent.
284
+ * Use `ndpcNotifiedAt` for the exact timestamp.
285
+ */
286
+ ndpcNotificationSent: boolean('ndpc_notification_sent').notNull().default(false),
287
+ },
288
+ (table) => ({
289
+ statusIdx: index('breach_status_idx').on(table.status),
290
+ severityIdx: index('breach_severity_idx').on(table.severity),
291
+ }),
292
+ );
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // ndpr_processing_records
296
+ // ---------------------------------------------------------------------------
297
+
298
+ /**
299
+ * Record of Processing Activities (ROPA).
300
+ *
301
+ * Data controllers are required under the NDPA accountability principle to
302
+ * maintain a register of all personal data processing activities. Each row
303
+ * represents one distinct processing activity (e.g. "Email marketing",
304
+ * "HR payroll processing", "Website analytics").
305
+ *
306
+ * Records are never deleted — inactive activities are archived by setting
307
+ * `status = 'archived'`.
308
+ *
309
+ * NDPA reference: Accountability principle; Schedule 1, Part 1
310
+ */
311
+ export const processingRecords = pgTable(
312
+ 'ndpr_processing_records',
313
+ {
314
+ id: text('id')
315
+ .primaryKey()
316
+ .$defaultFn(() => createId()),
317
+
318
+ /** The primary purpose of this processing activity. */
319
+ purpose: text('purpose').notNull(),
320
+
321
+ /**
322
+ * NDPA lawful basis for this processing activity.
323
+ * One of: "consent" | "contract" | "legal_obligation" | "vital_interests" |
324
+ * "public_task" | "legitimate_interest"
325
+ */
326
+ lawfulBasis: text('lawful_basis').notNull(),
327
+
328
+ /**
329
+ * Categories of personal data processed.
330
+ * Stored as a JSON array of strings.
331
+ * Example: ["name", "email", "purchase_history"]
332
+ */
333
+ dataCategories: json('data_categories').notNull(),
334
+
335
+ /**
336
+ * Categories of data subjects whose data is processed.
337
+ * Stored as a JSON array of strings.
338
+ * Example: ["customers", "employees", "website_visitors"]
339
+ */
340
+ dataSubjects: json('data_subjects').notNull(),
341
+
342
+ /**
343
+ * Third parties to whom data is disclosed.
344
+ * Stored as a JSON array of strings.
345
+ * Example: ["Mailchimp", "Stripe", "Google Analytics"]
346
+ */
347
+ recipients: json('recipients').notNull(),
348
+
349
+ /**
350
+ * How long data is retained for this activity.
351
+ * Use a human-readable string: "2 years", "Until account deletion", etc.
352
+ */
353
+ retentionPeriod: text('retention_period').notNull(),
354
+
355
+ /**
356
+ * Technical and organisational security measures in place.
357
+ * Stored as a JSON array of strings.
358
+ * Example: ["encryption_at_rest", "access_controls", "audit_logging"]
359
+ */
360
+ securityMeasures: json('security_measures').notNull(),
361
+
362
+ /**
363
+ * Countries to which data is transferred outside Nigeria.
364
+ * Stored as a JSON array of country codes/names, or null if no transfers.
365
+ */
366
+ transferCountries: json('transfer_countries'),
367
+
368
+ /**
369
+ * Legal mechanism used for the cross-border transfer.
370
+ * E.g. "adequacy_decision", "standard_contractual_clauses", "consent".
371
+ */
372
+ transferMechanism: text('transfer_mechanism'),
373
+
374
+ /**
375
+ * Whether a Data Protection Impact Assessment (DPIA) was conducted.
376
+ * Required for high-risk processing activities.
377
+ */
378
+ dpiaConducted: boolean('dpia_conducted').notNull().default(false),
379
+
380
+ /**
381
+ * Current status of this processing activity.
382
+ * One of: "active" | "archived"
383
+ */
384
+ status: text('status').notNull().default('active'),
385
+
386
+ /** When this processing record was first created. */
387
+ createdAt: timestamp('created_at').defaultNow().notNull(),
388
+
389
+ /** When this processing record was last updated. */
390
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
391
+ },
392
+ );
393
+
394
+ // ---------------------------------------------------------------------------
395
+ // ndpr_audit_log
396
+ // ---------------------------------------------------------------------------
397
+
398
+ /**
399
+ * General compliance audit log.
400
+ *
401
+ * Records every significant compliance action across all modules. The audit
402
+ * log is append-only — rows are never updated or deleted. It provides an
403
+ * authoritative trail for regulatory inspection under the NDPA accountability
404
+ * principle.
405
+ *
406
+ * NDPA reference: Section 44 (accountability); Schedule 1, Part 1
407
+ */
408
+ export const auditLog = pgTable(
409
+ 'ndpr_audit_log',
410
+ {
411
+ id: text('id')
412
+ .primaryKey()
413
+ .$defaultFn(() => createId()),
414
+
415
+ /**
416
+ * Which compliance module generated this entry.
417
+ * One of: "consent" | "dsr" | "breach" | "ropa" | "system"
418
+ */
419
+ module: text('module').notNull(),
420
+
421
+ /**
422
+ * The action that occurred.
423
+ * E.g. "created", "updated", "revoked", "deleted", "notified"
424
+ */
425
+ action: text('action').notNull(),
426
+
427
+ /** ID of the entity that was acted upon. */
428
+ entityId: text('entity_id').notNull(),
429
+
430
+ /**
431
+ * Type/model of the entity.
432
+ * E.g. "ConsentRecord", "DSRRequest", "BreachReport"
433
+ */
434
+ entityType: text('entity_type').notNull(),
435
+
436
+ /**
437
+ * Snapshot of what changed.
438
+ * Stored as JSON. The exact shape depends on the module and action.
439
+ */
440
+ changes: json('changes'),
441
+
442
+ /**
443
+ * Who performed the action.
444
+ * May be a user ID, email address, or system identifier.
445
+ * NULL indicates an automated/system action.
446
+ */
447
+ performedBy: text('performed_by'),
448
+
449
+ /** Exact timestamp when the audit event occurred. */
450
+ createdAt: timestamp('created_at').defaultNow().notNull(),
451
+ },
452
+ (table) => ({
453
+ moduleEntityIdx: index('audit_module_entity_idx').on(table.module, table.entityId),
454
+ }),
455
+ );
456
+
457
+ // ---------------------------------------------------------------------------
458
+ // Type exports — infer table row types for use throughout your application
459
+ // ---------------------------------------------------------------------------
460
+
461
+ /** Row type for ndpr_consent_records */
462
+ export type ConsentRecord = typeof consentRecords.$inferSelect;
463
+ /** Insert type for ndpr_consent_records */
464
+ export type NewConsentRecord = typeof consentRecords.$inferInsert;
465
+
466
+ /** Row type for ndpr_dsr_requests */
467
+ export type DSRRequest = typeof dsrRequests.$inferSelect;
468
+ /** Insert type for ndpr_dsr_requests */
469
+ export type NewDSRRequest = typeof dsrRequests.$inferInsert;
470
+
471
+ /** Row type for ndpr_breach_reports */
472
+ export type BreachReport = typeof breachReports.$inferSelect;
473
+ /** Insert type for ndpr_breach_reports */
474
+ export type NewBreachReport = typeof breachReports.$inferInsert;
475
+
476
+ /** Row type for ndpr_processing_records */
477
+ export type ProcessingRecord = typeof processingRecords.$inferSelect;
478
+ /** Insert type for ndpr_processing_records */
479
+ export type NewProcessingRecord = typeof processingRecords.$inferInsert;
480
+
481
+ /** Row type for ndpr_audit_log */
482
+ export type AuditLogEntry = typeof auditLog.$inferSelect;
483
+ /** Insert type for ndpr_audit_log */
484
+ export type NewAuditLogEntry = typeof auditLog.$inferInsert;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Express — NDPR Router Factory
3
+ *
4
+ * Assembles all NDPA compliance route modules into a single Express Router
5
+ * that can be mounted at any path in your application with a single call.
6
+ *
7
+ * This entry point is the recommended way to add the full compliance API to
8
+ * an existing Express or NestJS (with Express adapter) application.
9
+ *
10
+ * Mounted routes
11
+ * --------------
12
+ * /consent — Consent management (NDPA Section 25, 26)
13
+ * /dsr — Data Subject Rights requests (NDPA Sections 34–38)
14
+ * /breach — Breach notification workflow (NDPA Section 40)
15
+ * /compliance — Compliance score dashboard
16
+ * /ropa — Record of Processing Activities (NDPA accountability principle)
17
+ *
18
+ * How to use
19
+ * ----------
20
+ * Copy this file and the `routes/` + `middleware/` directories into your project,
21
+ * then mount the router in your Express app:
22
+ *
23
+ * import express from 'express';
24
+ * import cookieParser from 'cookie-parser';
25
+ * import { createNDPRRouter } from './ndpr/express';
26
+ *
27
+ * const app = express();
28
+ * app.use(express.json());
29
+ * app.use(cookieParser()); // required for consent cookie fallback
30
+ * app.use('/api/ndpr', createNDPRRouter());
31
+ *
32
+ * The full compliance API is then available at:
33
+ * GET /api/ndpr/consent?subjectId=xxx
34
+ * POST /api/ndpr/dsr
35
+ * GET /api/ndpr/compliance
36
+ * ... etc.
37
+ *
38
+ * Protecting routes with consent middleware
39
+ * -----------------------------------------
40
+ * import { requireConsent } from './ndpr/express/middleware/consent-check';
41
+ *
42
+ * app.post('/email/marketing', requireConsent('marketing'), sendEmailHandler);
43
+ *
44
+ * @module express/index
45
+ */
46
+
47
+ import { Router } from 'express';
48
+ import { consentRouter } from './routes/consent';
49
+ import { dsrRouter } from './routes/dsr';
50
+ import { breachRouter } from './routes/breach';
51
+ import { complianceRouter } from './routes/compliance';
52
+ import { ropaRouter } from './routes/ropa';
53
+
54
+ /**
55
+ * Create and return a fully configured NDPR compliance Express Router.
56
+ *
57
+ * All five compliance modules are mounted as sub-routers under their
58
+ * respective path prefixes. Each module handles its own Prisma queries
59
+ * and audit logging — no additional wiring is required beyond mounting
60
+ * this router in your app.
61
+ *
62
+ * @returns An Express Router with all NDPR compliance routes mounted.
63
+ */
64
+ export function createNDPRRouter(): Router {
65
+ const router = Router();
66
+
67
+ router.use('/consent', consentRouter);
68
+ router.use('/dsr', dsrRouter);
69
+ router.use('/breach', breachRouter);
70
+ router.use('/compliance', complianceRouter);
71
+ router.use('/ropa', ropaRouter);
72
+
73
+ return router;
74
+ }
75
+
76
+ // Re-export middleware and individual routers for granular use.
77
+ export { consentRouter } from './routes/consent';
78
+ export { dsrRouter } from './routes/dsr';
79
+ export { breachRouter } from './routes/breach';
80
+ export { complianceRouter } from './routes/compliance';
81
+ export { ropaRouter } from './routes/ropa';
82
+ export { requireConsent, requireAllConsents } from './middleware/consent-check';
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Express — Consent Gate Middleware
3
+ *
4
+ * Provides a middleware factory that blocks requests from data subjects who
5
+ * have not granted the required consent type, implementing the consent
6
+ * enforcement required by NDPA Section 25.
7
+ *
8
+ * NDPA Section 25 requires that data processing based on consent only occurs
9
+ * when the subject has freely given specific, informed, and unambiguous consent.
10
+ * This middleware enforces that requirement at the HTTP layer so individual
11
+ * route handlers don't need to repeat the check.
12
+ *
13
+ * How to use
14
+ * ----------
15
+ * Apply the middleware to any Express route or router that requires consent:
16
+ *
17
+ * import { requireConsent } from './middleware/consent-check';
18
+ *
19
+ * // Protect a single route
20
+ * app.post('/email/send', requireConsent('marketing'), sendEmailHandler);
21
+ *
22
+ * // Protect an entire router
23
+ * analyticsRouter.use(requireConsent('analytics'));
24
+ *
25
+ * Subject identification
26
+ * ----------------------
27
+ * The middleware looks for a subject identifier in two places (in priority order):
28
+ * 1. The `x-subject-id` request header (set by your auth middleware or JWT)
29
+ * 2. The `subject_id` cookie (set by the consent banner on the client)
30
+ *
31
+ * @module express/middleware/consent-check
32
+ */
33
+
34
+ import { PrismaClient } from '@prisma/client';
35
+
36
+ const prisma = new PrismaClient();
37
+
38
+ /**
39
+ * Express middleware factory that enforces a specific consent type.
40
+ *
41
+ * Returns a standard Express middleware function that:
42
+ * 1. Resolves the data subject's identifier from the request.
43
+ * 2. Loads the most recent active consent record for that subject.
44
+ * 3. Verifies that the required consent type is set to `true`.
45
+ * 4. Calls `next()` if consent is present, or responds with 403 if not.
46
+ *
47
+ * @param consentType - The consent category key to check (e.g. 'marketing', 'analytics')
48
+ * @returns Express middleware function
49
+ *
50
+ * @example
51
+ * router.post('/newsletter/subscribe', requireConsent('marketing'), handler);
52
+ */
53
+ export function requireConsent(consentType: string) {
54
+ return async (req: any, res: any, next: any): Promise<void> => {
55
+ // Resolve subject identifier from header (preferred) or cookie fallback.
56
+ const subjectId: string | undefined =
57
+ req.headers['x-subject-id'] || req.cookies?.subject_id;
58
+
59
+ if (!subjectId) {
60
+ res.status(403).json({ error: 'Consent verification required' });
61
+ return;
62
+ }
63
+
64
+ // Load the most recent active consent record for this subject.
65
+ const record = await prisma.consentRecord.findFirst({
66
+ where: { subjectId, revokedAt: null },
67
+ orderBy: { createdAt: 'desc' },
68
+ });
69
+
70
+ if (!record) {
71
+ res.status(403).json({ error: 'No consent on record' });
72
+ return;
73
+ }
74
+
75
+ // Verify that the specific consent category has been granted.
76
+ const consents = record.consents as Record<string, boolean>;
77
+
78
+ if (!consents[consentType]) {
79
+ res.status(403).json({ error: `Consent for "${consentType}" not granted` });
80
+ return;
81
+ }
82
+
83
+ // Consent verified — attach consent record to request for downstream use.
84
+ req.consentRecord = record;
85
+ next();
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Convenience middleware that requires ALL of the listed consent types.
91
+ *
92
+ * If any one of the required consent types is missing, the request is blocked.
93
+ * Useful when a single endpoint processes data under multiple consent categories.
94
+ *
95
+ * @param consentTypes - Array of consent category keys, all of which must be granted
96
+ *
97
+ * @example
98
+ * router.post('/profile/sync', requireAllConsents(['analytics', 'personalisation']), handler);
99
+ */
100
+ export function requireAllConsents(consentTypes: string[]) {
101
+ return async (req: any, res: any, next: any): Promise<void> => {
102
+ const subjectId: string | undefined =
103
+ req.headers['x-subject-id'] || req.cookies?.subject_id;
104
+
105
+ if (!subjectId) {
106
+ res.status(403).json({ error: 'Consent verification required' });
107
+ return;
108
+ }
109
+
110
+ const record = await prisma.consentRecord.findFirst({
111
+ where: { subjectId, revokedAt: null },
112
+ orderBy: { createdAt: 'desc' },
113
+ });
114
+
115
+ if (!record) {
116
+ res.status(403).json({ error: 'No consent on record' });
117
+ return;
118
+ }
119
+
120
+ const consents = record.consents as Record<string, boolean>;
121
+ const missing = consentTypes.filter((type) => !consents[type]);
122
+
123
+ if (missing.length > 0) {
124
+ res.status(403).json({
125
+ error: `Consent not granted for: ${missing.join(', ')}`,
126
+ });
127
+ return;
128
+ }
129
+
130
+ req.consentRecord = record;
131
+ next();
132
+ };
133
+ }