@tantainnovative/create-ndpr 0.1.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,22 +1,32 @@
1
1
  {
2
2
  "name": "@tantainnovative/create-ndpr",
3
- "version": "0.1.0",
4
- "description": "CLI scaffolder for NDPA compliance with @tantainnovative/ndpr-toolkit",
3
+ "version": "0.5.0",
4
+ "description": "CLI scaffolder for Nigeria NDPA 2023 / NDPC GAID 2025 compliance with @tantainnovative/ndpr-toolkit",
5
5
  "license": "MIT",
6
6
  "author": {
7
7
  "name": "Abraham Esandayinze Tanta",
8
8
  "url": "https://linkedin.com/in/mr-tanta"
9
9
  },
10
+ "homepage": "https://ndprtoolkit.com.ng",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/mr-tanta/ndpr-toolkit.git",
14
+ "directory": "packages/create-ndpr"
15
+ },
10
16
  "bin": {
11
- "create-ndpr": "./bin/index.mjs"
17
+ "create-ndpr": "bin/index.mjs"
12
18
  },
13
19
  "files": [
14
20
  "bin/",
15
- "templates/"
21
+ "templates/",
22
+ "README.md"
16
23
  ],
17
24
  "keywords": [
18
25
  "ndpa",
19
26
  "ndpr",
27
+ "gaid-2025",
28
+ "dcpmi",
29
+ "compliance-audit-return",
20
30
  "cli",
21
31
  "scaffold",
22
32
  "nigeria",
@@ -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,19 +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
- * - auditLog — §44 audit trail
12
- *
13
- * Prerequisites: drizzle-orm, @paralleldrive/cuid2
14
- * Run `drizzle-kit push` to apply.
15
- */
16
-
17
1
  import {
18
2
  pgTable,
19
3
  text,
@@ -22,142 +6,265 @@ import {
22
6
  boolean,
23
7
  integer,
24
8
  index,
9
+ uniqueIndex,
25
10
  } from 'drizzle-orm/pg-core';
26
11
  import { createId } from '@paralleldrive/cuid2';
27
12
 
28
- // ---------------------------------------------------------------------------
29
- // ndpr_consent_records NDPA §25–26
30
- // ---------------------------------------------------------------------------
13
+ /**
14
+ * NDPA persistence schema for {{ORG_NAME_COMMENT}}.
15
+ * Every operational and audit row carries a required server-resolved tenantId.
16
+ */
31
17
 
32
18
  export const consentRecords = pgTable(
33
19
  'ndpr_consent_records',
34
20
  {
35
- id: text('id').primaryKey().$defaultFn(() => createId()),
36
- subjectId: text('subject_id').notNull(),
37
- consents: json('consents').notNull(),
38
- version: text('version').notNull(),
39
- method: text('method').notNull(),
40
- lawfulBasis: text('lawful_basis'),
41
- ipAddress: text('ip_address'),
42
- userAgent: text('user_agent'),
43
- createdAt: timestamp('created_at').defaultNow().notNull(),
44
- 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'),
45
35
  },
46
- (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
+ }),
47
53
  );
48
54
 
49
- // ---------------------------------------------------------------------------
50
- // ndpr_dsr_requests — NDPA §34–38
51
- // ---------------------------------------------------------------------------
52
-
53
55
  export const dsrRequests = pgTable(
54
56
  'ndpr_dsr_requests',
55
57
  {
56
- id: text('id').primaryKey().$defaultFn(() => createId()),
57
- type: text('type').notNull(),
58
- status: text('status').notNull().default('pending'),
59
- subjectName: text('subject_name').notNull(),
60
- subjectEmail: text('subject_email').notNull(),
61
- subjectPhone: text('subject_phone'),
62
- 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(),
63
67
  identifierValue: text('identifier_value').notNull(),
64
- description: text('description'),
65
- internalNotes: text('internal_notes'),
66
- assignedTo: text('assigned_to'),
67
- submittedAt: timestamp('submitted_at').defaultNow().notNull(),
68
- acknowledgedAt: timestamp('acknowledged_at'),
69
- completedAt: timestamp('completed_at'),
70
- 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(),
71
75
  },
72
- (t) => ({
73
- statusIdx: index('dsr_status_idx').on(t.status),
74
- 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),
75
80
  }),
76
81
  );
77
82
 
78
- // ---------------------------------------------------------------------------
79
- // ndpr_breach_reports — NDPA §40
80
- // ---------------------------------------------------------------------------
81
-
82
83
  export const breachReports = pgTable(
83
84
  'ndpr_breach_reports',
84
85
  {
85
- id: text('id').primaryKey().$defaultFn(() => createId()),
86
- title: text('title').notNull(),
87
- description: text('description').notNull(),
88
- category: text('category').notNull(),
89
- severity: text('severity').notNull(),
90
- status: text('status').notNull().default('ongoing'),
91
- discoveredAt: timestamp('discovered_at').notNull(),
92
- occurredAt: timestamp('occurred_at'),
93
- reportedAt: timestamp('reported_at').defaultNow().notNull(),
94
- ndpcNotifiedAt: timestamp('ndpc_notified_at'),
95
- reporterName: text('reporter_name').notNull(),
96
- reporterEmail: text('reporter_email').notNull(),
97
- reporterDepartment: text('reporter_department'),
98
- affectedSystems: json('affected_systems').notNull(),
99
- dataTypes: json('data_types').notNull(),
100
- estimatedAffected: integer('estimated_affected'),
101
- 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'),
102
111
  ndpcNotificationSent: boolean('ndpc_notification_sent').notNull().default(false),
103
112
  },
104
- (t) => ({
105
- statusIdx: index('breach_status_idx').on(t.status),
106
- 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),
116
+ }),
117
+ );
118
+
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),
107
140
  }),
108
141
  );
109
142
 
110
- // ---------------------------------------------------------------------------
111
- // ndpr_processing_records — ROPA
112
- // ---------------------------------------------------------------------------
143
+ export const dpiaRecords = pgTable(
144
+ 'ndpr_dpia_records',
145
+ {
146
+ id: text('id').primaryKey().$defaultFn(() => createId()),
147
+ tenantId: text('tenant_id').notNull(),
148
+ projectName: text('project_name').notNull(),
149
+ description: text('description').notNull(),
150
+ dpiaData: json('dpia_data').notNull(),
151
+ overallRisk: text('overall_risk').notNull(),
152
+ score: integer('score').notNull(),
153
+ status: text('status').notNull().default('draft'),
154
+ conductedBy: text('conducted_by').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'),
159
+ },
160
+ (table) => ({
161
+ tenantStatusIdx: index('dpia_tenant_status_idx').on(
162
+ table.tenantId,
163
+ table.removedAt,
164
+ table.status,
165
+ ),
166
+ }),
167
+ );
113
168
 
114
- export const processingRecords = pgTable('ndpr_processing_records', {
115
- id: text('id').primaryKey().$defaultFn(() => createId()),
116
- purpose: text('purpose').notNull(),
117
- lawfulBasis: text('lawful_basis').notNull(),
118
- dataCategories: json('data_categories').notNull(),
119
- dataSubjects: json('data_subjects').notNull(),
120
- recipients: json('recipients').notNull(),
121
- retentionPeriod: text('retention_period').notNull(),
122
- securityMeasures: json('security_measures').notNull(),
123
- transferCountries: json('transfer_countries'),
124
- transferMechanism: text('transfer_mechanism'),
125
- dpiaConducted: boolean('dpia_conducted').notNull().default(false),
126
- status: text('status').notNull().default('active'),
127
- createdAt: timestamp('created_at').defaultNow().notNull(),
128
- updatedAt: timestamp('updated_at').defaultNow().notNull(),
129
- });
169
+ export const lawfulBasisRecords = pgTable(
170
+ 'ndpr_lawful_basis_records',
171
+ {
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(),
177
+ dataCategories: json('data_categories').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'),
185
+ },
186
+ (table) => ({
187
+ tenantBasisIdx: index('lawful_basis_tenant_idx').on(
188
+ table.tenantId,
189
+ table.removedAt,
190
+ table.lawfulBasis,
191
+ ),
192
+ }),
193
+ );
130
194
 
131
- // ---------------------------------------------------------------------------
132
- // ndpr_audit_log — §44 accountability
133
- // ---------------------------------------------------------------------------
195
+ export const crossBorderTransferRecords = pgTable(
196
+ 'ndpr_cross_border_transfer_records',
197
+ {
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),
207
+ ndpcApprovalReference: text('ndpc_approval_reference'),
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'),
213
+ },
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
+ ),
230
+ }),
231
+ );
134
232
 
135
- export const auditLog = pgTable(
233
+ /** Append-oriented application audit rows; enforce immutability in DB permissions. */
234
+ export const complianceAuditLog = pgTable(
136
235
  'ndpr_audit_log',
137
236
  {
138
- id: text('id').primaryKey().$defaultFn(() => createId()),
139
- module: text('module').notNull(),
140
- action: text('action').notNull(),
141
- entityId: text('entity_id').notNull(),
142
- entityType: text('entity_type').notNull(),
143
- 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'),
144
244
  performedBy: text('performed_by'),
145
- createdAt: timestamp('created_at').defaultNow().notNull(),
245
+ createdAt: timestamp('created_at').defaultNow().notNull(),
146
246
  },
147
- (t) => ({
148
- 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
+ ),
149
253
  }),
150
254
  );
151
255
 
152
- // ---------------------------------------------------------------------------
153
- // Inferred types
154
- // ---------------------------------------------------------------------------
155
-
156
- export type ConsentRecord = typeof consentRecords.$inferSelect;
256
+ export type ConsentRecord = typeof consentRecords.$inferSelect;
157
257
  export type NewConsentRecord = typeof consentRecords.$inferInsert;
158
- export type DSRRequest = typeof dsrRequests.$inferSelect;
159
- export type NewDSRRequest = typeof dsrRequests.$inferInsert;
160
- export type BreachReport = typeof breachReports.$inferSelect;
161
- export type NewBreachReport = typeof breachReports.$inferInsert;
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;
162
262
  export type ProcessingRecord = typeof processingRecords.$inferSelect;
163
- export type AuditLogEntry = typeof auditLog.$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
+ });