@tantainnovative/create-ndpr 0.4.0 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tantainnovative/create-ndpr",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "CLI scaffolder for Nigeria NDPA 2023 / NDPC GAID 2025 compliance with @tantainnovative/ndpr-toolkit",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -0,0 +1,44 @@
1
+ /**
2
+ * NDPR Drizzle integration seam.
3
+ *
4
+ * Call configureNDPRDatabase(yourDrizzleInstance) once during server startup.
5
+ * This package intentionally does not choose a PostgreSQL driver or open a
6
+ * connection for the host application.
7
+ */
8
+ export interface NDPRDrizzleTransactionConfig {
9
+ isolationLevel?: 'read uncommitted' | 'read committed' | 'repeatable read' | 'serializable';
10
+ accessMode?: 'read only' | 'read write';
11
+ deferrable?: boolean;
12
+ }
13
+
14
+ export interface NDPRDrizzleDatabase {
15
+ select(...args: any[]): any;
16
+ insert(...args: any[]): any;
17
+ update(...args: any[]): any;
18
+ delete(...args: any[]): any;
19
+ transaction<T>(
20
+ callback: (tx: NDPRDrizzleDatabase) => Promise<T>,
21
+ config?: NDPRDrizzleTransactionConfig,
22
+ ): Promise<T>;
23
+ }
24
+
25
+ let configuredDatabase: NDPRDrizzleDatabase | null = null;
26
+
27
+ export function configureNDPRDatabase(database: unknown): void {
28
+ if (!database || typeof database !== 'object') {
29
+ throw new TypeError('configureNDPRDatabase requires a Drizzle database instance');
30
+ }
31
+ configuredDatabase = database as NDPRDrizzleDatabase;
32
+ }
33
+
34
+ export const db = new Proxy({} as NDPRDrizzleDatabase, {
35
+ get(_target, property) {
36
+ if (!configuredDatabase) {
37
+ throw new Error(
38
+ 'NDPR Drizzle database is not configured. Call configureNDPRDatabase(db) during server startup.',
39
+ );
40
+ }
41
+ const value = Reflect.get(configuredDatabase, property);
42
+ return typeof value === 'function' ? value.bind(configuredDatabase) : value;
43
+ },
44
+ });
@@ -1,22 +1,3 @@
1
- /**
2
- * NDPA Compliance Schema — generated by create-ndpr
3
- * Organisation: {{ORG_NAME}}
4
- * DPO: {{DPO_EMAIL}}
5
- *
6
- * Covers:
7
- * - consentRecords — NDPA §25–26 (consent & withdrawal)
8
- * - dsrRequests — NDPA Part IV §34–38 (data subject rights)
9
- * - breachReports — NDPA §40 (72-hour breach notification)
10
- * - processingRecords — ROPA (accountability principle)
11
- * - dpiaRecords — NDPA §34(3) (impact assessments)
12
- * - lawfulBasisRecords — NDPA §25 (lawful basis register)
13
- * - crossBorderTransferRecords — NDPA §41–42 (cross-border transfers)
14
- * - auditLog — §44 audit trail
15
- *
16
- * Prerequisites: drizzle-orm, @paralleldrive/cuid2
17
- * Run `drizzle-kit push` to apply.
18
- */
19
-
20
1
  import {
21
2
  pgTable,
22
3
  text,
@@ -25,224 +6,265 @@ import {
25
6
  boolean,
26
7
  integer,
27
8
  index,
9
+ uniqueIndex,
28
10
  } from 'drizzle-orm/pg-core';
29
11
  import { createId } from '@paralleldrive/cuid2';
30
12
 
31
- // ---------------------------------------------------------------------------
32
- // ndpr_consent_records NDPA §25–26
33
- // ---------------------------------------------------------------------------
13
+ /**
14
+ * NDPA persistence schema for {{ORG_NAME_COMMENT}}.
15
+ * Every operational and audit row carries a required server-resolved tenantId.
16
+ */
34
17
 
35
18
  export const consentRecords = pgTable(
36
19
  'ndpr_consent_records',
37
20
  {
38
- id: text('id').primaryKey().$defaultFn(() => createId()),
39
- subjectId: text('subject_id').notNull(),
40
- consents: json('consents').notNull(),
41
- version: text('version').notNull(),
42
- method: text('method').notNull(),
43
- lawfulBasis: text('lawful_basis'),
44
- ipAddress: text('ip_address'),
45
- userAgent: text('user_agent'),
46
- createdAt: timestamp('created_at').defaultNow().notNull(),
47
- revokedAt: timestamp('revoked_at'),
21
+ id: text('id').primaryKey().$defaultFn(() => createId()),
22
+ tenantId: text('tenant_id').notNull(),
23
+ subjectId: text('subject_id').notNull(),
24
+ activeSubjectKey: text('active_subject_key'),
25
+ consents: json('consents').notNull(),
26
+ version: text('version').notNull(),
27
+ method: text('method').notNull(),
28
+ hasInteracted: boolean('has_interacted').notNull(),
29
+ lawfulBasis: text('lawful_basis'),
30
+ ipAddress: text('ip_address'),
31
+ userAgent: text('user_agent'),
32
+ clientTimestamp: timestamp('client_timestamp').notNull(),
33
+ createdAt: timestamp('created_at').defaultNow().notNull(),
34
+ revokedAt: timestamp('revoked_at'),
48
35
  },
49
- (t) => ({ subjectIdIdx: index('consent_subject_id_idx').on(t.subjectId) }),
36
+ (table) => ({
37
+ activeSubjectKeyUnique: uniqueIndex('consent_active_subject_key').on(
38
+ table.activeSubjectKey,
39
+ ),
40
+ tenantSubjectIdx: index('consent_tenant_subject_idx').on(
41
+ table.tenantId,
42
+ table.subjectId,
43
+ table.revokedAt,
44
+ ),
45
+ replayIdx: index('consent_replay_idx').on(
46
+ table.tenantId,
47
+ table.subjectId,
48
+ table.clientTimestamp,
49
+ table.version,
50
+ table.method,
51
+ ),
52
+ }),
50
53
  );
51
54
 
52
- // ---------------------------------------------------------------------------
53
- // ndpr_dsr_requests — NDPA §34–38
54
- // ---------------------------------------------------------------------------
55
-
56
55
  export const dsrRequests = pgTable(
57
56
  'ndpr_dsr_requests',
58
57
  {
59
- id: text('id').primaryKey().$defaultFn(() => createId()),
60
- type: text('type').notNull(),
61
- status: text('status').notNull().default('pending'),
62
- subjectName: text('subject_name').notNull(),
63
- subjectEmail: text('subject_email').notNull(),
64
- subjectPhone: text('subject_phone'),
65
- identifierType: text('identifier_type').notNull(),
58
+ id: text('id').primaryKey().$defaultFn(() => createId()),
59
+ tenantId: text('tenant_id').notNull(),
60
+ subjectId: text('subject_id').notNull(),
61
+ type: text('type').notNull(),
62
+ status: text('status').notNull().default('pending'),
63
+ subjectName: text('subject_name').notNull(),
64
+ subjectEmail: text('subject_email').notNull(),
65
+ subjectPhone: text('subject_phone'),
66
+ identifierType: text('identifier_type').notNull(),
66
67
  identifierValue: text('identifier_value').notNull(),
67
- description: text('description'),
68
- internalNotes: text('internal_notes'),
69
- assignedTo: text('assigned_to'),
70
- submittedAt: timestamp('submitted_at').defaultNow().notNull(),
71
- acknowledgedAt: timestamp('acknowledged_at'),
72
- completedAt: timestamp('completed_at'),
73
- dueAt: timestamp('due_at').notNull(),
68
+ description: text('description'),
69
+ internalNotes: text('internal_notes'),
70
+ assignedTo: text('assigned_to'),
71
+ submittedAt: timestamp('submitted_at').defaultNow().notNull(),
72
+ acknowledgedAt: timestamp('acknowledged_at'),
73
+ completedAt: timestamp('completed_at'),
74
+ dueAt: timestamp('due_at').notNull(),
74
75
  },
75
- (t) => ({
76
- statusIdx: index('dsr_status_idx').on(t.status),
77
- subjectEmailIdx: index('dsr_subject_email_idx').on(t.subjectEmail),
76
+ (table) => ({
77
+ tenantStatusIdx: index('dsr_tenant_status_idx').on(table.tenantId, table.status),
78
+ tenantSubjectIdx: index('dsr_tenant_subject_idx').on(table.tenantId, table.subjectId),
79
+ tenantEmailIdx: index('dsr_tenant_email_idx').on(table.tenantId, table.subjectEmail),
78
80
  }),
79
81
  );
80
82
 
81
- // ---------------------------------------------------------------------------
82
- // ndpr_breach_reports — NDPA §40
83
- // ---------------------------------------------------------------------------
84
-
85
83
  export const breachReports = pgTable(
86
84
  'ndpr_breach_reports',
87
85
  {
88
- id: text('id').primaryKey().$defaultFn(() => createId()),
89
- title: text('title').notNull(),
90
- description: text('description').notNull(),
91
- category: text('category').notNull(),
92
- severity: text('severity').notNull(),
93
- status: text('status').notNull().default('ongoing'),
94
- discoveredAt: timestamp('discovered_at').notNull(),
95
- occurredAt: timestamp('occurred_at'),
96
- reportedAt: timestamp('reported_at').defaultNow().notNull(),
97
- ndpcNotifiedAt: timestamp('ndpc_notified_at'),
98
- reporterName: text('reporter_name').notNull(),
99
- reporterEmail: text('reporter_email').notNull(),
100
- reporterDepartment: text('reporter_department'),
101
- affectedSystems: json('affected_systems').notNull(),
102
- dataTypes: json('data_types').notNull(),
103
- estimatedAffected: integer('estimated_affected'),
104
- initialActions: text('initial_actions'),
86
+ id: text('id').primaryKey().$defaultFn(() => createId()),
87
+ tenantId: text('tenant_id').notNull(),
88
+ title: text('title').notNull(),
89
+ description: text('description').notNull(),
90
+ category: text('category').notNull(),
91
+ severity: text('severity').notNull(),
92
+ status: text('status').notNull().default('ongoing'),
93
+ discoveredAt: timestamp('discovered_at').notNull(),
94
+ occurredAt: timestamp('occurred_at'),
95
+ reportedAt: timestamp('reported_at').defaultNow().notNull(),
96
+ ndpcNotifiedAt: timestamp('ndpc_notified_at'),
97
+ reporterName: text('reporter_name').notNull(),
98
+ reporterEmail: text('reporter_email').notNull(),
99
+ reporterDepartment: text('reporter_department'),
100
+ affectedSystems: json('affected_systems').notNull(),
101
+ dataTypes: json('data_types').notNull(),
102
+ involvesSensitiveData: boolean('involves_sensitive_data').notNull().default(false),
103
+ estimatedAffected: integer('estimated_affected'),
104
+ approximateRecordCount: integer('approximate_record_count'),
105
+ dataSubjectCategories: json('data_subject_categories').notNull(),
106
+ likelyConsequences: text('likely_consequences'),
107
+ mitigationMeasures: text('mitigation_measures'),
108
+ isPhasedReport: boolean('is_phased_report').notNull().default(false),
109
+ supplementsReportId: text('supplements_report_id'),
110
+ initialActions: text('initial_actions'),
105
111
  ndpcNotificationSent: boolean('ndpc_notification_sent').notNull().default(false),
106
112
  },
107
- (t) => ({
108
- statusIdx: index('breach_status_idx').on(t.status),
109
- severityIdx: index('breach_severity_idx').on(t.severity),
113
+ (table) => ({
114
+ tenantStatusIdx: index('breach_tenant_status_idx').on(table.tenantId, table.status),
115
+ tenantSeverityIdx: index('breach_tenant_severity_idx').on(table.tenantId, table.severity),
110
116
  }),
111
117
  );
112
118
 
113
- // ---------------------------------------------------------------------------
114
- // ndpr_processing_records — ROPA
115
- // ---------------------------------------------------------------------------
116
-
117
- export const processingRecords = pgTable('ndpr_processing_records', {
118
- id: text('id').primaryKey().$defaultFn(() => createId()),
119
- purpose: text('purpose').notNull(),
120
- lawfulBasis: text('lawful_basis').notNull(),
121
- dataCategories: json('data_categories').notNull(),
122
- dataSubjects: json('data_subjects').notNull(),
123
- recipients: json('recipients').notNull(),
124
- retentionPeriod: text('retention_period').notNull(),
125
- securityMeasures: json('security_measures').notNull(),
126
- transferCountries: json('transfer_countries'),
127
- transferMechanism: text('transfer_mechanism'),
128
- dpiaConducted: boolean('dpia_conducted').notNull().default(false),
129
- status: text('status').notNull().default('active'),
130
- createdAt: timestamp('created_at').defaultNow().notNull(),
131
- updatedAt: timestamp('updated_at').defaultNow().notNull(),
132
- });
133
-
134
- // ---------------------------------------------------------------------------
135
- // ndpr_dpia_records — NDPA §34(3)
136
- // ---------------------------------------------------------------------------
119
+ export const processingRecords = pgTable(
120
+ 'ndpr_processing_records',
121
+ {
122
+ id: text('id').primaryKey().$defaultFn(() => createId()),
123
+ tenantId: text('tenant_id').notNull(),
124
+ purpose: text('purpose').notNull(),
125
+ lawfulBasis: text('lawful_basis').notNull(),
126
+ dataCategories: json('data_categories').notNull(),
127
+ dataSubjects: json('data_subjects').notNull(),
128
+ recipients: json('recipients').notNull(),
129
+ retentionPeriod: text('retention_period').notNull(),
130
+ securityMeasures: json('security_measures').notNull(),
131
+ transferCountries: json('transfer_countries'),
132
+ transferMechanism: text('transfer_mechanism'),
133
+ dpiaConducted: boolean('dpia_conducted').notNull().default(false),
134
+ status: text('status').notNull().default('active'),
135
+ createdAt: timestamp('created_at').defaultNow().notNull(),
136
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
137
+ },
138
+ (table) => ({
139
+ tenantStatusIdx: index('processing_tenant_status_idx').on(table.tenantId, table.status),
140
+ }),
141
+ );
137
142
 
138
143
  export const dpiaRecords = pgTable(
139
144
  'ndpr_dpia_records',
140
145
  {
141
- id: text('id').primaryKey().$defaultFn(() => createId()),
146
+ id: text('id').primaryKey().$defaultFn(() => createId()),
147
+ tenantId: text('tenant_id').notNull(),
142
148
  projectName: text('project_name').notNull(),
143
149
  description: text('description').notNull(),
144
- dpiaData: json('dpia_data').notNull(),
150
+ dpiaData: json('dpia_data').notNull(),
145
151
  overallRisk: text('overall_risk').notNull(),
146
- score: integer('score').notNull(),
147
- status: text('status').notNull().default('draft'),
152
+ score: integer('score').notNull(),
153
+ status: text('status').notNull().default('draft'),
148
154
  conductedBy: text('conducted_by').notNull(),
149
- approvedBy: text('approved_by'),
150
- createdAt: timestamp('created_at').defaultNow().notNull(),
151
- updatedAt: timestamp('updated_at').defaultNow().notNull(),
155
+ approvedBy: text('approved_by'),
156
+ createdAt: timestamp('created_at').defaultNow().notNull(),
157
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
158
+ removedAt: timestamp('removed_at'),
152
159
  },
153
- (t) => ({
154
- statusIdx: index('dpia_status_idx').on(t.status),
155
- conductedByIdx: index('dpia_conducted_by_idx').on(t.conductedBy),
160
+ (table) => ({
161
+ tenantStatusIdx: index('dpia_tenant_status_idx').on(
162
+ table.tenantId,
163
+ table.removedAt,
164
+ table.status,
165
+ ),
156
166
  }),
157
167
  );
158
168
 
159
- // ---------------------------------------------------------------------------
160
- // ndpr_lawful_basis_records — NDPA §25
161
- // ---------------------------------------------------------------------------
162
-
163
169
  export const lawfulBasisRecords = pgTable(
164
170
  'ndpr_lawful_basis_records',
165
171
  {
166
- id: text('id').primaryKey().$defaultFn(() => createId()),
167
- activityName: text('activity_name').notNull(),
168
- lawfulBasis: text('lawful_basis').notNull(),
169
- justification: text('justification').notNull(),
172
+ id: text('id').primaryKey().$defaultFn(() => createId()),
173
+ tenantId: text('tenant_id').notNull(),
174
+ activityName: text('activity_name').notNull(),
175
+ lawfulBasis: text('lawful_basis').notNull(),
176
+ justification: text('justification').notNull(),
170
177
  dataCategories: json('data_categories').notNull(),
171
- purposes: json('purposes').notNull(),
172
- assessedBy: text('assessed_by').notNull(),
173
- assessedAt: timestamp('assessed_at').defaultNow().notNull(),
174
- reviewDate: timestamp('review_date'),
175
- createdAt: timestamp('created_at').defaultNow().notNull(),
176
- updatedAt: timestamp('updated_at').defaultNow().notNull(),
178
+ purposes: json('purposes').notNull(),
179
+ assessedBy: text('assessed_by').notNull(),
180
+ assessedAt: timestamp('assessed_at').defaultNow().notNull(),
181
+ reviewDate: timestamp('review_date'),
182
+ createdAt: timestamp('created_at').defaultNow().notNull(),
183
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
184
+ removedAt: timestamp('removed_at'),
177
185
  },
178
- (t) => ({
179
- lawfulBasisIdx: index('lawful_basis_idx').on(t.lawfulBasis),
180
- assessedByIdx: index('lawful_basis_assessed_by_idx').on(t.assessedBy),
186
+ (table) => ({
187
+ tenantBasisIdx: index('lawful_basis_tenant_idx').on(
188
+ table.tenantId,
189
+ table.removedAt,
190
+ table.lawfulBasis,
191
+ ),
181
192
  }),
182
193
  );
183
194
 
184
- // ---------------------------------------------------------------------------
185
- // ndpr_cross_border_transfer_records — NDPA §41–42
186
- // ---------------------------------------------------------------------------
187
-
188
195
  export const crossBorderTransferRecords = pgTable(
189
196
  'ndpr_cross_border_transfer_records',
190
197
  {
191
- id: text('id').primaryKey().$defaultFn(() => createId()),
192
- destinationCountry: text('destination_country').notNull(),
193
- recipientName: text('recipient_name').notNull(),
194
- transferMechanism: text('transfer_mechanism').notNull(),
195
- safeguards: text('safeguards').notNull(),
196
- dataCategories: json('data_categories').notNull(),
197
- adequacyStatus: text('adequacy_status').notNull(),
198
- ndpcApprovalRequired: boolean('ndpc_approval_required').notNull().default(false),
198
+ id: text('id').primaryKey().$defaultFn(() => createId()),
199
+ tenantId: text('tenant_id').notNull(),
200
+ destinationCountry: text('destination_country').notNull(),
201
+ recipientName: text('recipient_name').notNull(),
202
+ transferMechanism: text('transfer_mechanism').notNull(),
203
+ safeguards: text('safeguards').notNull(),
204
+ dataCategories: json('data_categories').notNull(),
205
+ adequacyStatus: text('adequacy_status').notNull(),
206
+ ndpcApprovalRequired: boolean('ndpc_approval_required').notNull().default(false),
199
207
  ndpcApprovalReference: text('ndpc_approval_reference'),
200
- riskLevel: text('risk_level').notNull(),
201
- createdAt: timestamp('created_at').defaultNow().notNull(),
202
- updatedAt: timestamp('updated_at').defaultNow().notNull(),
208
+ riskLevel: text('risk_level').notNull(),
209
+ status: text('status').notNull().default('active'),
210
+ createdAt: timestamp('created_at').defaultNow().notNull(),
211
+ updatedAt: timestamp('updated_at').defaultNow().notNull(),
212
+ removedAt: timestamp('removed_at'),
203
213
  },
204
- (t) => ({
205
- destinationCountryIdx: index('cross_border_destination_idx').on(t.destinationCountry),
206
- riskLevelIdx: index('cross_border_risk_level_idx').on(t.riskLevel),
214
+ (table) => ({
215
+ tenantDestinationIdx: index('cross_border_tenant_destination_idx').on(
216
+ table.tenantId,
217
+ table.removedAt,
218
+ table.destinationCountry,
219
+ ),
220
+ tenantRiskIdx: index('cross_border_tenant_risk_idx').on(
221
+ table.tenantId,
222
+ table.removedAt,
223
+ table.riskLevel,
224
+ ),
225
+ tenantStatusIdx: index('cross_border_tenant_status_idx').on(
226
+ table.tenantId,
227
+ table.removedAt,
228
+ table.status,
229
+ ),
207
230
  }),
208
231
  );
209
232
 
210
- // ---------------------------------------------------------------------------
211
- // ndpr_audit_log §44 accountability
212
- // ---------------------------------------------------------------------------
213
-
214
- export const auditLog = pgTable(
233
+ /** Append-oriented application audit rows; enforce immutability in DB permissions. */
234
+ export const complianceAuditLog = pgTable(
215
235
  'ndpr_audit_log',
216
236
  {
217
- id: text('id').primaryKey().$defaultFn(() => createId()),
218
- module: text('module').notNull(),
219
- action: text('action').notNull(),
220
- entityId: text('entity_id').notNull(),
221
- entityType: text('entity_type').notNull(),
222
- changes: json('changes'),
237
+ id: text('id').primaryKey().$defaultFn(() => createId()),
238
+ tenantId: text('tenant_id').notNull(),
239
+ module: text('module').notNull(),
240
+ action: text('action').notNull(),
241
+ entityId: text('entity_id').notNull(),
242
+ entityType: text('entity_type').notNull(),
243
+ changes: json('changes'),
223
244
  performedBy: text('performed_by'),
224
- createdAt: timestamp('created_at').defaultNow().notNull(),
245
+ createdAt: timestamp('created_at').defaultNow().notNull(),
225
246
  },
226
- (t) => ({
227
- moduleEntityIdx: index('audit_module_entity_idx').on(t.module, t.entityId),
247
+ (table) => ({
248
+ tenantEntityIdx: index('audit_tenant_module_entity_idx').on(
249
+ table.tenantId,
250
+ table.module,
251
+ table.entityId,
252
+ ),
228
253
  }),
229
254
  );
230
255
 
231
- // ---------------------------------------------------------------------------
232
- // Inferred types
233
- // ---------------------------------------------------------------------------
234
-
235
- export type ConsentRecord = typeof consentRecords.$inferSelect;
256
+ export type ConsentRecord = typeof consentRecords.$inferSelect;
236
257
  export type NewConsentRecord = typeof consentRecords.$inferInsert;
237
- export type DSRRequest = typeof dsrRequests.$inferSelect;
238
- export type NewDSRRequest = typeof dsrRequests.$inferInsert;
239
- export type BreachReport = typeof breachReports.$inferSelect;
240
- export type NewBreachReport = typeof breachReports.$inferInsert;
241
- export type ProcessingRecord = typeof processingRecords.$inferSelect;
242
- export type DPIARecord = typeof dpiaRecords.$inferSelect;
243
- export type NewDPIARecord = typeof dpiaRecords.$inferInsert;
244
- export type LawfulBasisRecord = typeof lawfulBasisRecords.$inferSelect;
245
- export type NewLawfulBasisRecord = typeof lawfulBasisRecords.$inferInsert;
246
- export type CrossBorderTransfer = typeof crossBorderTransferRecords.$inferSelect;
247
- export type NewCrossBorderTransfer = typeof crossBorderTransferRecords.$inferInsert;
248
- export type AuditLogEntry = typeof auditLog.$inferSelect;
258
+ export type DSRRequest = typeof dsrRequests.$inferSelect;
259
+ export type NewDSRRequest = typeof dsrRequests.$inferInsert;
260
+ export type BreachReport = typeof breachReports.$inferSelect;
261
+ export type NewBreachReport = typeof breachReports.$inferInsert;
262
+ export type ProcessingRecord = typeof processingRecords.$inferSelect;
263
+ export type NewProcessingRecord = typeof processingRecords.$inferInsert;
264
+ export type DPIARecord = typeof dpiaRecords.$inferSelect;
265
+ export type NewDPIARecord = typeof dpiaRecords.$inferInsert;
266
+ export type LawfulBasisRecord = typeof lawfulBasisRecords.$inferSelect;
267
+ export type NewLawfulBasisRecord = typeof lawfulBasisRecords.$inferInsert;
268
+ export type CrossBorderTransfer = typeof crossBorderTransferRecords.$inferSelect;
269
+ export type NewCrossBorderTransfer = typeof crossBorderTransferRecords.$inferInsert;
270
+ export type ComplianceAuditLogEntry = typeof complianceAuditLog.$inferSelect;
@@ -1,4 +1,7 @@
1
- # NDPA Compliance Database
2
- # Organisation: {{ORG_NAME}}
3
- # DPO: {{DPO_EMAIL}}
1
+ # NDPA compliance persistence — generated for {{ORG_NAME_COMMENT}}
4
2
  DATABASE_URL="postgresql://user:password@localhost:5432/myapp_dev"
3
+
4
+ # Server-controlled tenant boundary. Never accept this value from clients.
5
+ # Multi-tenant applications should replace the generated request-context
6
+ # resolver with one that derives tenantId from a verified session.
7
+ NDPR_TENANT_ID="{{TENANT_ID}}"
@@ -0,0 +1,98 @@
1
+ /** Express — staff-only, tenant-scoped breach register for {{ORG_NAME_COMMENT}}. */
2
+ import { Router } from 'express';
3
+ import { assessBreachNotification } from '@tantainnovative/ndpr-toolkit/server';
4
+ import { getNDPRContextProblem, resolveNDPRRequestContext } from '{{NDPR_CONTEXT_IMPORT}}';
5
+ // {{#if ORM=prisma}}
6
+ import { PrismaClient } from '@prisma/client';
7
+ const prisma = new PrismaClient();
8
+ // {{/if}}
9
+ // {{#if ORM=drizzle}}
10
+ import { db } from '{{NDPR_DB_IMPORT}}';
11
+ import { breachReports, complianceAuditLog } from '{{NDPR_SCHEMA_IMPORT}}';
12
+ import { and, desc, eq } from 'drizzle-orm';
13
+ // {{/if}}
14
+ // {{#if ORM=none}}
15
+ // DEVELOPMENT ONLY: replace both stores with one durable transactional store.
16
+ type Severity = 'critical' | 'high' | 'medium' | 'low';
17
+ interface BreachRow { id: string; tenantId: string; title: string; description: string; category: string; severity: Severity; status: string; discoveredAt: Date; occurredAt: Date | null; reportedAt: Date; reporterName: string; reporterEmail: string; reporterDepartment: string | null; affectedSystems: string[]; dataTypes: string[]; estimatedAffected: number | null; approximateRecordCount: number | null; dataSubjectCategories: string[]; likelyConsequences: string | null; mitigationMeasures: string | null; involvesSensitiveData: boolean; isPhasedReport: boolean; supplementsReportId: string | null; initialActions: string | null }
18
+ const breachStore = new Map<string, BreachRow>();
19
+ const auditLog: Array<{ id: string; tenantId: string; action: string; entityId: string; performedBy: string; at: Date }> = [];
20
+ // {{/if}}
21
+
22
+ type BreachSeverity = 'critical' | 'high' | 'medium' | 'low';
23
+ function severityFor(category: string, affected: number | null): BreachSeverity {
24
+ const highRisk = new Set(['unauthorized_access', 'ransomware', 'data_exfiltration', 'identity_theft']);
25
+ if (highRisk.has(category)) return (affected ?? 0) > 1000 ? 'critical' : 'high';
26
+ if ((affected ?? 0) > 500) return 'high';
27
+ if ((affected ?? 0) > 50) return 'medium';
28
+ return 'low';
29
+ }
30
+ function count(value: unknown): number | null { return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null; }
31
+ export const breachRouter = Router();
32
+
33
+ breachRouter.get('/', async (req, res) => {
34
+ const context = await resolveNDPRRequestContext(req);
35
+ const problem = getNDPRContextProblem(context, 'staff');
36
+ if (problem) return res.status(problem.status).json({ error: problem.error });
37
+ const status = typeof req.query.status === 'string' ? req.query.status : undefined;
38
+ // {{#if ORM=prisma}}
39
+ const reports = await prisma.breachReport.findMany({ where: { tenantId: context.tenantId, ...(status ? { status } : {}) }, orderBy: { reportedAt: 'desc' } });
40
+ // {{/if}}
41
+ // {{#if ORM=drizzle}}
42
+ const reports = status
43
+ ? await db.select().from(breachReports).where(and(eq(breachReports.tenantId, context.tenantId), eq(breachReports.status, status))).orderBy(desc(breachReports.reportedAt))
44
+ : await db.select().from(breachReports).where(eq(breachReports.tenantId, context.tenantId)).orderBy(desc(breachReports.reportedAt));
45
+ // {{/if}}
46
+ // {{#if ORM=none}}
47
+ const reports = [...breachStore.values()].filter((row) => row.tenantId === context.tenantId && (!status || row.status === status)).sort((a, b) => b.reportedAt.getTime() - a.reportedAt.getTime());
48
+ // {{/if}}
49
+ return res.json(reports);
50
+ });
51
+
52
+ breachRouter.post('/', async (req, res) => {
53
+ const context = await resolveNDPRRequestContext(req);
54
+ const problem = getNDPRContextProblem(context, 'staff');
55
+ if (problem) return res.status(problem.status).json({ error: problem.error });
56
+ const actor = context.actor;
57
+ if (!actor) return res.status(401).json({ error: 'Verified staff profile is required' });
58
+ const actorId = actor.id;
59
+ if (!req.body || typeof req.body !== 'object') {
60
+ return res.status(400).json({ error: 'A JSON object is required' });
61
+ }
62
+ const input = req.body as Record<string, unknown>;
63
+ const required = ['title', 'description', 'category', 'discoveredAt'] as const;
64
+ if (required.some((field) => typeof input[field] !== 'string' || !(input[field] as string).trim()) || !Array.isArray(input.affectedSystems) || !input.affectedSystems.every((v) => typeof v === 'string') || !Array.isArray(input.dataTypes) || !input.dataTypes.every((v) => typeof v === 'string')) {
65
+ return res.status(400).json({ error: `${required.join(', ')}, affectedSystems, and dataTypes are required` });
66
+ }
67
+ const discoveredAt = new Date(input.discoveredAt as string);
68
+ const occurredAt = typeof input.occurredAt === 'string' ? new Date(input.occurredAt) : null;
69
+ if (!Number.isFinite(discoveredAt.getTime()) || discoveredAt.getTime() > Date.now() || occurredAt && (!Number.isFinite(occurredAt.getTime()) || occurredAt > discoveredAt)) return res.status(400).json({ error: 'Breach dates are invalid or inconsistent' });
70
+ const estimatedAffected = input.estimatedAffected == null ? null : count(input.estimatedAffected);
71
+ const approximateRecordCount = input.approximateRecordCount == null ? null : count(input.approximateRecordCount);
72
+ if (input.estimatedAffected != null && estimatedAffected === null || input.approximateRecordCount != null && approximateRecordCount === null) return res.status(400).json({ error: 'Counts must be finite non-negative numbers' });
73
+ const dataSubjectCategories = Array.isArray(input.dataSubjectCategories) && input.dataSubjectCategories.every((v) => typeof v === 'string') ? input.dataSubjectCategories as string[] : [];
74
+ const severity = severityFor(input.category as string, estimatedAffected);
75
+ const reportedAt = new Date();
76
+ const data = { tenantId: context.tenantId, title: input.title as string, description: input.description as string, category: input.category as string, severity, status: 'ongoing', discoveredAt, occurredAt, reporterName: actor.displayName, reporterEmail: actor.email, reporterDepartment: actor.department ?? null, affectedSystems: input.affectedSystems as string[], dataTypes: input.dataTypes as string[], estimatedAffected, approximateRecordCount, dataSubjectCategories, likelyConsequences: typeof input.likelyConsequences === 'string' ? input.likelyConsequences : null, mitigationMeasures: typeof input.mitigationMeasures === 'string' ? input.mitigationMeasures : null, involvesSensitiveData: input.involvesSensitiveData === true, isPhasedReport: input.isPhasedReport === true, supplementsReportId: typeof input.supplementsReportId === 'string' ? input.supplementsReportId : null, initialActions: typeof input.initialActions === 'string' ? input.initialActions : null };
77
+ // {{#if ORM=prisma}}
78
+ const report = await prisma.$transaction(async (tx) => {
79
+ const created = await tx.breachReport.create({ data });
80
+ await tx.complianceAuditLog.create({ data: { tenantId: context.tenantId, module: 'breach', action: 'reported', entityId: created.id, entityType: 'BreachReport', performedBy: actorId, changes: { title: data.title, category: data.category, severity, status: 'ongoing' } } });
81
+ return created;
82
+ });
83
+ // {{/if}}
84
+ // {{#if ORM=drizzle}}
85
+ const report = await db.transaction(async (tx) => {
86
+ const [created] = await tx.insert(breachReports).values(data).returning();
87
+ await tx.insert(complianceAuditLog).values({ tenantId: context.tenantId, module: 'breach', action: 'reported', entityId: created.id, entityType: 'BreachReport', performedBy: actorId, changes: { title: data.title, category: data.category, severity, status: 'ongoing' } });
88
+ return created;
89
+ });
90
+ // {{/if}}
91
+ // {{#if ORM=none}}
92
+ const report: BreachRow = { id: crypto.randomUUID(), ...data, reportedAt };
93
+ breachStore.set(report.id, report);
94
+ auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'reported', entityId: report.id, performedBy: actorId, at: reportedAt });
95
+ // {{/if}}
96
+ const readiness = assessBreachNotification({ id: report.id, title: data.title, description: data.description, category: data.category, discoveredAt: discoveredAt.getTime(), occurredAt: occurredAt?.getTime(), reportedAt: reportedAt.getTime(), reporter: { name: data.reporterName, email: data.reporterEmail, department: data.reporterDepartment ?? '' }, affectedSystems: data.affectedSystems, dataTypes: data.dataTypes, involvesSensitiveData: data.involvesSensitiveData, estimatedAffectedSubjects: estimatedAffected ?? undefined, approximateRecordCount: approximateRecordCount ?? undefined, dataSubjectCategories, likelyConsequences: data.likelyConsequences ?? undefined, mitigationMeasures: data.mitigationMeasures ?? undefined, isPhasedReport: data.isPhasedReport, supplementsReportId: data.supplementsReportId ?? undefined, initialActions: data.initialActions ?? undefined, dpoContact: { name: `{{ORG_NAME_TEMPLATE}} DPO`, email: `{{DPO_EMAIL_TEMPLATE}}` }, status: 'ongoing' });
97
+ return res.status(201).json({ ...report, ndpcReadiness: { complete: readiness.complete, ready: readiness.ready, valid: readiness.valid, completeness: readiness.completeness, missing: readiness.missing, validationErrors: readiness.validationErrors, notificationRequired: readiness.notificationRequired, hoursRemaining: readiness.timing.hoursRemaining, evidenceRecorded: readiness.evidence.notificationProvided } });
98
+ });