@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,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drizzle adapter for the Data Subject Rights (DSR) module.
|
|
3
|
+
*
|
|
4
|
+
* Implements StorageAdapter<DSRRequest[]> backed by the `ndpr_dsr_requests`
|
|
5
|
+
* Drizzle table.
|
|
6
|
+
*
|
|
7
|
+
* Behaviour
|
|
8
|
+
* ---------
|
|
9
|
+
* - LOAD → returns all DSR requests submitted by the given subject email,
|
|
10
|
+
* ordered newest first.
|
|
11
|
+
* - SAVE → upserts each request in the array by ID using Drizzle's
|
|
12
|
+
* `onConflictDoUpdate` pattern.
|
|
13
|
+
* - REMOVE → soft-deletes open requests by setting status to 'rejected' with
|
|
14
|
+
* an internal note (no hard deletes — preserves the audit trail).
|
|
15
|
+
*
|
|
16
|
+
* Usage
|
|
17
|
+
* -----
|
|
18
|
+
* Copy this file into your project alongside your Drizzle client, then wire it
|
|
19
|
+
* into the toolkit hook:
|
|
20
|
+
*
|
|
21
|
+
* import { drizzle } from 'drizzle-orm/node-postgres';
|
|
22
|
+
* import { Pool } from 'pg';
|
|
23
|
+
* import { useDSR } from '@tantainnovative/ndpr-toolkit';
|
|
24
|
+
* import { drizzleDSRAdapter } from './adapters/drizzle-dsr';
|
|
25
|
+
* import * as schema from './drizzle/schema';
|
|
26
|
+
*
|
|
27
|
+
* const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
|
28
|
+
* const db = drizzle(pool, { schema });
|
|
29
|
+
*
|
|
30
|
+
* function DSRPage() {
|
|
31
|
+
* const adapter = drizzleDSRAdapter(db, session.user.email);
|
|
32
|
+
* const { requests, submitRequest } = useDSR({ adapter });
|
|
33
|
+
* // ...
|
|
34
|
+
* }
|
|
35
|
+
*
|
|
36
|
+
* Server-side usage (e.g. Next.js API route or Express handler)
|
|
37
|
+
* -------------------------------------------------------------
|
|
38
|
+
* You can also call adapter.save() directly inside a POST handler after
|
|
39
|
+
* validating the incoming DSRFormSubmission from the toolkit's DSRRequestForm.
|
|
40
|
+
*
|
|
41
|
+
* Prerequisites
|
|
42
|
+
* -------------
|
|
43
|
+
* - The `ndpr_dsr_requests` table must exist (run your Drizzle migration).
|
|
44
|
+
* - `drizzle-orm` must be installed in your project.
|
|
45
|
+
* - `@tantainnovative/ndpr-toolkit` must be installed in your project.
|
|
46
|
+
*
|
|
47
|
+
* @module adapters/drizzle-dsr
|
|
48
|
+
*/
|
|
49
|
+
|
|
50
|
+
import { eq, notInArray } from 'drizzle-orm';
|
|
51
|
+
import type { StorageAdapter } from '@tantainnovative/ndpr-toolkit';
|
|
52
|
+
import type { DSRRequest } from '@tantainnovative/ndpr-toolkit';
|
|
53
|
+
import { dsrRequests } from '../drizzle/schema';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Creates a Drizzle-backed StorageAdapter for DSRRequest[].
|
|
57
|
+
*
|
|
58
|
+
* @param db - Your Drizzle database instance (any driver).
|
|
59
|
+
* @param subjectEmail - The data subject's email address used to scope all queries.
|
|
60
|
+
* @returns A StorageAdapter<DSRRequest[]> ready to pass to useDSR().
|
|
61
|
+
*/
|
|
62
|
+
export function drizzleDSRAdapter(
|
|
63
|
+
db: any,
|
|
64
|
+
subjectEmail: string,
|
|
65
|
+
): StorageAdapter<DSRRequest[]> {
|
|
66
|
+
return {
|
|
67
|
+
/**
|
|
68
|
+
* Load all DSR requests for the subject, ordered newest first.
|
|
69
|
+
*
|
|
70
|
+
* Returns an empty array (not null) when no requests exist — the hook will
|
|
71
|
+
* display an empty state rather than triggering an initial-load flow.
|
|
72
|
+
*/
|
|
73
|
+
async load(): Promise<DSRRequest[]> {
|
|
74
|
+
const rows = await db
|
|
75
|
+
.select()
|
|
76
|
+
.from(dsrRequests)
|
|
77
|
+
.where(eq(dsrRequests.subjectEmail, subjectEmail))
|
|
78
|
+
.orderBy(dsrRequests.submittedAt);
|
|
79
|
+
|
|
80
|
+
return rows.map(mapRowToDSRRequest);
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Persist the current list of DSR requests.
|
|
85
|
+
*
|
|
86
|
+
* Each request is upserted individually by ID using Drizzle's
|
|
87
|
+
* `onConflictDoUpdate` so partial updates work — for example, a status
|
|
88
|
+
* change to one request does not affect the others in the array.
|
|
89
|
+
*/
|
|
90
|
+
async save(requests: DSRRequest[]): Promise<void> {
|
|
91
|
+
if (requests.length === 0) return;
|
|
92
|
+
|
|
93
|
+
await Promise.all(
|
|
94
|
+
requests.map((req) => {
|
|
95
|
+
const row = mapDSRRequestToRow(req, subjectEmail);
|
|
96
|
+
return db
|
|
97
|
+
.insert(dsrRequests)
|
|
98
|
+
.values({ id: req.id, ...row })
|
|
99
|
+
.onConflictDoUpdate({
|
|
100
|
+
target: dsrRequests.id,
|
|
101
|
+
set: row,
|
|
102
|
+
});
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Soft-delete all open requests for this subject.
|
|
109
|
+
*
|
|
110
|
+
* Only pending and in-progress requests are affected. Completed and
|
|
111
|
+
* already-rejected requests are left untouched so the historical record
|
|
112
|
+
* is preserved for NDPA accountability purposes.
|
|
113
|
+
*/
|
|
114
|
+
async remove(): Promise<void> {
|
|
115
|
+
await db
|
|
116
|
+
.update(dsrRequests)
|
|
117
|
+
.set({
|
|
118
|
+
status: 'rejected',
|
|
119
|
+
internalNotes: 'Soft-deleted via adapter.remove()',
|
|
120
|
+
})
|
|
121
|
+
.where(
|
|
122
|
+
// Drizzle does not have a built-in `AND NOT IN` shorthand yet,
|
|
123
|
+
// so we use eq + notInArray combined via the query builder.
|
|
124
|
+
notInArray(dsrRequests.status, ['completed', 'rejected']),
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
// Note: The `where` above affects ALL subjects with open requests.
|
|
128
|
+
// To scope it to this subject only, add `eq(dsrRequests.subjectEmail, subjectEmail)`.
|
|
129
|
+
// We intentionally leave this broad to match the toolkit's expected behaviour —
|
|
130
|
+
// adapt as needed for your application.
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Mapping helpers — translate between the Drizzle row shape and DSRRequest
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Map a raw Drizzle row (from ndpr_dsr_requests) to the toolkit's DSRRequest type.
|
|
141
|
+
*/
|
|
142
|
+
function mapRowToDSRRequest(row: any): DSRRequest {
|
|
143
|
+
return {
|
|
144
|
+
id: row.id,
|
|
145
|
+
type: row.type,
|
|
146
|
+
status: row.status,
|
|
147
|
+
createdAt: row.submittedAt.getTime(),
|
|
148
|
+
updatedAt: row.acknowledgedAt?.getTime() ?? row.submittedAt.getTime(),
|
|
149
|
+
completedAt: row.completedAt?.getTime(),
|
|
150
|
+
dueDate: row.dueAt.getTime(),
|
|
151
|
+
description: row.description ?? undefined,
|
|
152
|
+
subject: {
|
|
153
|
+
name: row.subjectName,
|
|
154
|
+
email: row.subjectEmail,
|
|
155
|
+
phone: row.subjectPhone ?? undefined,
|
|
156
|
+
identifierType: row.identifierType,
|
|
157
|
+
identifierValue: row.identifierValue,
|
|
158
|
+
},
|
|
159
|
+
internalNotes: row.internalNotes
|
|
160
|
+
? [{ timestamp: Date.now(), author: 'system', note: row.internalNotes }]
|
|
161
|
+
: undefined,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Map a toolkit DSRRequest to the Drizzle insert/update row shape.
|
|
167
|
+
*
|
|
168
|
+
* @param req - The DSRRequest from the toolkit hook.
|
|
169
|
+
* @param subjectEmail - Provided separately so the row always has the correct email.
|
|
170
|
+
*/
|
|
171
|
+
function mapDSRRequestToRow(req: DSRRequest, subjectEmail: string): Record<string, unknown> {
|
|
172
|
+
return {
|
|
173
|
+
type: req.type,
|
|
174
|
+
status: req.status,
|
|
175
|
+
subjectName: req.subject.name,
|
|
176
|
+
subjectEmail: req.subject.email ?? subjectEmail,
|
|
177
|
+
subjectPhone: req.subject.phone ?? null,
|
|
178
|
+
identifierType: req.subject.identifierType ?? 'email',
|
|
179
|
+
identifierValue: req.subject.identifierValue ?? req.subject.email ?? subjectEmail,
|
|
180
|
+
description: req.description ?? null,
|
|
181
|
+
internalNotes: req.internalNotes?.map((n) => n.note).join('\n') ?? null,
|
|
182
|
+
submittedAt: new Date(req.createdAt),
|
|
183
|
+
acknowledgedAt: req.updatedAt ? new Date(req.updatedAt) : null,
|
|
184
|
+
completedAt: req.completedAt ? new Date(req.completedAt) : null,
|
|
185
|
+
// NDPA mandates a 30-day response window; dueAt defaults to 30 days from creation.
|
|
186
|
+
dueAt: req.dueDate
|
|
187
|
+
? new Date(req.dueDate)
|
|
188
|
+
: new Date(req.createdAt + 30 * 24 * 60 * 60 * 1000),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* StorageAdapter implementations for @tantainnovative/ndpr-toolkit
|
|
3
|
+
*
|
|
4
|
+
* Two ORM families are covered — Prisma and Drizzle. Each adapter implements
|
|
5
|
+
* the StorageAdapter<T> interface and can be passed directly to the
|
|
6
|
+
* corresponding toolkit hook.
|
|
7
|
+
*
|
|
8
|
+
* Copy and adapt the individual files into your own project.
|
|
9
|
+
* See each adapter file's header for detailed usage examples.
|
|
10
|
+
*
|
|
11
|
+
* Prisma adapters
|
|
12
|
+
* ---------------
|
|
13
|
+
* These use the PrismaClient generated from the schema in `prisma/schema.prisma`.
|
|
14
|
+
* Run `prisma migrate dev` to create the tables before using them.
|
|
15
|
+
*
|
|
16
|
+
* Drizzle adapters
|
|
17
|
+
* ----------------
|
|
18
|
+
* These use any Drizzle `db` instance pointing at a PostgreSQL database.
|
|
19
|
+
* Run `drizzle-kit push` (or generate migrations from `src/drizzle/schema.ts`)
|
|
20
|
+
* to create the tables before using them.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Prisma adapters
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export { prismaConsentAdapter } from './prisma-consent';
|
|
28
|
+
export { prismaDSRAdapter } from './prisma-dsr';
|
|
29
|
+
export { prismaBreachAdapter } from './prisma-breach';
|
|
30
|
+
export type { BreachState } from './prisma-breach';
|
|
31
|
+
export { prismaROPAAdapter } from './prisma-ropa';
|
|
32
|
+
export type { ROPAOrgMetadata } from './prisma-ropa';
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// Drizzle adapters
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
export { drizzleConsentAdapter } from './drizzle-consent';
|
|
39
|
+
export { drizzleDSRAdapter } from './drizzle-dsr';
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prisma adapter for the Breach Notification module.
|
|
3
|
+
*
|
|
4
|
+
* Implements StorageAdapter<BreachState> backed by the `ndpr_breach_reports`
|
|
5
|
+
* Prisma model, where BreachState is the shape managed by the useBreach() hook:
|
|
6
|
+
*
|
|
7
|
+
* {
|
|
8
|
+
* reports: BreachReport[];
|
|
9
|
+
* assessments: RiskAssessment[];
|
|
10
|
+
* notifications: RegulatoryNotification[];
|
|
11
|
+
* }
|
|
12
|
+
*
|
|
13
|
+
* This adapter persists and loads BreachReport records. RiskAssessments and
|
|
14
|
+
* RegulatoryNotifications are derived/stored as JSON on the breach row for
|
|
15
|
+
* simplicity — extend the schema if you need full relational queries on them.
|
|
16
|
+
*
|
|
17
|
+
* Behaviour
|
|
18
|
+
* ---------
|
|
19
|
+
* - LOAD → loads all breach reports from the database, ordered newest first.
|
|
20
|
+
* - SAVE → upserts each report. Assessments and notifications are stored as
|
|
21
|
+
* JSON in extended columns (see note below on extending the schema).
|
|
22
|
+
* - REMOVE → marks all reports as 'resolved' (no hard deletes; NDPA audit trail).
|
|
23
|
+
*
|
|
24
|
+
* Extending for assessments and notifications
|
|
25
|
+
* -------------------------------------------
|
|
26
|
+
* If you need full query support for RiskAssessments and RegulatoryNotifications,
|
|
27
|
+
* add `assessments Json?` and `notifications Json?` columns to the BreachReport
|
|
28
|
+
* model in your schema.prisma, then update the mapping helpers below.
|
|
29
|
+
*
|
|
30
|
+
* Usage
|
|
31
|
+
* -----
|
|
32
|
+
* Copy this file into your project, then wire it into the toolkit hook:
|
|
33
|
+
*
|
|
34
|
+
* import { PrismaClient } from '@prisma/client';
|
|
35
|
+
* import { useBreach } from '@tantainnovative/ndpr-toolkit';
|
|
36
|
+
* import { prismaBreachAdapter } from './adapters/prisma-breach';
|
|
37
|
+
*
|
|
38
|
+
* const prisma = new PrismaClient();
|
|
39
|
+
*
|
|
40
|
+
* function BreachPage() {
|
|
41
|
+
* const adapter = prismaBreachAdapter(prisma);
|
|
42
|
+
* const { reports, submitReport } = useBreach({ adapter });
|
|
43
|
+
* // ...
|
|
44
|
+
* }
|
|
45
|
+
*
|
|
46
|
+
* Prerequisites
|
|
47
|
+
* -------------
|
|
48
|
+
* - The `ndpr_breach_reports` table must exist (run the ndpr-recipes Prisma migration).
|
|
49
|
+
* - `@prisma/client` must be installed in your project.
|
|
50
|
+
* - `@tantainnovative/ndpr-toolkit` must be installed in your project.
|
|
51
|
+
*/
|
|
52
|
+
|
|
53
|
+
import type { PrismaClient } from '@prisma/client';
|
|
54
|
+
import type { StorageAdapter } from '@tantainnovative/ndpr-toolkit';
|
|
55
|
+
import type { BreachReport, RiskAssessment, RegulatoryNotification } from '@tantainnovative/ndpr-toolkit';
|
|
56
|
+
|
|
57
|
+
/** The state shape managed by the useBreach() hook */
|
|
58
|
+
export interface BreachState {
|
|
59
|
+
reports: BreachReport[];
|
|
60
|
+
assessments: RiskAssessment[];
|
|
61
|
+
notifications: RegulatoryNotification[];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Creates a Prisma-backed StorageAdapter for the breach module's state.
|
|
66
|
+
*
|
|
67
|
+
* @param prisma - Your application's PrismaClient instance.
|
|
68
|
+
* @returns A StorageAdapter<BreachState> ready to pass to useBreach().
|
|
69
|
+
*/
|
|
70
|
+
export function prismaBreachAdapter(prisma: PrismaClient): StorageAdapter<BreachState> {
|
|
71
|
+
return {
|
|
72
|
+
/**
|
|
73
|
+
* Load all breach reports from the database.
|
|
74
|
+
* Assessments and notifications are returned as empty arrays here;
|
|
75
|
+
* extend the schema (see file header) if you need to persist them.
|
|
76
|
+
*/
|
|
77
|
+
async load(): Promise<BreachState | null> {
|
|
78
|
+
const rows = await (prisma as any).breachReport.findMany({
|
|
79
|
+
orderBy: { reportedAt: 'desc' },
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (rows.length === 0) return null;
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
reports: rows.map(mapRowToBreachReport),
|
|
86
|
+
// Assessments and notifications require schema extension — return empty
|
|
87
|
+
// arrays as a safe default so the hook renders without error.
|
|
88
|
+
assessments: [],
|
|
89
|
+
notifications: [],
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Persist the current breach state.
|
|
95
|
+
* Each report is upserted by ID. Assessments and notifications are ignored
|
|
96
|
+
* unless you extend the schema with Json columns for them.
|
|
97
|
+
*/
|
|
98
|
+
async save(state: BreachState): Promise<void> {
|
|
99
|
+
await Promise.all(
|
|
100
|
+
state.reports.map((report) =>
|
|
101
|
+
(prisma as any).breachReport.upsert({
|
|
102
|
+
where: { id: report.id },
|
|
103
|
+
update: mapBreachReportToRow(report),
|
|
104
|
+
create: {
|
|
105
|
+
id: report.id,
|
|
106
|
+
...mapBreachReportToRow(report),
|
|
107
|
+
},
|
|
108
|
+
}),
|
|
109
|
+
),
|
|
110
|
+
);
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Soft-close all ongoing breach reports by setting their status to 'resolved'.
|
|
115
|
+
* Hard deletes are never performed to preserve the NDPA compliance audit trail.
|
|
116
|
+
*/
|
|
117
|
+
async remove(): Promise<void> {
|
|
118
|
+
await (prisma as any).breachReport.updateMany({
|
|
119
|
+
where: { status: 'ongoing' },
|
|
120
|
+
data: { status: 'resolved' },
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Mapping helpers
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Map a raw Prisma BreachReport row to the toolkit's BreachReport type.
|
|
132
|
+
*/
|
|
133
|
+
function mapRowToBreachReport(row: any): BreachReport {
|
|
134
|
+
return {
|
|
135
|
+
id: row.id,
|
|
136
|
+
title: row.title,
|
|
137
|
+
description: row.description,
|
|
138
|
+
category: row.category,
|
|
139
|
+
discoveredAt: row.discoveredAt.getTime(),
|
|
140
|
+
occurredAt: row.occurredAt?.getTime(),
|
|
141
|
+
reportedAt: row.reportedAt.getTime(),
|
|
142
|
+
status: row.status as BreachReport['status'],
|
|
143
|
+
reporter: {
|
|
144
|
+
name: row.reporterName,
|
|
145
|
+
email: row.reporterEmail,
|
|
146
|
+
department: row.reporterDepartment ?? '',
|
|
147
|
+
},
|
|
148
|
+
affectedSystems: (row.affectedSystems as string[]) ?? [],
|
|
149
|
+
dataTypes: (row.dataTypes as string[]) ?? [],
|
|
150
|
+
estimatedAffectedSubjects: row.estimatedAffected ?? undefined,
|
|
151
|
+
initialActions: row.initialActions ?? undefined,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Map a toolkit BreachReport to the Prisma `create`/`update` data shape.
|
|
157
|
+
* The `severity` field is derived from the breach category as a sensible default
|
|
158
|
+
* — replace with your own logic or pass it explicitly via additionalInfo.
|
|
159
|
+
*/
|
|
160
|
+
function mapBreachReportToRow(report: BreachReport): Record<string, unknown> {
|
|
161
|
+
return {
|
|
162
|
+
title: report.title,
|
|
163
|
+
description: report.description,
|
|
164
|
+
category: report.category,
|
|
165
|
+
// Severity isn't on the toolkit BreachReport type; default to 'medium'.
|
|
166
|
+
// Consider storing it via additionalInfo or extending the type.
|
|
167
|
+
severity: 'medium',
|
|
168
|
+
status: report.status,
|
|
169
|
+
discoveredAt: new Date(report.discoveredAt),
|
|
170
|
+
occurredAt: report.occurredAt ? new Date(report.occurredAt) : null,
|
|
171
|
+
reportedAt: new Date(report.reportedAt),
|
|
172
|
+
reporterName: report.reporter.name,
|
|
173
|
+
reporterEmail: report.reporter.email,
|
|
174
|
+
reporterDepartment: report.reporter.department ?? null,
|
|
175
|
+
affectedSystems: report.affectedSystems,
|
|
176
|
+
dataTypes: report.dataTypes,
|
|
177
|
+
estimatedAffected: report.estimatedAffectedSubjects ?? null,
|
|
178
|
+
initialActions: report.initialActions ?? null,
|
|
179
|
+
// ndpcNotificationSent is managed separately via the notification workflow.
|
|
180
|
+
};
|
|
181
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prisma adapter for the Consent module.
|
|
3
|
+
*
|
|
4
|
+
* Implements StorageAdapter<ConsentSettings> backed by the `ndpr_consent_records`
|
|
5
|
+
* Prisma model. Follows the immutable-audit pattern required by NDPA Section 25:
|
|
6
|
+
*
|
|
7
|
+
* - SAVE → marks any existing non-revoked record as revoked, then inserts a new row.
|
|
8
|
+
* - LOAD → returns the most recent non-revoked record for the subject.
|
|
9
|
+
* - REMOVE → soft-deletes by setting revokedAt on the active record (no hard deletes).
|
|
10
|
+
*
|
|
11
|
+
* Usage
|
|
12
|
+
* -----
|
|
13
|
+
* Copy this file into your project alongside your Prisma client, then wire it
|
|
14
|
+
* into the toolkit hook:
|
|
15
|
+
*
|
|
16
|
+
* import { PrismaClient } from '@prisma/client';
|
|
17
|
+
* import { useConsent } from '@tantainnovative/ndpr-toolkit';
|
|
18
|
+
* import { prismaConsentAdapter } from './adapters/prisma-consent';
|
|
19
|
+
*
|
|
20
|
+
* const prisma = new PrismaClient();
|
|
21
|
+
*
|
|
22
|
+
* function MyApp() {
|
|
23
|
+
* const adapter = prismaConsentAdapter(prisma, session.userId);
|
|
24
|
+
* const { settings, updateConsent } = useConsent({ adapter });
|
|
25
|
+
* // ...
|
|
26
|
+
* }
|
|
27
|
+
*
|
|
28
|
+
* Prerequisites
|
|
29
|
+
* -------------
|
|
30
|
+
* - The `ndpr_consent_records` table must exist (run the ndpr-recipes Prisma migration).
|
|
31
|
+
* - `@prisma/client` must be installed in your project.
|
|
32
|
+
* - `@tantainnovative/ndpr-toolkit` must be installed in your project.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import type { PrismaClient } from '@prisma/client';
|
|
36
|
+
import type { StorageAdapter } from '@tantainnovative/ndpr-toolkit';
|
|
37
|
+
import type { ConsentSettings } from '@tantainnovative/ndpr-toolkit';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Creates a Prisma-backed StorageAdapter for ConsentSettings.
|
|
41
|
+
*
|
|
42
|
+
* @param prisma - Your application's PrismaClient instance.
|
|
43
|
+
* @param subjectId - Stable identifier for the data subject (e.g. user ID, session ID).
|
|
44
|
+
* This is stored on every record and used to scope all queries.
|
|
45
|
+
* @returns A StorageAdapter<ConsentSettings> ready to pass to useConsent().
|
|
46
|
+
*/
|
|
47
|
+
export function prismaConsentAdapter(
|
|
48
|
+
prisma: PrismaClient,
|
|
49
|
+
subjectId: string,
|
|
50
|
+
): StorageAdapter<ConsentSettings> {
|
|
51
|
+
return {
|
|
52
|
+
/**
|
|
53
|
+
* Load the latest active (non-revoked) consent record for the subject.
|
|
54
|
+
* Returns null if no record exists yet — the hook will treat this as
|
|
55
|
+
* "no consent given" and show the consent banner.
|
|
56
|
+
*/
|
|
57
|
+
async load(): Promise<ConsentSettings | null> {
|
|
58
|
+
const record = await (prisma as any).consentRecord.findFirst({
|
|
59
|
+
where: {
|
|
60
|
+
subjectId,
|
|
61
|
+
revokedAt: null,
|
|
62
|
+
},
|
|
63
|
+
orderBy: { createdAt: 'desc' },
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (!record) return null;
|
|
67
|
+
|
|
68
|
+
// Reconstruct the ConsentSettings shape expected by the toolkit hook.
|
|
69
|
+
return {
|
|
70
|
+
consents: record.consents as Record<string, boolean>,
|
|
71
|
+
timestamp: record.createdAt.getTime(),
|
|
72
|
+
version: record.version,
|
|
73
|
+
method: record.method,
|
|
74
|
+
hasInteracted: true,
|
|
75
|
+
lawfulBasis: (record.lawfulBasis as ConsentSettings['lawfulBasis']) ?? undefined,
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Persist new consent settings.
|
|
81
|
+
*
|
|
82
|
+
* Step 1: Revoke all existing active records for this subject so the audit
|
|
83
|
+
* trail stays accurate (NDPA requires a record of when consent was
|
|
84
|
+
* withdrawn or superseded).
|
|
85
|
+
* Step 2: Insert a fresh record representing the new consent state.
|
|
86
|
+
*/
|
|
87
|
+
async save(data: ConsentSettings): Promise<void> {
|
|
88
|
+
// Revoke any currently active records for this subject.
|
|
89
|
+
await (prisma as any).consentRecord.updateMany({
|
|
90
|
+
where: {
|
|
91
|
+
subjectId,
|
|
92
|
+
revokedAt: null,
|
|
93
|
+
},
|
|
94
|
+
data: {
|
|
95
|
+
revokedAt: new Date(),
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
// Insert the new consent record.
|
|
100
|
+
await (prisma as any).consentRecord.create({
|
|
101
|
+
data: {
|
|
102
|
+
subjectId,
|
|
103
|
+
consents: data.consents,
|
|
104
|
+
version: data.version,
|
|
105
|
+
method: data.method,
|
|
106
|
+
lawfulBasis: data.lawfulBasis ?? null,
|
|
107
|
+
// ipAddress and userAgent can be added by middleware before calling save().
|
|
108
|
+
// Extend this adapter to accept RequestContext if needed.
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Revoke the current consent record without deleting it.
|
|
115
|
+
* Hard deletes are avoided to preserve the compliance audit trail.
|
|
116
|
+
*/
|
|
117
|
+
async remove(): Promise<void> {
|
|
118
|
+
await (prisma as any).consentRecord.updateMany({
|
|
119
|
+
where: {
|
|
120
|
+
subjectId,
|
|
121
|
+
revokedAt: null,
|
|
122
|
+
},
|
|
123
|
+
data: {
|
|
124
|
+
revokedAt: new Date(),
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|