@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 +408 -0
- package/package.json +25 -0
- package/prisma/schema.prisma +104 -0
- package/src/adapters/drizzle-consent.ts +135 -0
- package/src/adapters/drizzle-dsr.ts +190 -0
- package/src/adapters/index.ts +39 -0
- package/src/adapters/prisma-breach.ts +181 -0
- package/src/adapters/prisma-consent.ts +129 -0
- package/src/adapters/prisma-dsr.ts +163 -0
- package/src/adapters/prisma-ropa.ts +205 -0
- package/src/drizzle/schema.ts +484 -0
- package/src/express/index.ts +82 -0
- package/src/express/middleware/consent-check.ts +133 -0
- package/src/express/routes/breach.ts +259 -0
- package/src/express/routes/compliance.ts +130 -0
- package/src/express/routes/consent.ts +163 -0
- package/src/express/routes/dsr.ts +203 -0
- package/src/express/routes/ropa.ts +225 -0
- package/src/nextjs/app-router/api/breach/[id]/route.ts +121 -0
- package/src/nextjs/app-router/api/breach/route.ts +182 -0
- package/src/nextjs/app-router/api/compliance/route.ts +162 -0
- package/src/nextjs/app-router/api/consent/route.ts +161 -0
- package/src/nextjs/app-router/api/dsr/[id]/route.ts +115 -0
- package/src/nextjs/app-router/api/dsr/route.ts +128 -0
- package/src/nextjs/app-router/api/ropa/route.ts +234 -0
- package/src/nextjs/app-router/layout-example.tsx +178 -0
- package/src/nextjs/app-router/middleware.ts +113 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js App Router — DSR (Data Subject Rights) List/Create Route
|
|
3
|
+
*
|
|
4
|
+
* Handles listing and creating Data Subject Rights requests as required by
|
|
5
|
+
* NDPA Sections 34–38 (rights to access, rectification, erasure, portability,
|
|
6
|
+
* and objection). All requests must be acknowledged within 72 hours and
|
|
7
|
+
* fulfilled within 30 days under the Act.
|
|
8
|
+
*
|
|
9
|
+
* Endpoints
|
|
10
|
+
* ---------
|
|
11
|
+
* GET /api/dsr — List DSR requests (optional ?status= filter)
|
|
12
|
+
* POST /api/dsr — Submit a new DSR request
|
|
13
|
+
*
|
|
14
|
+
* How to use
|
|
15
|
+
* ----------
|
|
16
|
+
* Copy this file to `app/api/dsr/route.ts` in your Next.js project.
|
|
17
|
+
* For single-request operations see `app/api/dsr/[id]/route.ts`.
|
|
18
|
+
*
|
|
19
|
+
* @module dsr/route
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
23
|
+
import { PrismaClient } from '@prisma/client';
|
|
24
|
+
|
|
25
|
+
const prisma = new PrismaClient();
|
|
26
|
+
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// GET /api/dsr?status=pending
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* List all DSR requests, optionally filtered by status.
|
|
33
|
+
*
|
|
34
|
+
* Supports the admin/DPO dashboard — returns requests ordered newest-first
|
|
35
|
+
* so overdue items surface quickly. The optional `status` query parameter
|
|
36
|
+
* lets you display only pending, in-progress, or completed requests.
|
|
37
|
+
*
|
|
38
|
+
* Query params:
|
|
39
|
+
* status (optional) — filter by request status: pending | in_progress | completed | rejected
|
|
40
|
+
*
|
|
41
|
+
* Returns 200 with an array of DSRRequest rows.
|
|
42
|
+
*/
|
|
43
|
+
export async function GET(req: NextRequest) {
|
|
44
|
+
const status = req.nextUrl.searchParams.get('status');
|
|
45
|
+
|
|
46
|
+
const requests = await prisma.dSRRequest.findMany({
|
|
47
|
+
where: status ? { status } : undefined,
|
|
48
|
+
orderBy: { submittedAt: 'desc' },
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return NextResponse.json(requests);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// POST /api/dsr
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Submit a new Data Subject Rights request.
|
|
60
|
+
*
|
|
61
|
+
* The NDPA requires organisations to provide a clear mechanism for data
|
|
62
|
+
* subjects to exercise their rights (Section 34). This route:
|
|
63
|
+
* 1. Validates required fields.
|
|
64
|
+
* 2. Calculates the statutory 30-day deadline (dueAt).
|
|
65
|
+
* 3. Persists the request with status 'pending'.
|
|
66
|
+
* 4. Writes an audit log entry for accountability.
|
|
67
|
+
*
|
|
68
|
+
* Body (JSON):
|
|
69
|
+
* type (required) — access | rectification | erasure | portability | objection
|
|
70
|
+
* subjectName (required) — full name of the data subject
|
|
71
|
+
* subjectEmail (required) — email address of the data subject
|
|
72
|
+
* identifierType (required) — how the subject is identified (e.g. 'email', 'account_id')
|
|
73
|
+
* identifierValue (required) — the subject's identifier value
|
|
74
|
+
* subjectPhone (optional) — phone number
|
|
75
|
+
* description (optional) — additional context from the subject
|
|
76
|
+
*
|
|
77
|
+
* Returns 201 with the newly created DSRRequest row.
|
|
78
|
+
*/
|
|
79
|
+
export async function POST(req: NextRequest) {
|
|
80
|
+
const body = await req.json();
|
|
81
|
+
const {
|
|
82
|
+
type,
|
|
83
|
+
subjectName,
|
|
84
|
+
subjectEmail,
|
|
85
|
+
subjectPhone,
|
|
86
|
+
identifierType,
|
|
87
|
+
identifierValue,
|
|
88
|
+
description,
|
|
89
|
+
} = body;
|
|
90
|
+
|
|
91
|
+
if (!type || !subjectName || !subjectEmail || !identifierType || !identifierValue) {
|
|
92
|
+
return NextResponse.json(
|
|
93
|
+
{ error: 'type, subjectName, subjectEmail, identifierType, and identifierValue are required' },
|
|
94
|
+
{ status: 400 },
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// NDPA mandates a 30-day response window from the date of submission.
|
|
99
|
+
const dueAt = new Date();
|
|
100
|
+
dueAt.setDate(dueAt.getDate() + 30);
|
|
101
|
+
|
|
102
|
+
const request = await prisma.dSRRequest.create({
|
|
103
|
+
data: {
|
|
104
|
+
type,
|
|
105
|
+
subjectName,
|
|
106
|
+
subjectEmail,
|
|
107
|
+
subjectPhone: subjectPhone ?? null,
|
|
108
|
+
identifierType,
|
|
109
|
+
identifierValue,
|
|
110
|
+
description: description ?? null,
|
|
111
|
+
status: 'pending',
|
|
112
|
+
dueAt,
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Audit log for NDPA Section 44 accountability principle.
|
|
117
|
+
await prisma.complianceAuditLog.create({
|
|
118
|
+
data: {
|
|
119
|
+
module: 'dsr',
|
|
120
|
+
action: 'submitted',
|
|
121
|
+
entityId: request.id,
|
|
122
|
+
entityType: 'DSRRequest',
|
|
123
|
+
changes: { type, subjectEmail, status: 'pending' },
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
return NextResponse.json(request, { status: 201 });
|
|
128
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js App Router — ROPA (Record of Processing Activities) Route
|
|
3
|
+
*
|
|
4
|
+
* Handles listing, creating, updating, and archiving processing activity records.
|
|
5
|
+
* Under the NDPA accountability principle (analogous to GDPR Article 30), data
|
|
6
|
+
* controllers with more than 250 employees — or that process sensitive data —
|
|
7
|
+
* must maintain a Record of Processing Activities (ROPA).
|
|
8
|
+
*
|
|
9
|
+
* Endpoints
|
|
10
|
+
* ---------
|
|
11
|
+
* GET /api/ropa — List all processing records (optional ?status= filter)
|
|
12
|
+
* POST /api/ropa — Create a new processing record
|
|
13
|
+
* PATCH /api/ropa — Update an existing processing record (body includes `id`)
|
|
14
|
+
* DELETE /api/ropa?id=xxx — Archive a processing record (soft delete)
|
|
15
|
+
*
|
|
16
|
+
* How to use
|
|
17
|
+
* ----------
|
|
18
|
+
* Copy this file to `app/api/ropa/route.ts` in your Next.js project.
|
|
19
|
+
*
|
|
20
|
+
* @module ropa/route
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
24
|
+
import { PrismaClient } from '@prisma/client';
|
|
25
|
+
|
|
26
|
+
const prisma = new PrismaClient();
|
|
27
|
+
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// GET /api/ropa?status=active
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* List all processing activity records.
|
|
34
|
+
*
|
|
35
|
+
* Returns records ordered by creation date ascending so the ROPA reads
|
|
36
|
+
* chronologically. Filter by status to distinguish active from archived entries.
|
|
37
|
+
*
|
|
38
|
+
* Query params:
|
|
39
|
+
* status (optional) — active | archived (default: returns all)
|
|
40
|
+
*
|
|
41
|
+
* Returns 200 with an array of ProcessingRecord rows.
|
|
42
|
+
*/
|
|
43
|
+
export async function GET(req: NextRequest) {
|
|
44
|
+
const status = req.nextUrl.searchParams.get('status');
|
|
45
|
+
|
|
46
|
+
const records = await prisma.processingRecord.findMany({
|
|
47
|
+
where: status ? { status } : undefined,
|
|
48
|
+
orderBy: { createdAt: 'asc' },
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return NextResponse.json(records);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// POST /api/ropa
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Create a new processing activity record.
|
|
60
|
+
*
|
|
61
|
+
* Each record documents a single processing activity — for example, "Customer
|
|
62
|
+
* order processing" or "Marketing email campaigns". Together, all active records
|
|
63
|
+
* form the organisation's ROPA as required under the NDPA accountability principle.
|
|
64
|
+
*
|
|
65
|
+
* Body (JSON):
|
|
66
|
+
* purpose (required) — description of the processing activity
|
|
67
|
+
* lawfulBasis (required) — consent | contract | legal_obligation | vital_interests
|
|
68
|
+
* | public_task | legitimate_interests
|
|
69
|
+
* dataCategories (required) — array of data category labels (e.g. ['name', 'email'])
|
|
70
|
+
* dataSubjects (required) — array of subject category labels (e.g. ['customers'])
|
|
71
|
+
* recipients (required) — array of recipient labels (e.g. ['payment processor'])
|
|
72
|
+
* retentionPeriod (required) — human-readable retention policy (e.g. '7 years')
|
|
73
|
+
* securityMeasures (required) — array of security measures in place
|
|
74
|
+
* transferCountries (optional) — array of countries receiving cross-border transfers
|
|
75
|
+
* transferMechanism (optional) — legal mechanism for transfers (e.g. 'adequacy decision')
|
|
76
|
+
* dpiaConducted (optional) — whether a DPIA has been performed (default false)
|
|
77
|
+
*
|
|
78
|
+
* Returns 201 with the newly created ProcessingRecord row.
|
|
79
|
+
*/
|
|
80
|
+
export async function POST(req: NextRequest) {
|
|
81
|
+
const body = await req.json();
|
|
82
|
+
const {
|
|
83
|
+
purpose,
|
|
84
|
+
lawfulBasis,
|
|
85
|
+
dataCategories,
|
|
86
|
+
dataSubjects,
|
|
87
|
+
recipients,
|
|
88
|
+
retentionPeriod,
|
|
89
|
+
securityMeasures,
|
|
90
|
+
transferCountries,
|
|
91
|
+
transferMechanism,
|
|
92
|
+
dpiaConducted,
|
|
93
|
+
} = body;
|
|
94
|
+
|
|
95
|
+
if (
|
|
96
|
+
!purpose ||
|
|
97
|
+
!lawfulBasis ||
|
|
98
|
+
!Array.isArray(dataCategories) ||
|
|
99
|
+
!Array.isArray(dataSubjects) ||
|
|
100
|
+
!Array.isArray(recipients) ||
|
|
101
|
+
!retentionPeriod ||
|
|
102
|
+
!Array.isArray(securityMeasures)
|
|
103
|
+
) {
|
|
104
|
+
return NextResponse.json(
|
|
105
|
+
{
|
|
106
|
+
error:
|
|
107
|
+
'purpose, lawfulBasis, dataCategories, dataSubjects, recipients, retentionPeriod, and securityMeasures are required',
|
|
108
|
+
},
|
|
109
|
+
{ status: 400 },
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const record = await prisma.processingRecord.create({
|
|
114
|
+
data: {
|
|
115
|
+
purpose,
|
|
116
|
+
lawfulBasis,
|
|
117
|
+
dataCategories,
|
|
118
|
+
dataSubjects,
|
|
119
|
+
recipients,
|
|
120
|
+
retentionPeriod,
|
|
121
|
+
securityMeasures,
|
|
122
|
+
transferCountries: transferCountries ?? null,
|
|
123
|
+
transferMechanism: transferMechanism ?? null,
|
|
124
|
+
dpiaConducted: dpiaConducted ?? false,
|
|
125
|
+
status: 'active',
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await prisma.complianceAuditLog.create({
|
|
130
|
+
data: {
|
|
131
|
+
module: 'ropa',
|
|
132
|
+
action: 'created',
|
|
133
|
+
entityId: record.id,
|
|
134
|
+
entityType: 'ProcessingRecord',
|
|
135
|
+
changes: { purpose, lawfulBasis, status: 'active' },
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
return NextResponse.json(record, { status: 201 });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// PATCH /api/ropa
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Update an existing processing activity record.
|
|
148
|
+
*
|
|
149
|
+
* Call this when a processing activity changes — e.g. a new data category
|
|
150
|
+
* is added, the retention period changes, or a DPIA is conducted. Keeping
|
|
151
|
+
* the ROPA up to date is part of the NDPA accountability obligation.
|
|
152
|
+
*
|
|
153
|
+
* Body (JSON):
|
|
154
|
+
* id (required) — ID of the ProcessingRecord to update
|
|
155
|
+
* ... (all other fields from POST are optional — only send what changed)
|
|
156
|
+
*
|
|
157
|
+
* Returns 200 with the updated ProcessingRecord row.
|
|
158
|
+
*/
|
|
159
|
+
export async function PATCH(req: NextRequest) {
|
|
160
|
+
const body = await req.json();
|
|
161
|
+
const { id, ...fields } = body;
|
|
162
|
+
|
|
163
|
+
if (!id) {
|
|
164
|
+
return NextResponse.json({ error: 'id is required in the request body' }, { status: 400 });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const existing = await prisma.processingRecord.findUnique({ where: { id } });
|
|
168
|
+
if (!existing) {
|
|
169
|
+
return NextResponse.json({ error: 'Processing record not found' }, { status: 404 });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const updated = await prisma.processingRecord.update({
|
|
173
|
+
where: { id },
|
|
174
|
+
data: fields,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await prisma.complianceAuditLog.create({
|
|
178
|
+
data: {
|
|
179
|
+
module: 'ropa',
|
|
180
|
+
action: 'updated',
|
|
181
|
+
entityId: id,
|
|
182
|
+
entityType: 'ProcessingRecord',
|
|
183
|
+
changes: fields,
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
return NextResponse.json(updated);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// DELETE /api/ropa?id=xxx
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Archive a processing activity record (soft delete).
|
|
196
|
+
*
|
|
197
|
+
* Records are never hard-deleted — archiving sets status to 'archived' so
|
|
198
|
+
* the historical ROPA remains available for regulatory review. This supports
|
|
199
|
+
* the NDPA accountability principle requirement to demonstrate compliance
|
|
200
|
+
* over time, not just in the present.
|
|
201
|
+
*
|
|
202
|
+
* Query params:
|
|
203
|
+
* id (required) — ID of the ProcessingRecord to archive
|
|
204
|
+
*
|
|
205
|
+
* Returns 200 `{ success: true }` when complete.
|
|
206
|
+
*/
|
|
207
|
+
export async function DELETE(req: NextRequest) {
|
|
208
|
+
const id = req.nextUrl.searchParams.get('id');
|
|
209
|
+
|
|
210
|
+
if (!id) {
|
|
211
|
+
return NextResponse.json({ error: 'id query parameter required' }, { status: 400 });
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const existing = await prisma.processingRecord.findUnique({ where: { id } });
|
|
215
|
+
if (!existing) {
|
|
216
|
+
return NextResponse.json({ error: 'Processing record not found' }, { status: 404 });
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
await prisma.processingRecord.update({
|
|
220
|
+
where: { id },
|
|
221
|
+
data: { status: 'archived' },
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
await prisma.complianceAuditLog.create({
|
|
225
|
+
data: {
|
|
226
|
+
module: 'ropa',
|
|
227
|
+
action: 'archived',
|
|
228
|
+
entityId: id,
|
|
229
|
+
entityType: 'ProcessingRecord',
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
return NextResponse.json({ success: true });
|
|
234
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Example: Full NDPA compliance wiring in a Next.js App Router layout.
|
|
3
|
+
*
|
|
4
|
+
* This file shows how to integrate the @tantainnovative/ndpr-toolkit consent
|
|
5
|
+
* banner with a persistent backend using the Prisma adapter. The pattern works
|
|
6
|
+
* for both the built-in Prisma adapter and the Drizzle adapter — swap out
|
|
7
|
+
* `prismaConsentAdapter` for `drizzleConsentAdapter` if you use Drizzle.
|
|
8
|
+
*
|
|
9
|
+
* What this example covers
|
|
10
|
+
* ------------------------
|
|
11
|
+
* - Wrapping your app with NDPRProvider so all child components can access the
|
|
12
|
+
* consent context via `useConsent()`.
|
|
13
|
+
* - Rendering the NDPRConsent banner with a server-backed storage adapter so
|
|
14
|
+
* consent decisions survive page refreshes and browser restarts.
|
|
15
|
+
* - Wiring the apiAdapter to your Next.js API route (`/api/consent`) for a
|
|
16
|
+
* fully decoupled client ↔ server consent flow.
|
|
17
|
+
*
|
|
18
|
+
* Copy this file, adapt the userId retrieval to your auth system, and you're done.
|
|
19
|
+
*
|
|
20
|
+
* @see prisma/schema.prisma — for the database schema
|
|
21
|
+
* @see src/nextjs/app-router/api/ — for the API route implementations
|
|
22
|
+
* @see src/adapters/prisma-consent.ts — for the server-side Prisma adapter
|
|
23
|
+
*
|
|
24
|
+
* @module nextjs/app-router/layout-example
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
'use client';
|
|
28
|
+
|
|
29
|
+
import React from 'react';
|
|
30
|
+
import { NDPRProvider } from '@tantainnovative/ndpr-toolkit/core';
|
|
31
|
+
import { NDPRConsent } from '@tantainnovative/ndpr-toolkit/presets';
|
|
32
|
+
import { apiAdapter } from '@tantainnovative/ndpr-toolkit/adapters';
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Types
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
interface NDPRLayoutProps {
|
|
39
|
+
children: React.ReactNode;
|
|
40
|
+
/**
|
|
41
|
+
* Optional: pass the authenticated user's ID down from your root layout.
|
|
42
|
+
* In a real app this comes from your session (NextAuth, Clerk, Supabase Auth, etc.).
|
|
43
|
+
*
|
|
44
|
+
* If no userId is available (unauthenticated visitor), pass a stable anonymous
|
|
45
|
+
* identifier instead — e.g. a UUID stored in a cookie. Consent must still be
|
|
46
|
+
* obtained even for non-authenticated users.
|
|
47
|
+
*/
|
|
48
|
+
userId?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Layout component
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* NDPRLayout — wraps your app with consent management and the banner UI.
|
|
57
|
+
*
|
|
58
|
+
* Recommended placement: inside your root `app/layout.tsx`, wrapped around
|
|
59
|
+
* the `{children}` slot. Example:
|
|
60
|
+
*
|
|
61
|
+
* // app/layout.tsx
|
|
62
|
+
* import { NDPRLayout } from '@/components/ndpr-layout';
|
|
63
|
+
*
|
|
64
|
+
* export default async function RootLayout({ children }) {
|
|
65
|
+
* const session = await getServerSession();
|
|
66
|
+
* return (
|
|
67
|
+
* <html lang="en">
|
|
68
|
+
* <body>
|
|
69
|
+
* <NDPRLayout userId={session?.user?.id}>
|
|
70
|
+
* {children}
|
|
71
|
+
* </NDPRLayout>
|
|
72
|
+
* </body>
|
|
73
|
+
* </html>
|
|
74
|
+
* );
|
|
75
|
+
* }
|
|
76
|
+
*
|
|
77
|
+
* Note: This component is marked 'use client' because NDPRProvider and the
|
|
78
|
+
* consent banner both require client-side interactivity. The session/userId
|
|
79
|
+
* should be resolved in the parent Server Component and passed as a prop.
|
|
80
|
+
*/
|
|
81
|
+
export default function NDPRLayout({ children, userId }: NDPRLayoutProps) {
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Subject identification
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
// In a real application, replace this with your actual auth system:
|
|
87
|
+
// const { data: session } = useSession(); // NextAuth
|
|
88
|
+
// const { user } = useUser(); // Clerk
|
|
89
|
+
// const { session } = useSessionContext(); // Supabase Auth
|
|
90
|
+
//
|
|
91
|
+
// For anonymous visitors: generate and persist a UUID in localStorage or a cookie
|
|
92
|
+
// so consent decisions are stable across page reloads.
|
|
93
|
+
const subjectId = userId ?? 'anonymous';
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Adapter
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
// apiAdapter points at the Next.js API route that wraps the Prisma/Drizzle adapter.
|
|
100
|
+
// The route is implemented in `src/nextjs/app-router/api/consent/route.ts`.
|
|
101
|
+
//
|
|
102
|
+
// For direct server-side use (SSR / Server Components), import and call
|
|
103
|
+
// `prismaConsentAdapter(prisma, subjectId)` directly instead.
|
|
104
|
+
const consentStorageAdapter = apiAdapter(`/api/consent?subjectId=${subjectId}`);
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Render
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<NDPRProvider
|
|
112
|
+
/**
|
|
113
|
+
* Your organisation's legal name — displayed in the consent banner headline
|
|
114
|
+
* and in the Data Subject Rights portal.
|
|
115
|
+
*/
|
|
116
|
+
organizationName="Your Company"
|
|
117
|
+
/**
|
|
118
|
+
* DPO or privacy contact email — shown to data subjects who want to exercise
|
|
119
|
+
* their rights or ask questions about data processing.
|
|
120
|
+
*/
|
|
121
|
+
dpoEmail="dpo@yourcompany.ng"
|
|
122
|
+
>
|
|
123
|
+
{/* Render your application's pages and components */}
|
|
124
|
+
{children}
|
|
125
|
+
|
|
126
|
+
{/*
|
|
127
|
+
* NDPRConsent — the NDPA-compliant consent banner.
|
|
128
|
+
*
|
|
129
|
+
* The `adapter` prop connects the banner to your database so consent
|
|
130
|
+
* decisions are persisted server-side and survive page refreshes.
|
|
131
|
+
*
|
|
132
|
+
* On first visit: the banner is displayed and the subject can accept,
|
|
133
|
+
* decline, or customise individual consent categories.
|
|
134
|
+
*
|
|
135
|
+
* On subsequent visits: the adapter loads the stored decision and the
|
|
136
|
+
* banner is hidden (no flicker, no double-prompt).
|
|
137
|
+
*
|
|
138
|
+
* The banner automatically handles:
|
|
139
|
+
* - NDPA §25 — recording the lawful basis and consent version
|
|
140
|
+
* - NDPA §26 — providing a clear withdrawal mechanism
|
|
141
|
+
* - Immutable audit trail — every change is stored as a new record
|
|
142
|
+
*/}
|
|
143
|
+
<NDPRConsent adapter={consentStorageAdapter} />
|
|
144
|
+
</NDPRProvider>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
// Alternative: using the Drizzle adapter directly (no API route needed)
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
//
|
|
152
|
+
// If you prefer to use the Drizzle adapter on the server side inside a
|
|
153
|
+
// Server Component or a Route Handler, here is how to wire it:
|
|
154
|
+
//
|
|
155
|
+
// import { drizzle } from 'drizzle-orm/node-postgres';
|
|
156
|
+
// import { Pool } from 'pg';
|
|
157
|
+
// import { drizzleConsentAdapter } from '@/adapters/drizzle-consent';
|
|
158
|
+
// import * as schema from '@/drizzle/schema';
|
|
159
|
+
//
|
|
160
|
+
// const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
161
|
+
// const db = drizzle(pool, { schema });
|
|
162
|
+
//
|
|
163
|
+
// // In a Server Component or API Route:
|
|
164
|
+
// const adapter = drizzleConsentAdapter(db, subjectId);
|
|
165
|
+
// const settings = await adapter.load();
|
|
166
|
+
//
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// Alternative: using the Prisma adapter directly (no API route needed)
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
//
|
|
171
|
+
// import { PrismaClient } from '@prisma/client';
|
|
172
|
+
// import { prismaConsentAdapter } from '@/adapters/prisma-consent';
|
|
173
|
+
//
|
|
174
|
+
// const prisma = new PrismaClient();
|
|
175
|
+
//
|
|
176
|
+
// // In a Server Component or API Route:
|
|
177
|
+
// const adapter = prismaConsentAdapter(prisma, subjectId);
|
|
178
|
+
// const settings = await adapter.load();
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js App Router — Consent Middleware
|
|
3
|
+
*
|
|
4
|
+
* Provides a reusable consent-gate helper that can be called inside any
|
|
5
|
+
* App Router route handler (or in Next.js middleware.ts at the edge) to verify
|
|
6
|
+
* that a data subject has granted the required consent before the route proceeds.
|
|
7
|
+
*
|
|
8
|
+
* NDPA Section 25 requires that processing based on consent only happens when
|
|
9
|
+
* the data subject has freely given specific, informed, and unambiguous consent.
|
|
10
|
+
* This helper enforces that requirement at the HTTP layer.
|
|
11
|
+
*
|
|
12
|
+
* How to use
|
|
13
|
+
* ----------
|
|
14
|
+
* Call `consentMiddleware(req, 'marketing')` at the top of any route handler
|
|
15
|
+
* that requires a specific consent type. If the function returns a Response,
|
|
16
|
+
* return it immediately; otherwise continue with your logic:
|
|
17
|
+
*
|
|
18
|
+
* import { consentMiddleware } from '../middleware';
|
|
19
|
+
*
|
|
20
|
+
* export async function POST(req: NextRequest) {
|
|
21
|
+
* const guard = await consentMiddleware(req, 'marketing');
|
|
22
|
+
* if (guard) return guard; // blocked — return 403
|
|
23
|
+
*
|
|
24
|
+
* // subject has consented — proceed
|
|
25
|
+
* }
|
|
26
|
+
*
|
|
27
|
+
* Subject identification
|
|
28
|
+
* ----------------------
|
|
29
|
+
* The helper looks for a subject identifier in two places (in priority order):
|
|
30
|
+
* 1. The `x-subject-id` request header (set by your auth middleware or JWT)
|
|
31
|
+
* 2. The `subject_id` cookie (set by your consent banner on the client)
|
|
32
|
+
*
|
|
33
|
+
* @module middleware
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
37
|
+
import { PrismaClient } from '@prisma/client';
|
|
38
|
+
|
|
39
|
+
const prisma = new PrismaClient();
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Verify that the requesting data subject has granted the specified consent type.
|
|
43
|
+
*
|
|
44
|
+
* @param req - The incoming NextRequest
|
|
45
|
+
* @param requiredConsent - The consent category key to check (e.g. 'marketing', 'analytics')
|
|
46
|
+
* @returns null if the subject has consented (allow through), or a NextResponse with
|
|
47
|
+
* status 403 and an error body if the check fails (block the request).
|
|
48
|
+
*/
|
|
49
|
+
export async function consentMiddleware(
|
|
50
|
+
req: NextRequest,
|
|
51
|
+
requiredConsent: string,
|
|
52
|
+
): Promise<NextResponse | null> {
|
|
53
|
+
// Resolve the subject identifier from the header or cookie.
|
|
54
|
+
const subjectId =
|
|
55
|
+
req.headers.get('x-subject-id') ?? req.cookies.get('subject_id')?.value ?? null;
|
|
56
|
+
|
|
57
|
+
if (!subjectId) {
|
|
58
|
+
return NextResponse.json(
|
|
59
|
+
{ error: 'Consent verification required' },
|
|
60
|
+
{ status: 403 },
|
|
61
|
+
);
|
|
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
|
+
return NextResponse.json(
|
|
72
|
+
{ error: 'No consent on record' },
|
|
73
|
+
{ status: 403 },
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Check that the specific consent type has been granted.
|
|
78
|
+
const consents = record.consents as Record<string, boolean>;
|
|
79
|
+
|
|
80
|
+
if (!consents[requiredConsent]) {
|
|
81
|
+
return NextResponse.json(
|
|
82
|
+
{ error: `Consent for "${requiredConsent}" not granted` },
|
|
83
|
+
{ status: 403 },
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Subject has consented — allow the request to proceed.
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Higher-order helper: wraps a route handler with a consent gate.
|
|
93
|
+
*
|
|
94
|
+
* This is an alternative to calling consentMiddleware() manually at the top
|
|
95
|
+
* of every handler. Use it when all methods on a route require the same consent.
|
|
96
|
+
*
|
|
97
|
+
* export const POST = withConsent('marketing', async (req) => {
|
|
98
|
+
* // marketing consent guaranteed here
|
|
99
|
+
* });
|
|
100
|
+
*
|
|
101
|
+
* @param requiredConsent - The consent category to enforce
|
|
102
|
+
* @param handler - The route handler to wrap
|
|
103
|
+
*/
|
|
104
|
+
export function withConsent(
|
|
105
|
+
requiredConsent: string,
|
|
106
|
+
handler: (req: NextRequest, ctx: any) => Promise<NextResponse>,
|
|
107
|
+
) {
|
|
108
|
+
return async (req: NextRequest, ctx: any): Promise<NextResponse> => {
|
|
109
|
+
const guard = await consentMiddleware(req, requiredConsent);
|
|
110
|
+
if (guard) return guard;
|
|
111
|
+
return handler(req, ctx);
|
|
112
|
+
};
|
|
113
|
+
}
|