@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,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
+ }