@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,163 @@
1
+ /**
2
+ * Prisma adapter for the Data Subject Rights (DSR) module.
3
+ *
4
+ * Implements StorageAdapter<DSRRequest[]> backed by the `ndpr_dsr_requests`
5
+ * Prisma model.
6
+ *
7
+ * Behaviour
8
+ * ---------
9
+ * - LOAD → returns all DSR requests submitted by the given subject email.
10
+ * - SAVE → upserts each request in the array by ID.
11
+ * - REMOVE → soft-deletes all requests for the subject by setting status to
12
+ * 'rejected' with an internal note (preserves the audit trail).
13
+ *
14
+ * Usage
15
+ * -----
16
+ * Copy this file into your project, then wire it into the toolkit hook:
17
+ *
18
+ * import { PrismaClient } from '@prisma/client';
19
+ * import { useDSR } from '@tantainnovative/ndpr-toolkit';
20
+ * import { prismaDSRAdapter } from './adapters/prisma-dsr';
21
+ *
22
+ * const prisma = new PrismaClient();
23
+ *
24
+ * function DSRPage() {
25
+ * const adapter = prismaDSRAdapter(prisma, session.user.email);
26
+ * const { requests, submitRequest } = useDSR({ adapter });
27
+ * // ...
28
+ * }
29
+ *
30
+ * Server-side usage (e.g. Next.js API route)
31
+ * ------------------------------------------
32
+ * You can also call adapter.save() directly inside a POST handler after
33
+ * validating the incoming DSRFormSubmission from the toolkit's DSRRequestForm.
34
+ *
35
+ * Prerequisites
36
+ * -------------
37
+ * - The `ndpr_dsr_requests` table must exist (run the ndpr-recipes Prisma migration).
38
+ * - `@prisma/client` must be installed in your project.
39
+ * - `@tantainnovative/ndpr-toolkit` must be installed in your project.
40
+ */
41
+
42
+ import type { PrismaClient } from '@prisma/client';
43
+ import type { StorageAdapter } from '@tantainnovative/ndpr-toolkit';
44
+ import type { DSRRequest } from '@tantainnovative/ndpr-toolkit';
45
+
46
+ /**
47
+ * Creates a Prisma-backed StorageAdapter for DSRRequest[].
48
+ *
49
+ * @param prisma - Your application's PrismaClient instance.
50
+ * @param subjectEmail - The data subject's email address used to scope all queries.
51
+ * @returns A StorageAdapter<DSRRequest[]> ready to pass to useDSR().
52
+ */
53
+ export function prismaDSRAdapter(
54
+ prisma: PrismaClient,
55
+ subjectEmail: string,
56
+ ): StorageAdapter<DSRRequest[]> {
57
+ return {
58
+ /**
59
+ * Load all DSR requests for the subject.
60
+ * Returns an empty array (not null) when no requests exist — the hook
61
+ * will display an empty state rather than triggering an initial-load flow.
62
+ */
63
+ async load(): Promise<DSRRequest[]> {
64
+ const rows = await (prisma as any).dSRRequest.findMany({
65
+ where: { subjectEmail },
66
+ orderBy: { submittedAt: 'desc' },
67
+ });
68
+
69
+ return rows.map(mapRowToDSRRequest);
70
+ },
71
+
72
+ /**
73
+ * Persist the current list of DSR requests.
74
+ * Each request is upserted individually by ID so partial updates work
75
+ * (e.g. a status change to a single request doesn't overwrite others).
76
+ */
77
+ async save(requests: DSRRequest[]): Promise<void> {
78
+ await Promise.all(
79
+ requests.map((req) =>
80
+ (prisma as any).dSRRequest.upsert({
81
+ where: { id: req.id },
82
+ update: mapDSRRequestToRow(req),
83
+ create: {
84
+ id: req.id,
85
+ ...mapDSRRequestToRow(req),
86
+ },
87
+ }),
88
+ ),
89
+ );
90
+ },
91
+
92
+ /**
93
+ * Soft-delete all pending/in-progress requests for this subject.
94
+ * Completed and already-rejected requests are left untouched so the
95
+ * historical record is preserved for accountability purposes.
96
+ */
97
+ async remove(): Promise<void> {
98
+ await (prisma as any).dSRRequest.updateMany({
99
+ where: {
100
+ subjectEmail,
101
+ status: { notIn: ['completed', 'rejected'] },
102
+ },
103
+ data: {
104
+ status: 'rejected',
105
+ internalNotes: 'Soft-deleted via adapter.remove()',
106
+ },
107
+ });
108
+ },
109
+ };
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Mapping helpers — translate between the Prisma row shape and DSRRequest
114
+ // ---------------------------------------------------------------------------
115
+
116
+ /**
117
+ * Map a raw Prisma row to the DSRRequest shape expected by the toolkit.
118
+ */
119
+ function mapRowToDSRRequest(row: any): DSRRequest {
120
+ return {
121
+ id: row.id,
122
+ type: row.type,
123
+ status: row.status,
124
+ createdAt: row.submittedAt.getTime(),
125
+ updatedAt: row.acknowledgedAt?.getTime() ?? row.submittedAt.getTime(),
126
+ completedAt: row.completedAt?.getTime(),
127
+ dueDate: row.dueAt.getTime(),
128
+ description: row.description ?? undefined,
129
+ subject: {
130
+ name: row.subjectName,
131
+ email: row.subjectEmail,
132
+ phone: row.subjectPhone ?? undefined,
133
+ identifierType: row.identifierType,
134
+ identifierValue: row.identifierValue,
135
+ },
136
+ internalNotes: row.internalNotes
137
+ ? [{ timestamp: Date.now(), author: 'system', note: row.internalNotes }]
138
+ : undefined,
139
+ };
140
+ }
141
+
142
+ /**
143
+ * Map a DSRRequest to the Prisma `create`/`update` data shape.
144
+ * Fields not present on the toolkit type (e.g. assignedTo) default to null.
145
+ */
146
+ function mapDSRRequestToRow(req: DSRRequest): Record<string, unknown> {
147
+ return {
148
+ type: req.type,
149
+ status: req.status,
150
+ subjectName: req.subject.name,
151
+ subjectEmail: req.subject.email,
152
+ subjectPhone: req.subject.phone ?? null,
153
+ identifierType: req.subject.identifierType ?? 'email',
154
+ identifierValue: req.subject.identifierValue ?? req.subject.email,
155
+ description: req.description ?? null,
156
+ internalNotes: req.internalNotes?.map((n) => n.note).join('\n') ?? null,
157
+ submittedAt: new Date(req.createdAt),
158
+ acknowledgedAt: req.updatedAt ? new Date(req.updatedAt) : null,
159
+ completedAt: req.completedAt ? new Date(req.completedAt) : null,
160
+ // NDPA mandates a 30-day response window; dueAt should be set on creation.
161
+ dueAt: req.dueDate ? new Date(req.dueDate) : new Date(req.createdAt + 30 * 24 * 60 * 60 * 1000),
162
+ };
163
+ }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Prisma adapter for the ROPA (Record of Processing Activities) module.
3
+ *
4
+ * Implements StorageAdapter<RecordOfProcessingActivities> backed by the
5
+ * `ndpr_processing_records` Prisma model.
6
+ *
7
+ * The toolkit's useROPA() hook stores a single RecordOfProcessingActivities
8
+ * object that wraps an array of ProcessingRecord entries plus organisation
9
+ * metadata. This adapter maps between that object and the flat Prisma table.
10
+ *
11
+ * Behaviour
12
+ * ---------
13
+ * - LOAD → reads all ProcessingRecord rows and assembles them into a
14
+ * RecordOfProcessingActivities using the org metadata you provide.
15
+ * - SAVE → upserts each ProcessingRecord individually by ID.
16
+ * - REMOVE → marks all active records as 'archived' (no hard deletes).
17
+ *
18
+ * Organisation metadata
19
+ * ---------------------
20
+ * Because the ROPA object includes organisation-level fields (name, contact,
21
+ * address, DPO details) that are not stored in the processing records table,
22
+ * you must supply them when creating the adapter. Typically these come from
23
+ * your app's settings or environment config.
24
+ *
25
+ * Usage
26
+ * -----
27
+ * Copy this file into your project, then wire it into the toolkit hook:
28
+ *
29
+ * import { PrismaClient } from '@prisma/client';
30
+ * import { useROPA } from '@tantainnovative/ndpr-toolkit';
31
+ * import { prismaROPAAdapter } from './adapters/prisma-ropa';
32
+ *
33
+ * const prisma = new PrismaClient();
34
+ *
35
+ * function ROPAPage() {
36
+ * const adapter = prismaROPAAdapter(prisma, {
37
+ * organizationName: process.env.ORG_NAME!,
38
+ * organizationContact: process.env.DPO_EMAIL!,
39
+ * organizationAddress: process.env.ORG_ADDRESS!,
40
+ * });
41
+ * const { ropa, addRecord } = useROPA({ adapter });
42
+ * // ...
43
+ * }
44
+ *
45
+ * Prerequisites
46
+ * -------------
47
+ * - The `ndpr_processing_records` table must exist (run the ndpr-recipes migration).
48
+ * - `@prisma/client` must be installed in your project.
49
+ * - `@tantainnovative/ndpr-toolkit` must be installed in your project.
50
+ */
51
+
52
+ import type { PrismaClient } from '@prisma/client';
53
+ import type { StorageAdapter } from '@tantainnovative/ndpr-toolkit';
54
+ import type { RecordOfProcessingActivities, ProcessingRecord } from '@tantainnovative/ndpr-toolkit';
55
+
56
+ /** Organisation metadata required to construct the ROPA envelope */
57
+ export interface ROPAOrgMetadata {
58
+ organizationName: string;
59
+ organizationContact: string;
60
+ organizationAddress: string;
61
+ dpoDetails?: {
62
+ name: string;
63
+ email: string;
64
+ phone?: string;
65
+ };
66
+ ndpcRegistrationNumber?: string;
67
+ }
68
+
69
+ /**
70
+ * Creates a Prisma-backed StorageAdapter for RecordOfProcessingActivities.
71
+ *
72
+ * @param prisma - Your application's PrismaClient instance.
73
+ * @param orgMeta - Organisation-level metadata used to build the ROPA envelope.
74
+ * @returns A StorageAdapter<RecordOfProcessingActivities> ready to pass to useROPA().
75
+ */
76
+ export function prismaROPAAdapter(
77
+ prisma: PrismaClient,
78
+ orgMeta: ROPAOrgMetadata,
79
+ ): StorageAdapter<RecordOfProcessingActivities> {
80
+ return {
81
+ /**
82
+ * Load all processing records and wrap them in a RecordOfProcessingActivities.
83
+ * Returns null if no records exist yet so the hook renders an empty ROPA.
84
+ */
85
+ async load(): Promise<RecordOfProcessingActivities | null> {
86
+ const rows = await (prisma as any).processingRecord.findMany({
87
+ orderBy: { createdAt: 'asc' },
88
+ });
89
+
90
+ if (rows.length === 0) return null;
91
+
92
+ const records: ProcessingRecord[] = rows.map(mapRowToProcessingRecord);
93
+ const lastUpdated = Math.max(...rows.map((r: any) => r.updatedAt.getTime()));
94
+
95
+ return {
96
+ id: 'ropa-' + orgMeta.organizationName.toLowerCase().replace(/\s+/g, '-'),
97
+ organizationName: orgMeta.organizationName,
98
+ organizationContact: orgMeta.organizationContact,
99
+ organizationAddress: orgMeta.organizationAddress,
100
+ dpoDetails: orgMeta.dpoDetails,
101
+ ndpcRegistrationNumber: orgMeta.ndpcRegistrationNumber,
102
+ records,
103
+ lastUpdated,
104
+ version: '1.0',
105
+ };
106
+ },
107
+
108
+ /**
109
+ * Persist the ROPA by upserting each ProcessingRecord individually.
110
+ * The organisation envelope fields are not stored in the database — they
111
+ * come from orgMeta on every load.
112
+ */
113
+ async save(ropa: RecordOfProcessingActivities): Promise<void> {
114
+ await Promise.all(
115
+ ropa.records.map((record) =>
116
+ (prisma as any).processingRecord.upsert({
117
+ where: { id: record.id },
118
+ update: mapProcessingRecordToRow(record),
119
+ create: {
120
+ id: record.id,
121
+ ...mapProcessingRecordToRow(record),
122
+ },
123
+ }),
124
+ ),
125
+ );
126
+ },
127
+
128
+ /**
129
+ * Archive all active processing records without deleting them.
130
+ * Archived records are excluded from active ROPA reports but are retained
131
+ * for audit purposes as required by the NDPA accountability principle.
132
+ */
133
+ async remove(): Promise<void> {
134
+ await (prisma as any).processingRecord.updateMany({
135
+ where: { status: 'active' },
136
+ data: { status: 'archived' },
137
+ });
138
+ },
139
+ };
140
+ }
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // Mapping helpers
144
+ // ---------------------------------------------------------------------------
145
+
146
+ /**
147
+ * Map a raw Prisma ProcessingRecord row to the toolkit's ProcessingRecord type.
148
+ * Several rich fields on the toolkit type (controllerDetails, lawfulBasisJustification, etc.)
149
+ * are not in the simplified Prisma schema — they default to placeholder values.
150
+ * Extend the schema with additional columns if you need full round-trip fidelity.
151
+ */
152
+ function mapRowToProcessingRecord(row: any): ProcessingRecord {
153
+ return {
154
+ id: row.id,
155
+ // `name` and `description` are derived from purpose since the schema is simplified.
156
+ name: row.purpose,
157
+ description: row.purpose,
158
+ controllerDetails: {
159
+ name: '',
160
+ contact: '',
161
+ address: '',
162
+ },
163
+ lawfulBasis: row.lawfulBasis as ProcessingRecord['lawfulBasis'],
164
+ lawfulBasisJustification: '',
165
+ purposes: [row.purpose],
166
+ dataCategories: (row.dataCategories as string[]) ?? [],
167
+ dataSubjectCategories: (row.dataSubjects as string[]) ?? [],
168
+ recipients: (row.recipients as string[]) ?? [],
169
+ crossBorderTransfers: row.transferCountries
170
+ ? (row.transferCountries as string[]).map((country: string) => ({
171
+ destinationCountry: country,
172
+ safeguards: '',
173
+ transferMechanism: row.transferMechanism ?? '',
174
+ }))
175
+ : undefined,
176
+ retentionPeriod: row.retentionPeriod,
177
+ securityMeasures: (row.securityMeasures as string[]) ?? [],
178
+ dataSource: 'data_subject',
179
+ dpiaRequired: row.dpiaConducted,
180
+ automatedDecisionMaking: false,
181
+ status: row.status as ProcessingRecord['status'],
182
+ createdAt: row.createdAt.getTime(),
183
+ updatedAt: row.updatedAt.getTime(),
184
+ };
185
+ }
186
+
187
+ /**
188
+ * Map a toolkit ProcessingRecord to the Prisma `create`/`update` data shape.
189
+ * Composite toolkit fields are flattened to match the simplified Prisma schema.
190
+ */
191
+ function mapProcessingRecordToRow(record: ProcessingRecord): Record<string, unknown> {
192
+ return {
193
+ purpose: record.purposes[0] ?? record.name,
194
+ lawfulBasis: record.lawfulBasis,
195
+ dataCategories: record.dataCategories,
196
+ dataSubjects: record.dataSubjectCategories,
197
+ recipients: record.recipients,
198
+ retentionPeriod: record.retentionPeriod,
199
+ securityMeasures: record.securityMeasures,
200
+ transferCountries: record.crossBorderTransfers?.map((t) => t.destinationCountry) ?? null,
201
+ transferMechanism: record.crossBorderTransfers?.[0]?.transferMechanism ?? null,
202
+ dpiaConducted: record.dpiaRequired,
203
+ status: record.status,
204
+ };
205
+ }