@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.
package/README.md ADDED
@@ -0,0 +1,408 @@
1
+ # @tantainnovative/ndpr-recipes
2
+
3
+ Backend recipes for NDPA compliance with [@tantainnovative/ndpr-toolkit](https://github.com/tantainnovative/ndpr-toolkit).
4
+
5
+ ## What is this?
6
+
7
+ This package is a **reference implementation** — not a library to install. Copy the files you need directly into your project and adapt them to fit your architecture. Each recipe is self-contained and heavily documented.
8
+
9
+ > Do not `npm install` this package into your project. Clone or download the files and integrate them manually.
10
+
11
+ ---
12
+
13
+ ## Overview
14
+
15
+ `ndpr-recipes` provides everything you need to back the `@tantainnovative/ndpr-toolkit` with a real database. It covers two ORM families, two server frameworks, and includes complete examples for wiring it all together.
16
+
17
+ ### What's covered
18
+
19
+ | Coverage area | Implementation |
20
+ |---|---|
21
+ | Database schema | Prisma + Drizzle ORM (PostgreSQL) |
22
+ | Consent persistence | Prisma adapter, Drizzle adapter |
23
+ | DSR request persistence | Prisma adapter, Drizzle adapter |
24
+ | Breach report persistence | Prisma adapter |
25
+ | ROPA persistence | Prisma adapter |
26
+ | Next.js App Router | Consent, DSR, Breach, ROPA, Compliance route handlers |
27
+ | Express | Full NDPR router with consent, DSR, breach, ROPA, compliance routes |
28
+ | Consent middleware | Next.js edge middleware + Express middleware |
29
+
30
+ ---
31
+
32
+ ## Available Recipes
33
+
34
+ | File | Description |
35
+ |---|---|
36
+ | `prisma/schema.prisma` | Prisma schema — all 5 NDPA compliance tables |
37
+ | `src/drizzle/schema.ts` | Drizzle ORM schema — mirrors the Prisma schema |
38
+ | `src/adapters/prisma-consent.ts` | Prisma `StorageAdapter<ConsentSettings>` |
39
+ | `src/adapters/prisma-dsr.ts` | Prisma `StorageAdapter<DSRRequest[]>` |
40
+ | `src/adapters/prisma-breach.ts` | Prisma `StorageAdapter<BreachState>` |
41
+ | `src/adapters/prisma-ropa.ts` | Prisma `StorageAdapter<RecordOfProcessingActivities>` |
42
+ | `src/adapters/drizzle-consent.ts` | Drizzle `StorageAdapter<ConsentSettings>` |
43
+ | `src/adapters/drizzle-dsr.ts` | Drizzle `StorageAdapter<DSRRequest[]>` |
44
+ | `src/nextjs/app-router/api/consent/route.ts` | Next.js consent API route |
45
+ | `src/nextjs/app-router/api/dsr/route.ts` | Next.js DSR API route |
46
+ | `src/nextjs/app-router/api/breach/route.ts` | Next.js breach API route |
47
+ | `src/nextjs/app-router/api/ropa/route.ts` | Next.js ROPA API route |
48
+ | `src/nextjs/app-router/api/compliance/route.ts` | Next.js compliance score API route |
49
+ | `src/nextjs/app-router/middleware.ts` | Next.js consent gate middleware |
50
+ | `src/nextjs/app-router/layout-example.tsx` | Full wiring example for App Router |
51
+ | `src/express/index.ts` | Express router factory — mounts all routes |
52
+ | `src/express/routes/consent.ts` | Express consent router |
53
+ | `src/express/routes/dsr.ts` | Express DSR router |
54
+ | `src/express/routes/breach.ts` | Express breach router |
55
+ | `src/express/routes/ropa.ts` | Express ROPA router |
56
+ | `src/express/routes/compliance.ts` | Express compliance score router |
57
+ | `src/express/middleware/consent-check.ts` | Express consent gate middleware |
58
+
59
+ ---
60
+
61
+ ## Quick Start
62
+
63
+ ### 1. Copy the database schema
64
+
65
+ **Prisma:**
66
+
67
+ ```bash
68
+ # Copy into your project
69
+ cp packages/ndpr-recipes/prisma/schema.prisma prisma/schema.prisma
70
+ ```
71
+
72
+ **Drizzle:**
73
+
74
+ ```bash
75
+ # Copy the schema file
76
+ cp packages/ndpr-recipes/src/drizzle/schema.ts src/db/ndpr-schema.ts
77
+ ```
78
+
79
+ ### 2. Set up the database connection
80
+
81
+ ```bash
82
+ # .env
83
+ DATABASE_URL="postgresql://user:password@localhost:5432/myapp_dev"
84
+ ```
85
+
86
+ ### 3. Run migrations
87
+
88
+ **Prisma:**
89
+
90
+ ```bash
91
+ npx prisma migrate dev --name init-ndpr-tables
92
+ npx prisma generate
93
+ ```
94
+
95
+ **Drizzle:**
96
+
97
+ ```bash
98
+ npx drizzle-kit push
99
+ # or generate a migration file:
100
+ npx drizzle-kit generate
101
+ npx drizzle-kit migrate
102
+ ```
103
+
104
+ ### 4. Copy and wire the adapters
105
+
106
+ Pick the adapter for your ORM (see sections below), copy it into your project, and pass it to the relevant toolkit hook.
107
+
108
+ ---
109
+
110
+ ## Prisma Adapters
111
+
112
+ The adapters in `src/adapters/prisma-*.ts` implement the `StorageAdapter<T>` interface from `@tantainnovative/ndpr-toolkit`. Copy them alongside your Prisma client and pass them to the corresponding toolkit hook.
113
+
114
+ ### Consent adapter
115
+
116
+ Follows the immutable-audit pattern required by NDPA Section 25: records are never deleted, and revocation sets `revokedAt` on the existing row.
117
+
118
+ ```ts
119
+ import { PrismaClient } from '@prisma/client';
120
+ import { useConsent } from '@tantainnovative/ndpr-toolkit';
121
+ import { prismaConsentAdapter } from './adapters/prisma-consent';
122
+
123
+ const prisma = new PrismaClient();
124
+
125
+ function ConsentBanner() {
126
+ const adapter = prismaConsentAdapter(prisma, session.userId);
127
+ const { settings, updateConsent } = useConsent({ adapter });
128
+ // ...
129
+ }
130
+ ```
131
+
132
+ ### DSR adapter
133
+
134
+ ```ts
135
+ import { prismaDSRAdapter } from './adapters/prisma-dsr';
136
+
137
+ const adapter = prismaDSRAdapter(prisma, session.user.email);
138
+ // Pass to useDSR({ adapter }) or call adapter.save(requests) in a route handler
139
+ ```
140
+
141
+ ### Breach adapter
142
+
143
+ ```ts
144
+ import { prismaBreachAdapter } from './adapters/prisma-breach';
145
+
146
+ const adapter = prismaBreachAdapter(prisma);
147
+ // Pass to useBreach({ adapter })
148
+ ```
149
+
150
+ ### ROPA adapter
151
+
152
+ Organisation metadata (name, DPO contact, address) is not stored in the database — supply it when constructing the adapter.
153
+
154
+ ```ts
155
+ import { prismaROPAAdapter } from './adapters/prisma-ropa';
156
+
157
+ const adapter = prismaROPAAdapter(prisma, {
158
+ organizationName: process.env.ORG_NAME!,
159
+ organizationContact: process.env.DPO_EMAIL!,
160
+ organizationAddress: process.env.ORG_ADDRESS!,
161
+ ndpcRegistrationNumber: process.env.NDPC_REG_NUMBER,
162
+ });
163
+ // Pass to useROPA({ adapter })
164
+ ```
165
+
166
+ ---
167
+
168
+ ## Drizzle Adapters
169
+
170
+ The adapters in `src/adapters/drizzle-*.ts` use the same `StorageAdapter<T>` interface but target a Drizzle `db` instance instead of Prisma. The schema lives in `src/drizzle/schema.ts`.
171
+
172
+ ### Setup
173
+
174
+ ```bash
175
+ pnpm add drizzle-orm pg @paralleldrive/cuid2
176
+ pnpm add -D drizzle-kit @types/pg
177
+ ```
178
+
179
+ ```ts
180
+ // src/db.ts
181
+ import { drizzle } from 'drizzle-orm/node-postgres';
182
+ import { Pool } from 'pg';
183
+ import * as schema from './drizzle/schema';
184
+
185
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL });
186
+ export const db = drizzle(pool, { schema });
187
+ ```
188
+
189
+ ### Consent adapter
190
+
191
+ ```ts
192
+ import { drizzleConsentAdapter } from './adapters/drizzle-consent';
193
+
194
+ const adapter = drizzleConsentAdapter(db, session.userId);
195
+ const { settings, updateConsent } = useConsent({ adapter });
196
+ ```
197
+
198
+ ### DSR adapter
199
+
200
+ ```ts
201
+ import { drizzleDSRAdapter } from './adapters/drizzle-dsr';
202
+
203
+ const adapter = drizzleDSRAdapter(db, session.user.email);
204
+ const { requests, submitRequest } = useDSR({ adapter });
205
+ ```
206
+
207
+ ---
208
+
209
+ ## Next.js Integration
210
+
211
+ ### App Router route handlers
212
+
213
+ Copy the API routes from `src/nextjs/app-router/api/` into your project's `app/api/` directory:
214
+
215
+ ```bash
216
+ # Consent management
217
+ cp src/nextjs/app-router/api/consent/route.ts app/api/consent/route.ts
218
+
219
+ # Data subject rights
220
+ cp src/nextjs/app-router/api/dsr/route.ts app/api/dsr/route.ts
221
+
222
+ # Breach reports
223
+ cp src/nextjs/app-router/api/breach/route.ts app/api/breach/route.ts
224
+
225
+ # ROPA
226
+ cp src/nextjs/app-router/api/ropa/route.ts app/api/ropa/route.ts
227
+
228
+ # Compliance score
229
+ cp src/nextjs/app-router/api/compliance/route.ts app/api/compliance/route.ts
230
+ ```
231
+
232
+ Each route is fully documented with its HTTP methods, query params, and body shape at the top of the file.
233
+
234
+ ### Consent middleware (route protection)
235
+
236
+ Protect any route that requires a specific consent type:
237
+
238
+ ```ts
239
+ // app/api/email/marketing/route.ts
240
+ import { consentMiddleware } from '@/ndpr/middleware';
241
+
242
+ export async function POST(req: NextRequest) {
243
+ const guard = await consentMiddleware(req, 'marketing');
244
+ if (guard) return guard; // 403 if consent not granted
245
+
246
+ // Proceed — subject has consented to marketing
247
+ }
248
+ ```
249
+
250
+ Or use the higher-order wrapper:
251
+
252
+ ```ts
253
+ import { withConsent } from '@/ndpr/middleware';
254
+
255
+ export const POST = withConsent('marketing', async (req) => {
256
+ // marketing consent guaranteed here
257
+ });
258
+ ```
259
+
260
+ ### Full layout example
261
+
262
+ See `src/nextjs/app-router/layout-example.tsx` for a complete wiring example. Copy it to `components/ndpr-layout.tsx` and add it to your root layout:
263
+
264
+ ```tsx
265
+ // app/layout.tsx
266
+ import NDPRLayout from '@/components/ndpr-layout';
267
+
268
+ export default async function RootLayout({ children }) {
269
+ const session = await getServerSession();
270
+ return (
271
+ <html lang="en">
272
+ <body>
273
+ <NDPRLayout userId={session?.user?.id}>
274
+ {children}
275
+ </NDPRLayout>
276
+ </body>
277
+ </html>
278
+ );
279
+ }
280
+ ```
281
+
282
+ ---
283
+
284
+ ## Express Integration
285
+
286
+ ### Mount the full compliance router
287
+
288
+ ```ts
289
+ import express from 'express';
290
+ import cookieParser from 'cookie-parser';
291
+ import { createNDPRRouter } from './ndpr/express';
292
+
293
+ const app = express();
294
+ app.use(express.json());
295
+ app.use(cookieParser()); // required for consent cookie fallback
296
+
297
+ // Mount all NDPR compliance routes under /api/ndpr
298
+ app.use('/api/ndpr', createNDPRRouter());
299
+ ```
300
+
301
+ This mounts:
302
+
303
+ | Route | Module |
304
+ |---|---|
305
+ | `GET/POST/DELETE /api/ndpr/consent` | Consent management |
306
+ | `GET/POST/PATCH /api/ndpr/dsr` | Data subject rights |
307
+ | `GET/POST/PATCH /api/ndpr/breach` | Breach notification |
308
+ | `GET/POST/PATCH /api/ndpr/ropa` | Record of Processing Activities |
309
+ | `GET /api/ndpr/compliance` | Compliance score |
310
+
311
+ ### Consent middleware (route protection)
312
+
313
+ ```ts
314
+ import { requireConsent } from './ndpr/express/middleware/consent-check';
315
+
316
+ // Require marketing consent before sending a marketing email
317
+ app.post('/email/marketing', requireConsent('marketing'), sendEmailHandler);
318
+
319
+ // Require multiple consents — all must be granted
320
+ import { requireAllConsents } from './ndpr/express/middleware/consent-check';
321
+ app.post('/profile/analytics', requireAllConsents(['analytics', 'functional']), handler);
322
+ ```
323
+
324
+ ### Use individual routers (granular mounting)
325
+
326
+ ```ts
327
+ import { consentRouter, dsrRouter } from './ndpr/express';
328
+
329
+ // Mount only the routes you need
330
+ app.use('/api/consent', consentRouter);
331
+ app.use('/api/dsr', dsrRouter);
332
+ ```
333
+
334
+ ---
335
+
336
+ ## Full Example
337
+
338
+ Below is the complete `layout-example.tsx` showing the toolkit wired up in a Next.js App Router layout with a server-backed consent adapter:
339
+
340
+ ```tsx
341
+ 'use client';
342
+
343
+ import React from 'react';
344
+ import { NDPRProvider } from '@tantainnovative/ndpr-toolkit/core';
345
+ import { NDPRConsent } from '@tantainnovative/ndpr-toolkit/presets';
346
+ import { apiAdapter } from '@tantainnovative/ndpr-toolkit/adapters';
347
+
348
+ export default function NDPRLayout({
349
+ children,
350
+ userId,
351
+ }: {
352
+ children: React.ReactNode;
353
+ userId?: string;
354
+ }) {
355
+ const subjectId = userId ?? 'anonymous';
356
+
357
+ return (
358
+ <NDPRProvider
359
+ organizationName="Your Company"
360
+ dpoEmail="dpo@yourcompany.ng"
361
+ >
362
+ {children}
363
+
364
+ <NDPRConsent
365
+ adapter={apiAdapter(`/api/consent?subjectId=${subjectId}`)}
366
+ />
367
+ </NDPRProvider>
368
+ );
369
+ }
370
+ ```
371
+
372
+ The `apiAdapter` hits your `/api/consent` route handler (from `src/nextjs/app-router/api/consent/route.ts`), which persists consent to PostgreSQL via Prisma or Drizzle.
373
+
374
+ ---
375
+
376
+ ## Database Schema
377
+
378
+ ### Tables
379
+
380
+ | Table | Description | NDPA reference |
381
+ |---|---|---|
382
+ | `ndpr_consent_records` | Immutable consent audit trail. `revokedAt` marks withdrawal — rows are never deleted. | §25–26 |
383
+ | `ndpr_dsr_requests` | Data subject rights requests. Tracks type, status, and 30-day response deadline. | Part IV §29–36 |
384
+ | `ndpr_breach_reports` | Breach incident records with 72-hour NDPC notification tracking. | §40 |
385
+ | `ndpr_processing_records` | Record of Processing Activities (ROPA). | Accountability principle |
386
+ | `ndpr_audit_log` | Append-only compliance event log. | §44 |
387
+
388
+ ### Consent immutability
389
+
390
+ The consent table follows an immutable-audit pattern: when a subject updates or withdraws consent, the old row has `revokedAt` set and a new row is inserted. At most one row per `subjectId` has `revokedAt = NULL` at any time. This pattern ensures the full consent history is available for regulatory inspection without requiring separate audit log queries.
391
+
392
+ ---
393
+
394
+ ## NDPA Compliance References
395
+
396
+ | Module | NDPA provision |
397
+ |---|---|
398
+ | Consent | Sections 25–26 (lawful basis, consent withdrawal) |
399
+ | Data Subject Rights | Part IV, Sections 29–36 (access, erasure, portability, etc.) |
400
+ | Breach Notification | Section 40 (72-hour notification to NDPC) |
401
+ | ROPA | Accountability principle; Schedule 1, Part 1 |
402
+ | Audit Log | Section 44 (accountability and record-keeping) |
403
+
404
+ ---
405
+
406
+ ## License
407
+
408
+ MIT
package/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@tantainnovative/ndpr-recipes",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "description": "Backend recipes for @tantainnovative/ndpr-toolkit — Prisma schemas, API routes, and ORM adapters for NDPA compliance",
6
+ "license": "MIT",
7
+ "author": {
8
+ "name": "Abraham Esandayinze Tanta",
9
+ "url": "https://linkedin.com/in/mr-tanta"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/mr-tanta/ndpr-toolkit.git",
14
+ "directory": "packages/ndpr-recipes"
15
+ },
16
+ "keywords": ["ndpa", "ndpr", "nigeria", "data-protection", "prisma", "nextjs", "express"],
17
+ "files": [
18
+ "src/**/*",
19
+ "prisma/**/*",
20
+ "README.md"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ }
25
+ }
@@ -0,0 +1,104 @@
1
+ generator client {
2
+ provider = "prisma-client-js"
3
+ }
4
+
5
+ datasource db {
6
+ provider = "postgresql"
7
+ url = env("DATABASE_URL")
8
+ }
9
+
10
+ model ConsentRecord {
11
+ id String @id @default(cuid())
12
+ subjectId String
13
+ consents Json
14
+ version String
15
+ method String
16
+ lawfulBasis String?
17
+ ipAddress String?
18
+ userAgent String?
19
+ createdAt DateTime @default(now())
20
+ revokedAt DateTime?
21
+
22
+ @@index([subjectId])
23
+ @@map("ndpr_consent_records")
24
+ }
25
+
26
+ model DSRRequest {
27
+ id String @id @default(cuid())
28
+ type String
29
+ status String @default("pending")
30
+ subjectName String
31
+ subjectEmail String
32
+ subjectPhone String?
33
+ identifierType String
34
+ identifierValue String
35
+ description String?
36
+ internalNotes String?
37
+ assignedTo String?
38
+ submittedAt DateTime @default(now())
39
+ acknowledgedAt DateTime?
40
+ completedAt DateTime?
41
+ dueAt DateTime
42
+
43
+ @@index([status])
44
+ @@index([subjectEmail])
45
+ @@map("ndpr_dsr_requests")
46
+ }
47
+
48
+ model BreachReport {
49
+ id String @id @default(cuid())
50
+ title String
51
+ description String
52
+ category String
53
+ severity String
54
+ status String @default("ongoing")
55
+ discoveredAt DateTime
56
+ occurredAt DateTime?
57
+ reportedAt DateTime @default(now())
58
+ ndpcNotifiedAt DateTime?
59
+ reporterName String
60
+ reporterEmail String
61
+ reporterDepartment String?
62
+ affectedSystems Json
63
+ dataTypes Json
64
+ estimatedAffected Int?
65
+ initialActions String?
66
+ ndpcNotificationSent Boolean @default(false)
67
+
68
+ @@index([status])
69
+ @@index([severity])
70
+ @@map("ndpr_breach_reports")
71
+ }
72
+
73
+ model ProcessingRecord {
74
+ id String @id @default(cuid())
75
+ purpose String
76
+ lawfulBasis String
77
+ dataCategories Json
78
+ dataSubjects Json
79
+ recipients Json
80
+ retentionPeriod String
81
+ securityMeasures Json
82
+ transferCountries Json?
83
+ transferMechanism String?
84
+ dpiaConducted Boolean @default(false)
85
+ status String @default("active")
86
+ createdAt DateTime @default(now())
87
+ updatedAt DateTime @updatedAt
88
+
89
+ @@map("ndpr_processing_records")
90
+ }
91
+
92
+ model ComplianceAuditLog {
93
+ id String @id @default(cuid())
94
+ module String
95
+ action String
96
+ entityId String
97
+ entityType String
98
+ changes Json?
99
+ performedBy String?
100
+ createdAt DateTime @default(now())
101
+
102
+ @@index([module, entityId])
103
+ @@map("ndpr_audit_log")
104
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Drizzle adapter for the Consent module.
3
+ *
4
+ * Implements StorageAdapter<ConsentSettings> backed by the `ndpr_consent_records`
5
+ * Drizzle table. Follows the same immutable-audit pattern as the Prisma adapter,
6
+ * as required by NDPA Section 25:
7
+ *
8
+ * - SAVE → soft-revokes any existing active record, then inserts a new row.
9
+ * - LOAD → returns the most recent non-revoked record for the subject.
10
+ * - REMOVE → soft-deletes by setting revokedAt on the active record (no hard deletes).
11
+ *
12
+ * Usage
13
+ * -----
14
+ * Copy this file into your project alongside your Drizzle client, then wire it
15
+ * into the toolkit hook:
16
+ *
17
+ * import { drizzle } from 'drizzle-orm/node-postgres';
18
+ * import { Pool } from 'pg';
19
+ * import { useConsent } from '@tantainnovative/ndpr-toolkit';
20
+ * import { drizzleConsentAdapter } from './adapters/drizzle-consent';
21
+ * import * as schema from './drizzle/schema';
22
+ *
23
+ * const pool = new Pool({ connectionString: process.env.DATABASE_URL });
24
+ * const db = drizzle(pool, { schema });
25
+ *
26
+ * function MyApp() {
27
+ * const adapter = drizzleConsentAdapter(db, session.userId);
28
+ * const { settings, updateConsent } = useConsent({ adapter });
29
+ * // ...
30
+ * }
31
+ *
32
+ * Prerequisites
33
+ * -------------
34
+ * - The `ndpr_consent_records` table must exist (run your Drizzle migration).
35
+ * - `drizzle-orm` must be installed in your project.
36
+ * - `@tantainnovative/ndpr-toolkit` must be installed in your project.
37
+ *
38
+ * @module adapters/drizzle-consent
39
+ */
40
+
41
+ import { eq, and, isNull, desc } from 'drizzle-orm';
42
+ import type { StorageAdapter } from '@tantainnovative/ndpr-toolkit';
43
+ import type { ConsentSettings } from '@tantainnovative/ndpr-toolkit';
44
+ import { consentRecords } from '../drizzle/schema';
45
+
46
+ /**
47
+ * Creates a Drizzle-backed StorageAdapter for ConsentSettings.
48
+ *
49
+ * @param db - Your Drizzle database instance (any driver — pg, neon, libsql, etc.)
50
+ * @param subjectId - Stable identifier for the data subject (e.g. user ID, session ID).
51
+ * This scopes all queries and is stored on every record.
52
+ * @returns A StorageAdapter<ConsentSettings> ready to pass to useConsent().
53
+ */
54
+ export function drizzleConsentAdapter(
55
+ db: any,
56
+ subjectId: string,
57
+ ): StorageAdapter<ConsentSettings> {
58
+ return {
59
+ /**
60
+ * Load the latest active (non-revoked) consent record for the subject.
61
+ *
62
+ * Uses Drizzle's type-safe query builder to select the most recent row
63
+ * where `subjectId` matches and `revokedAt` is NULL.
64
+ *
65
+ * Returns null if no record exists — the toolkit hook treats this as
66
+ * "no consent given" and displays the consent banner.
67
+ */
68
+ async load(): Promise<ConsentSettings | null> {
69
+ const rows = await db
70
+ .select()
71
+ .from(consentRecords)
72
+ .where(and(eq(consentRecords.subjectId, subjectId), isNull(consentRecords.revokedAt)))
73
+ .orderBy(desc(consentRecords.createdAt))
74
+ .limit(1);
75
+
76
+ const record = rows[0];
77
+ if (!record) return null;
78
+
79
+ // Reconstruct the ConsentSettings shape expected by the toolkit hook.
80
+ return {
81
+ consents: record.consents as Record<string, boolean>,
82
+ timestamp: record.createdAt.getTime(),
83
+ version: record.version,
84
+ method: record.method,
85
+ hasInteracted: true,
86
+ lawfulBasis: (record.lawfulBasis as ConsentSettings['lawfulBasis']) ?? undefined,
87
+ };
88
+ },
89
+
90
+ /**
91
+ * Persist new consent settings.
92
+ *
93
+ * Step 1: Revoke all currently active records for this subject by setting
94
+ * revokedAt = now(). This preserves the audit trail as required by
95
+ * NDPA Section 25 — the old consent history is never erased.
96
+ *
97
+ * Step 2: Insert a fresh record representing the new consent state.
98
+ *
99
+ * Both steps are performed sequentially (not in a transaction by default).
100
+ * Wrap this in a Drizzle transaction if your database supports it and you
101
+ * need atomicity guarantees.
102
+ */
103
+ async save(data: ConsentSettings): Promise<void> {
104
+ // Step 1: Revoke all currently active records for this subject.
105
+ await db
106
+ .update(consentRecords)
107
+ .set({ revokedAt: new Date() })
108
+ .where(and(eq(consentRecords.subjectId, subjectId), isNull(consentRecords.revokedAt)));
109
+
110
+ // Step 2: Insert the new consent record.
111
+ await db.insert(consentRecords).values({
112
+ subjectId,
113
+ consents: data.consents,
114
+ version: data.version,
115
+ method: data.method,
116
+ lawfulBasis: data.lawfulBasis ?? null,
117
+ // Pass ipAddress / userAgent by extending this adapter to accept
118
+ // a RequestContext parameter if you need to capture them.
119
+ });
120
+ },
121
+
122
+ /**
123
+ * Revoke the current consent record for the subject without deleting it.
124
+ *
125
+ * Hard deletes are never performed so the compliance audit trail is preserved
126
+ * for NDPA Section 26 (right to withdraw consent) accountability purposes.
127
+ */
128
+ async remove(): Promise<void> {
129
+ await db
130
+ .update(consentRecords)
131
+ .set({ revokedAt: new Date() })
132
+ .where(and(eq(consentRecords.subjectId, subjectId), isNull(consentRecords.revokedAt)));
133
+ },
134
+ };
135
+ }