@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.
@@ -1,287 +1,332 @@
1
- /**
2
- * Express — DPIA (Data Protection Impact Assessment) Router
3
- * Generated by create-ndpr for: {{ORG_NAME}}
4
- * DPO: {{DPO_EMAIL}}
5
- *
6
- * NDPA §28 — a DPIA must be conducted where processing is likely
7
- * to result in a high risk to the rights and freedoms of data subjects.
8
- *
9
- * Routes:
10
- * GET /dpia — List DPIA records (optional ?status= filter)
11
- * GET /dpia/:id — Get a single DPIA record
12
- * POST /dpia — Create a new DPIA record
13
- * PUT /dpia/:id — Update an existing DPIA record
14
- * DELETE /dpia/:id — Delete a DPIA record
15
- *
16
- * Usage:
17
- * import { dpiaRouter } from './routes/dpia';
18
- * app.use('/api/dpia', dpiaRouter);
19
- */
20
-
1
+ /** Express — staff-only, tenant-scoped DPIA register for {{ORG_NAME_COMMENT}}. */
21
2
  import { Router } from 'express';
3
+ import { getNDPRContextProblem, resolveNDPRRequestContext } from '{{NDPR_CONTEXT_IMPORT}}';
22
4
  // {{#if ORM=prisma}}
23
- import { PrismaClient } from '@prisma/client';
24
-
5
+ import { Prisma, PrismaClient } from '@prisma/client';
25
6
  const prisma = new PrismaClient();
26
7
  // {{/if}}
27
8
  // {{#if ORM=drizzle}}
28
- import { db } from '@/drizzle';
29
- import { dpiaRecords, complianceAuditLog } from '@/drizzle/ndpr-schema';
30
- import { eq, desc } from 'drizzle-orm';
9
+ import { db } from '{{NDPR_DB_IMPORT}}';
10
+ import { complianceAuditLog, dpiaRecords } from '{{NDPR_SCHEMA_IMPORT}}';
11
+ import { and, desc, eq, isNull } from 'drizzle-orm';
31
12
  // {{/if}}
32
- // {{#if ORM=none}}
33
- // TODO ({{ORG_NAME}}): wire this to your persistent store of choice. The
34
- // in-memory map below is enough to develop against locally but DOES NOT
35
- // satisfy NDPA Section 44 (record-keeping). Replace `dpiaStore`/`auditLog`
36
- // with your DB / KV / API.
37
- interface DPIARecord {
13
+
14
+ const DPIA_STATUSES = ['draft', 'in_progress', 'completed', 'approved', 'rejected'] as const;
15
+ const RISK_LEVELS = ['low', 'medium', 'high', 'critical'] as const;
16
+ const LAWFUL_BASES = ['consent', 'contract', 'legal_obligation', 'vital_interests', 'public_interest', 'legitimate_interests'] as const;
17
+ type DPIAStatus = (typeof DPIA_STATUSES)[number];
18
+ type RiskLevel = (typeof RISK_LEVELS)[number];
19
+ type AnswerValue = string | number | boolean | string[];
20
+ type Validation<T> = { ok: true; value: T } | { ok: false; error: string };
21
+
22
+ interface DPIARiskEvidence {
38
23
  id: string;
24
+ description: string;
25
+ likelihood: number;
26
+ impact: number;
27
+ score: number;
28
+ level: RiskLevel;
29
+ mitigated: boolean;
30
+ mitigationMeasures?: string[];
31
+ residualScore?: number;
32
+ relatedQuestionIds: string[];
33
+ }
34
+ interface DPIAEvidence {
35
+ answers: Record<string, AnswerValue>;
36
+ risks: DPIARiskEvidence[];
37
+ conclusion: string;
38
+ version: string;
39
+ recommendations?: string[];
40
+ reviewDate?: number;
41
+ lawfulBasis?: (typeof LAWFUL_BASES)[number];
42
+ involvesCrossBorderTransfer?: boolean;
43
+ overallRiskLevel: RiskLevel;
44
+ canProceed: boolean;
45
+ ndpcConsultationRequired: boolean;
46
+ completedAt: number | null;
47
+ }
48
+ interface DPIAExisting {
39
49
  projectName: string;
40
50
  description: string;
41
51
  dpiaData: unknown;
42
- overallRisk: string;
43
- score: number;
44
52
  status: string;
45
53
  conductedBy: string;
46
54
  approvedBy: string | null;
47
- createdAt: Date;
55
+ }
56
+ interface DPIAMutation {
57
+ projectName: string;
58
+ description: string;
59
+ dpiaData: DPIAEvidence;
60
+ overallRisk: RiskLevel;
61
+ score: number;
62
+ status: DPIAStatus;
63
+ conductedBy: string;
64
+ approvedBy: string | null;
48
65
  updatedAt: Date;
49
66
  }
50
- const dpiaStore = new Map<string, DPIARecord>();
51
- const auditLog: Array<{ id: string; module: string; action: string; entityId: string; at: Date }> = [];
52
- function newId() { return crypto.randomUUID(); }
67
+
68
+ // {{#if ORM=none}}
69
+ // DEVELOPMENT ONLY: replace both stores with one durable transactional store.
70
+ interface DPIARow extends DPIAExisting { id: string; tenantId: string; dpiaData: DPIAEvidence; overallRisk: RiskLevel; score: number; status: DPIAStatus; createdAt: Date; updatedAt: Date; removedAt: Date | null }
71
+ const dpiaStore = new Map<string, DPIARow>();
72
+ const auditLog: Array<{ id: string; tenantId: string; action: string; entityId: string; performedBy: string; changes?: unknown; at: Date }> = [];
53
73
  // {{/if}}
54
74
 
75
+ function isRecord(value: unknown): value is Record<string, unknown> {
76
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
77
+ }
78
+ function hasOwn(value: Record<string, unknown>, key: string): boolean {
79
+ return Object.prototype.hasOwnProperty.call(value, key);
80
+ }
81
+ function rejectUnknownFields(value: Record<string, unknown>, allowed: readonly string[]): string | null {
82
+ const allowedSet = new Set(allowed);
83
+ const unknown = Object.keys(value).filter((key) => !allowedSet.has(key));
84
+ return unknown.length > 0 ? `Unsupported field(s): ${unknown.join(', ')}` : null;
85
+ }
86
+ function nonEmptyText(value: unknown, field: string, maxLength = 10_000): Validation<string> {
87
+ if (typeof value !== 'string' || !value.trim() || value.trim().length > maxLength) return { ok: false, error: `${field} must be non-empty text of at most ${maxLength} characters` };
88
+ return { ok: true, value: value.trim() };
89
+ }
90
+ function nonEmptyStringArray(value: unknown, field: string): Validation<string[]> {
91
+ if (!Array.isArray(value) || value.length === 0 || value.length > 100) return { ok: false, error: `${field} must be a non-empty array with at most 100 items` };
92
+ const result: string[] = [];
93
+ for (const item of value) {
94
+ if (typeof item !== 'string' || !item.trim()) return { ok: false, error: `${field} must contain only non-empty strings` };
95
+ if (!result.includes(item.trim())) result.push(item.trim());
96
+ }
97
+ return { ok: true, value: result };
98
+ }
99
+ function parseTimestamp(value: unknown): number | undefined {
100
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) return value;
101
+ if (typeof value !== 'string') return undefined;
102
+ const input = value.trim();
103
+ const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(input);
104
+ const dateTime = /^(\d{4})-(\d{2})-(\d{2})T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.exec(input);
105
+ if (!dateOnly && !dateTime) return undefined;
106
+ const timestamp = Date.parse(input);
107
+ if (!Number.isFinite(timestamp)) return undefined;
108
+ const parts = dateOnly ?? dateTime;
109
+ const check = new Date(Date.UTC(Number(parts![1]), Number(parts![2]) - 1, Number(parts![3])));
110
+ if (check.getUTCFullYear() !== Number(parts![1]) || check.getUTCMonth() + 1 !== Number(parts![2]) || check.getUTCDate() !== Number(parts![3])) return undefined;
111
+ return timestamp;
112
+ }
113
+ function isDPIAStatus(value: unknown): value is DPIAStatus {
114
+ return typeof value === 'string' && (DPIA_STATUSES as readonly string[]).includes(value);
115
+ }
116
+ function riskLevelForScore(score: number): RiskLevel {
117
+ if (score >= 17) return 'critical';
118
+ if (score >= 10) return 'high';
119
+ if (score >= 5) return 'medium';
120
+ return 'low';
121
+ }
122
+ function highestRiskLevel(risks: DPIARiskEvidence[]): RiskLevel {
123
+ const rank: Record<RiskLevel, number> = { low: 0, medium: 1, high: 2, critical: 3 };
124
+ return risks.reduce<RiskLevel>((highest, risk) => rank[risk.level] > rank[highest] ? risk.level : highest, 'low');
125
+ }
126
+ function isSafeQuestionId(questionId: string): boolean {
127
+ return questionId.length > 0
128
+ && questionId.length <= 200
129
+ && questionId !== '__proto__'
130
+ && questionId !== 'prototype'
131
+ && !Object.prototype.hasOwnProperty.call(Object.prototype, questionId);
132
+ }
133
+ function normalizeAnswers(value: unknown): Validation<Record<string, AnswerValue>> {
134
+ if (!isRecord(value) || Object.keys(value).length === 0 || Object.keys(value).length > 500) return { ok: false, error: 'dpiaData.answers must be a non-empty object with at most 500 answers' };
135
+ const answers = new Map<string, AnswerValue>();
136
+ for (const [rawQuestionId, answer] of Object.entries(value)) {
137
+ const questionId = rawQuestionId.trim();
138
+ if (!questionId || !isSafeQuestionId(questionId)) return { ok: false, error: 'dpiaData.answers question ids must be non-reserved text with at most 200 characters' };
139
+ if (typeof answer === 'string') {
140
+ if (!answer.trim()) return { ok: false, error: `dpiaData.answers.${questionId} cannot be empty` };
141
+ answers.set(questionId, answer.trim());
142
+ } else if (typeof answer === 'number') {
143
+ if (!Number.isFinite(answer)) return { ok: false, error: `dpiaData.answers.${questionId} must be finite` };
144
+ answers.set(questionId, answer);
145
+ } else if (typeof answer === 'boolean') answers.set(questionId, answer);
146
+ else if (Array.isArray(answer)) {
147
+ const normalized = nonEmptyStringArray(answer, `dpiaData.answers.${questionId}`);
148
+ if (!normalized.ok) return normalized;
149
+ answers.set(questionId, normalized.value);
150
+ } else return { ok: false, error: `dpiaData.answers.${questionId} must be text, a finite number, boolean, or a non-empty string array` };
151
+ }
152
+ return { ok: true, value: Object.fromEntries(answers) };
153
+ }
154
+ function normalizeRisks(value: unknown, answers: Record<string, AnswerValue>, stored: boolean): Validation<DPIARiskEvidence[]> {
155
+ if (!Array.isArray(value) || value.length === 0 || value.length > 100) return { ok: false, error: 'dpiaData.risks must be a non-empty array with at most 100 risks' };
156
+ const risks: DPIARiskEvidence[] = [];
157
+ const ids = new Set<string>();
158
+ for (let index = 0; index < value.length; index += 1) {
159
+ const item = value[index];
160
+ if (!isRecord(item)) return { ok: false, error: `dpiaData.risks[${index}] must be an object` };
161
+ const unknown = rejectUnknownFields(item, stored
162
+ ? ['id', 'description', 'likelihood', 'impact', 'mitigated', 'mitigationMeasures', 'residualScore', 'relatedQuestionIds', 'score', 'level']
163
+ : ['id', 'description', 'likelihood', 'impact', 'mitigated', 'mitigationMeasures', 'residualScore', 'relatedQuestionIds']);
164
+ if (unknown) return { ok: false, error: `dpiaData.risks[${index}]: ${unknown}` };
165
+ const description = nonEmptyText(item.description, `dpiaData.risks[${index}].description`, 2_000);
166
+ if (!description.ok) return description;
167
+ if (!Number.isSafeInteger(item.likelihood) || (item.likelihood as number) < 1 || (item.likelihood as number) > 5) return { ok: false, error: `dpiaData.risks[${index}].likelihood must be an integer from 1 to 5` };
168
+ if (!Number.isSafeInteger(item.impact) || (item.impact as number) < 1 || (item.impact as number) > 5) return { ok: false, error: `dpiaData.risks[${index}].impact must be an integer from 1 to 5` };
169
+ if (typeof item.mitigated !== 'boolean') return { ok: false, error: `dpiaData.risks[${index}].mitigated must be boolean` };
170
+ const related = nonEmptyStringArray(item.relatedQuestionIds, `dpiaData.risks[${index}].relatedQuestionIds`);
171
+ if (!related.ok) return related;
172
+ if (related.value.some((questionId) => !hasOwn(answers, questionId))) return { ok: false, error: `dpiaData.risks[${index}].relatedQuestionIds must reference supplied answers` };
173
+ const mitigation = item.mitigationMeasures === undefined ? undefined : nonEmptyStringArray(item.mitigationMeasures, `dpiaData.risks[${index}].mitigationMeasures`);
174
+ if (mitigation && !mitigation.ok) return mitigation;
175
+ if (item.mitigated && !mitigation) return { ok: false, error: `dpiaData.risks[${index}] requires mitigationMeasures when mitigated is true` };
176
+ if (!item.mitigated && item.residualScore !== undefined) return { ok: false, error: `dpiaData.risks[${index}].residualScore is only valid for a mitigated risk` };
177
+ const score = (item.likelihood as number) * (item.impact as number);
178
+ let residualScore: number | undefined;
179
+ if (item.residualScore !== undefined) {
180
+ if (!Number.isSafeInteger(item.residualScore) || (item.residualScore as number) < 0 || (item.residualScore as number) > score) return { ok: false, error: `dpiaData.risks[${index}].residualScore must be an integer from 0 to the computed score (${score})` };
181
+ residualScore = item.residualScore as number;
182
+ }
183
+ if (item.mitigated && residualScore === undefined) return { ok: false, error: `dpiaData.risks[${index}] requires residualScore when mitigated is true` };
184
+ const level = riskLevelForScore(residualScore ?? score);
185
+ if (stored && (item.score !== score || item.level !== level)) return { ok: false, error: `dpiaData.risks[${index}] contains inconsistent server-derived score or level` };
186
+ const idValue = item.id === undefined && !stored ? crypto.randomUUID() : nonEmptyText(item.id, `dpiaData.risks[${index}].id`, 200);
187
+ if (typeof idValue !== 'string' && !idValue.ok) return idValue;
188
+ const id = typeof idValue === 'string' ? idValue : idValue.value;
189
+ if (ids.has(id)) return { ok: false, error: `dpiaData.risks contains duplicate id ${id}` };
190
+ ids.add(id);
191
+ risks.push({ id, description: description.value, likelihood: item.likelihood as number, impact: item.impact as number, score, level, mitigated: item.mitigated, ...(mitigation?.ok ? { mitigationMeasures: mitigation.value } : {}), ...(residualScore !== undefined ? { residualScore } : {}), relatedQuestionIds: related.value });
192
+ }
193
+ return { ok: true, value: risks };
194
+ }
195
+ function normalizeDPIAEvidence(value: unknown, now: number, stored = false): Validation<DPIAEvidence> {
196
+ if (!isRecord(value)) return { ok: false, error: 'dpiaData must be an object' };
197
+ const unknown = rejectUnknownFields(value, stored
198
+ ? ['answers', 'risks', 'conclusion', 'version', 'recommendations', 'reviewDate', 'lawfulBasis', 'involvesCrossBorderTransfer', 'overallRiskLevel', 'canProceed', 'ndpcConsultationRequired', 'completedAt']
199
+ : ['answers', 'risks', 'conclusion', 'version', 'recommendations', 'reviewDate', 'lawfulBasis', 'involvesCrossBorderTransfer']);
200
+ if (unknown) return { ok: false, error: `dpiaData: ${unknown}` };
201
+ const answers = normalizeAnswers(value.answers); if (!answers.ok) return answers;
202
+ const risks = normalizeRisks(value.risks, answers.value, stored); if (!risks.ok) return risks;
203
+ const conclusion = nonEmptyText(value.conclusion, 'dpiaData.conclusion', 10_000); if (!conclusion.ok) return conclusion;
204
+ const version = nonEmptyText(value.version, 'dpiaData.version', 100); if (!version.ok) return version;
205
+ const recommendations = value.recommendations == null ? undefined : nonEmptyStringArray(value.recommendations, 'dpiaData.recommendations'); if (recommendations && !recommendations.ok) return recommendations;
206
+ const reviewDate = value.reviewDate == null ? undefined : parseTimestamp(value.reviewDate);
207
+ if (value.reviewDate != null && reviewDate === undefined) return { ok: false, error: 'dpiaData.reviewDate must be epoch milliseconds or an unambiguous ISO date' };
208
+ if (!stored && reviewDate !== undefined && reviewDate <= now) return { ok: false, error: 'dpiaData.reviewDate must be in the future' };
209
+ if (value.lawfulBasis != null && (typeof value.lawfulBasis !== 'string' || !(LAWFUL_BASES as readonly string[]).includes(value.lawfulBasis))) return { ok: false, error: 'dpiaData.lawfulBasis is not a supported NDPA lawful basis' };
210
+ if (value.involvesCrossBorderTransfer !== undefined && typeof value.involvesCrossBorderTransfer !== 'boolean') return { ok: false, error: 'dpiaData.involvesCrossBorderTransfer must be boolean' };
211
+ const overallRiskLevel = highestRiskLevel(risks.value);
212
+ const canProceed = !risks.value.some((risk) => risk.level === 'high' || risk.level === 'critical');
213
+ const ndpcConsultationRequired = overallRiskLevel === 'high' || overallRiskLevel === 'critical';
214
+ const completedAt = stored && value.completedAt != null ? parseTimestamp(value.completedAt) : undefined;
215
+ if (stored && value.completedAt != null && completedAt === undefined) return { ok: false, error: 'dpiaData.completedAt is invalid' };
216
+ if (stored && completedAt !== undefined && completedAt > now) return { ok: false, error: 'dpiaData.completedAt cannot be in the future' };
217
+ if (stored && (value.overallRiskLevel !== overallRiskLevel || value.canProceed !== canProceed || value.ndpcConsultationRequired !== ndpcConsultationRequired)) return { ok: false, error: 'dpiaData contains inconsistent server-derived risk evidence' };
218
+ return { ok: true, value: { answers: answers.value, risks: risks.value, conclusion: conclusion.value, version: version.value, ...(recommendations?.ok ? { recommendations: recommendations.value } : {}), ...(reviewDate !== undefined ? { reviewDate } : {}), ...(typeof value.lawfulBasis === 'string' ? { lawfulBasis: value.lawfulBasis as (typeof LAWFUL_BASES)[number] } : {}), ...(typeof value.involvesCrossBorderTransfer === 'boolean' ? { involvesCrossBorderTransfer: value.involvesCrossBorderTransfer } : {}), overallRiskLevel, canProceed, ndpcConsultationRequired, completedAt: completedAt ?? null } };
219
+ }
220
+
221
+ const STATUS_TRANSITIONS: Record<DPIAStatus, readonly DPIAStatus[]> = {
222
+ draft: ['draft', 'in_progress'], in_progress: ['in_progress', 'draft', 'completed', 'rejected'], completed: ['completed', 'in_progress', 'approved', 'rejected'], approved: ['approved', 'in_progress'], rejected: ['rejected', 'in_progress'],
223
+ };
224
+ function buildDPIAMutation(input: Record<string, unknown>, actorId: string, existing?: DPIAExisting, now = Date.now()): Validation<DPIAMutation> {
225
+ const projectName = hasOwn(input, 'projectName') ? nonEmptyText(input.projectName, 'projectName', 500) : existing ? { ok: true as const, value: existing.projectName } : { ok: false as const, error: 'projectName is required' }; if (!projectName.ok) return projectName;
226
+ const description = hasOwn(input, 'description') ? nonEmptyText(input.description, 'description', 10_000) : existing ? { ok: true as const, value: existing.description } : { ok: false as const, error: 'description is required' }; if (!description.ok) return description;
227
+ const evidence = hasOwn(input, 'dpiaData') ? normalizeDPIAEvidence(input.dpiaData, now) : existing ? normalizeDPIAEvidence(existing.dpiaData, now, true) : { ok: false as const, error: 'dpiaData is required' }; if (!evidence.ok) return evidence;
228
+ if (input.status !== undefined && !isDPIAStatus(input.status)) return { ok: false, error: 'status is not supported' };
229
+ const status: DPIAStatus = isDPIAStatus(input.status) ? input.status : existing && isDPIAStatus(existing.status) ? existing.status : 'draft';
230
+ if (existing) {
231
+ if (!isDPIAStatus(existing.status)) return { ok: false, error: 'Stored DPIA status is invalid' };
232
+ if (!STATUS_TRANSITIONS[existing.status].includes(status)) return { ok: false, error: `DPIA status cannot transition from ${existing.status} to ${status}` };
233
+ const evidenceChanged = hasOwn(input, 'projectName') || hasOwn(input, 'description') || hasOwn(input, 'dpiaData');
234
+ if (evidenceChanged && ['completed', 'approved', 'rejected'].includes(existing.status) && status !== 'in_progress') return { ok: false, error: 'A terminal DPIA must be reopened as in_progress before its evidence is changed' };
235
+ }
236
+ if (status === 'approved' && !evidence.value.canProceed) return { ok: false, error: 'A DPIA with high or critical residual risk cannot be approved' };
237
+ if (status === 'rejected' && evidence.value.canProceed) return { ok: false, error: 'A rejected DPIA must contain residual risk that prevents proceeding' };
238
+ const terminal = status === 'completed' || status === 'approved' || status === 'rejected';
239
+ const previouslyTerminal = existing && (existing.status === 'completed' || existing.status === 'approved' || existing.status === 'rejected');
240
+ const dpiaData: DPIAEvidence = { ...evidence.value, completedAt: terminal ? (previouslyTerminal ? evidence.value.completedAt ?? now : now) : null };
241
+ const score = dpiaData.risks.reduce((highest, risk) => Math.max(highest, risk.residualScore ?? risk.score), 0);
242
+ return { ok: true, value: { projectName: projectName.value, description: description.value, dpiaData, overallRisk: dpiaData.overallRiskLevel, score, status, conductedBy: actorId, approvedBy: status === 'approved' ? (existing?.status === 'approved' ? existing.approvedBy ?? actorId : actorId) : null, updatedAt: new Date(now) } };
243
+ }
244
+ function auditChanges(data: DPIAMutation) {
245
+ return { projectName: data.projectName, overallRisk: data.overallRisk, score: data.score, status: data.status, conductedBy: data.conductedBy, approvedBy: data.approvedBy };
246
+ }
247
+
55
248
  export const dpiaRouter = Router();
56
249
 
57
- // GET /dpia?status=draft
58
250
  dpiaRouter.get('/', async (req, res) => {
59
- const { status } = req.query;
60
- const statusFilter = typeof status === 'string' ? status : undefined;
61
-
251
+ const context = await resolveNDPRRequestContext(req); const problem = getNDPRContextProblem(context, 'staff'); if (problem) return res.status(problem.status).json({ error: problem.error });
252
+ const statusValue = typeof req.query.status === 'string' ? req.query.status : undefined;
253
+ if (statusValue !== undefined && !isDPIAStatus(statusValue)) return res.status(400).json({ error: 'status filter is not supported' });
62
254
  // {{#if ORM=prisma}}
63
- const records = await prisma.dPIARecord.findMany({
64
- where: statusFilter ? { status: statusFilter } : undefined,
65
- orderBy: { createdAt: 'desc' },
66
- });
255
+ const records = await prisma.dPIARecord.findMany({ where: { tenantId: context.tenantId, removedAt: null, ...(statusValue ? { status: statusValue } : {}) }, orderBy: { createdAt: 'desc' } });
67
256
  // {{/if}}
68
257
  // {{#if ORM=drizzle}}
69
- const records = statusFilter
70
- ? await db.select().from(dpiaRecords).where(eq(dpiaRecords.status, statusFilter)).orderBy(desc(dpiaRecords.createdAt))
71
- : await db.select().from(dpiaRecords).orderBy(desc(dpiaRecords.createdAt));
258
+ const records = statusValue ? await db.select().from(dpiaRecords).where(and(eq(dpiaRecords.tenantId, context.tenantId), isNull(dpiaRecords.removedAt), eq(dpiaRecords.status, statusValue))).orderBy(desc(dpiaRecords.createdAt)) : await db.select().from(dpiaRecords).where(and(eq(dpiaRecords.tenantId, context.tenantId), isNull(dpiaRecords.removedAt))).orderBy(desc(dpiaRecords.createdAt));
72
259
  // {{/if}}
73
260
  // {{#if ORM=none}}
74
- const records = [...dpiaStore.values()]
75
- .filter((r) => (statusFilter ? r.status === statusFilter : true))
76
- .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
261
+ const records = [...dpiaStore.values()].filter((row) => row.tenantId === context.tenantId && row.removedAt === null && (!statusValue || row.status === statusValue)).sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
77
262
  // {{/if}}
78
-
79
263
  return res.json(records);
80
264
  });
81
265
 
82
- // GET /dpia/:id
83
266
  dpiaRouter.get('/:id', async (req, res) => {
267
+ const context = await resolveNDPRRequestContext(req); const problem = getNDPRContextProblem(context, 'staff'); if (problem) return res.status(problem.status).json({ error: problem.error });
84
268
  // {{#if ORM=prisma}}
85
- const record = await prisma.dPIARecord.findUnique({ where: { id: req.params.id } });
269
+ const record = await prisma.dPIARecord.findFirst({ where: { id: req.params.id, tenantId: context.tenantId, removedAt: null } });
86
270
  // {{/if}}
87
271
  // {{#if ORM=drizzle}}
88
- const [record] = await db.select().from(dpiaRecords).where(eq(dpiaRecords.id, req.params.id)).limit(1);
272
+ const [record] = await db.select().from(dpiaRecords).where(and(eq(dpiaRecords.id, req.params.id), eq(dpiaRecords.tenantId, context.tenantId), isNull(dpiaRecords.removedAt))).limit(1);
89
273
  // {{/if}}
90
274
  // {{#if ORM=none}}
91
- const record = dpiaStore.get(req.params.id) ?? null;
275
+ const candidate = dpiaStore.get(req.params.id); const record = candidate?.tenantId === context.tenantId && candidate.removedAt === null ? candidate : null;
92
276
  // {{/if}}
93
-
94
- if (!record) {
95
- return res.status(404).json({ error: 'DPIA record not found' });
96
- }
97
-
98
- return res.json(record);
277
+ return record ? res.json(record) : res.status(404).json({ error: 'DPIA record not found' });
99
278
  });
100
279
 
101
- // POST /dpia
102
280
  dpiaRouter.post('/', async (req, res) => {
103
- const {
104
- projectName,
105
- description,
106
- dpiaData,
107
- overallRisk,
108
- score,
109
- conductedBy,
110
- approvedBy,
111
- } = req.body;
112
-
113
- if (!projectName || !description || !dpiaData || !overallRisk || score == null || !conductedBy) {
114
- return res.status(400).json({
115
- error: 'projectName, description, dpiaData, overallRisk, score, and conductedBy are required',
116
- });
117
- }
118
-
281
+ const context = await resolveNDPRRequestContext(req); const problem = getNDPRContextProblem(context, 'staff'); if (problem) return res.status(problem.status).json({ error: problem.error });
282
+ const actorId = context.actorId as string;
283
+ if (!isRecord(req.body)) return res.status(400).json({ error: 'A JSON object is required' });
284
+ const unknown = rejectUnknownFields(req.body, ['projectName', 'description', 'dpiaData']); if (unknown) return res.status(400).json({ error: unknown });
285
+ const normalized = buildDPIAMutation(req.body, actorId); if (!normalized.ok) return res.status(400).json({ error: normalized.error });
286
+ const data = normalized.value;
119
287
  // {{#if ORM=prisma}}
120
- const record = await prisma.dPIARecord.create({
121
- data: {
122
- projectName,
123
- description,
124
- dpiaData,
125
- overallRisk,
126
- score,
127
- status: 'draft',
128
- conductedBy,
129
- approvedBy: approvedBy ?? null,
130
- },
131
- });
132
-
133
- await prisma.complianceAuditLog.create({
134
- data: {
135
- module: 'dpia',
136
- action: 'created',
137
- entityId: record.id,
138
- entityType: 'DPIARecord',
139
- changes: { projectName, overallRisk, score, status: 'draft' },
140
- },
141
- });
288
+ const record = await prisma.$transaction(async (tx) => { const created = await tx.dPIARecord.create({ data: { ...data, tenantId: context.tenantId, dpiaData: data.dpiaData as unknown as Prisma.InputJsonValue, removedAt: null } }); await tx.complianceAuditLog.create({ data: { tenantId: context.tenantId, module: 'dpia', action: 'created', entityId: created.id, entityType: 'DPIARecord', performedBy: actorId, changes: auditChanges(data) } }); return created; });
142
289
  // {{/if}}
143
290
  // {{#if ORM=drizzle}}
144
- const [record] = await db.insert(dpiaRecords).values({
145
- projectName,
146
- description,
147
- dpiaData,
148
- overallRisk,
149
- score,
150
- status: 'draft',
151
- conductedBy,
152
- approvedBy: approvedBy ?? null,
153
- }).returning();
154
-
155
- await db.insert(complianceAuditLog).values({
156
- module: 'dpia',
157
- action: 'created',
158
- entityId: record.id,
159
- entityType: 'DPIARecord',
160
- changes: { projectName, overallRisk, score, status: 'draft' },
161
- });
291
+ const record = await db.transaction(async (tx) => { const [created] = await tx.insert(dpiaRecords).values({ ...data, tenantId: context.tenantId, removedAt: null }).returning(); await tx.insert(complianceAuditLog).values({ tenantId: context.tenantId, module: 'dpia', action: 'created', entityId: created.id, entityType: 'DPIARecord', performedBy: actorId, changes: auditChanges(data) }); return created; });
162
292
  // {{/if}}
163
293
  // {{#if ORM=none}}
164
- const now = new Date();
165
- const record: DPIARecord = {
166
- id: newId(),
167
- projectName,
168
- description,
169
- dpiaData,
170
- overallRisk,
171
- score,
172
- status: 'draft',
173
- conductedBy,
174
- approvedBy: approvedBy ?? null,
175
- createdAt: now,
176
- updatedAt: now,
177
- };
178
- dpiaStore.set(record.id, record);
179
- auditLog.push({ id: newId(), module: 'dpia', action: 'created', entityId: record.id, at: new Date() });
294
+ const now = data.updatedAt; const record: DPIARow = { id: crypto.randomUUID(), ...data, tenantId: context.tenantId, createdAt: now, removedAt: null }; dpiaStore.set(record.id, record); auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'created', entityId: record.id, performedBy: actorId, changes: auditChanges(data), at: now });
180
295
  // {{/if}}
181
-
182
296
  return res.status(201).json(record);
183
297
  });
184
298
 
185
- // PUT /dpia/:id
186
299
  dpiaRouter.put('/:id', async (req, res) => {
187
- const { id } = req.params;
188
-
300
+ const context = await resolveNDPRRequestContext(req); const problem = getNDPRContextProblem(context, 'staff'); if (problem) return res.status(problem.status).json({ error: problem.error });
301
+ const actorId = context.actorId as string;
302
+ if (!isRecord(req.body)) return res.status(400).json({ error: 'A JSON object is required' });
303
+ const unknown = rejectUnknownFields(req.body, ['projectName', 'description', 'dpiaData', 'status']); if (unknown) return res.status(400).json({ error: unknown });
304
+ if (!['projectName', 'description', 'dpiaData', 'status'].some((field) => hasOwn(req.body, field))) return res.status(400).json({ error: 'Provide at least one allowlisted DPIA field to update' });
305
+ const id = req.params.id; let validationError: string | null = null;
189
306
  // {{#if ORM=prisma}}
190
- const existing = await prisma.dPIARecord.findUnique({ where: { id } });
191
- if (!existing) {
192
- return res.status(404).json({ error: 'DPIA record not found' });
193
- }
194
-
195
- const record = await prisma.dPIARecord.update({
196
- where: { id },
197
- data: req.body,
198
- });
199
-
200
- await prisma.complianceAuditLog.create({
201
- data: {
202
- module: 'dpia',
203
- action: 'updated',
204
- entityId: record.id,
205
- entityType: 'DPIARecord',
206
- changes: req.body,
207
- },
208
- });
307
+ const record = await prisma.$transaction(async (tx) => { const existing = await tx.dPIARecord.findFirst({ where: { id, tenantId: context.tenantId, removedAt: null } }); if (!existing) return null; const normalized = buildDPIAMutation(req.body, actorId, existing); if (!normalized.ok) { validationError = normalized.error; return null; } const data = normalized.value; await tx.dPIARecord.updateMany({ where: { id, tenantId: context.tenantId, removedAt: null }, data: { ...data, dpiaData: data.dpiaData as unknown as Prisma.InputJsonValue } }); const updated = await tx.dPIARecord.findFirstOrThrow({ where: { id, tenantId: context.tenantId, removedAt: null } }); await tx.complianceAuditLog.create({ data: { tenantId: context.tenantId, module: 'dpia', action: 'updated', entityId: id, entityType: 'DPIARecord', performedBy: actorId, changes: auditChanges(data) } }); return updated; });
209
308
  // {{/if}}
210
309
  // {{#if ORM=drizzle}}
211
- const [existing] = await db.select().from(dpiaRecords).where(eq(dpiaRecords.id, id)).limit(1);
212
- if (!existing) {
213
- return res.status(404).json({ error: 'DPIA record not found' });
214
- }
215
-
216
- const [record] = await db.update(dpiaRecords).set(req.body).where(eq(dpiaRecords.id, id)).returning();
217
-
218
- await db.insert(complianceAuditLog).values({
219
- module: 'dpia',
220
- action: 'updated',
221
- entityId: record.id,
222
- entityType: 'DPIARecord',
223
- changes: req.body,
224
- });
310
+ const record = await db.transaction(async (tx) => { const [existing] = await tx.select().from(dpiaRecords).where(and(eq(dpiaRecords.id, id), eq(dpiaRecords.tenantId, context.tenantId), isNull(dpiaRecords.removedAt))).limit(1); if (!existing) return null; const normalized = buildDPIAMutation(req.body, actorId, existing); if (!normalized.ok) { validationError = normalized.error; return null; } const data = normalized.value; const [updated] = await tx.update(dpiaRecords).set(data).where(and(eq(dpiaRecords.id, id), eq(dpiaRecords.tenantId, context.tenantId), isNull(dpiaRecords.removedAt))).returning(); await tx.insert(complianceAuditLog).values({ tenantId: context.tenantId, module: 'dpia', action: 'updated', entityId: id, entityType: 'DPIARecord', performedBy: actorId, changes: auditChanges(data) }); return updated; });
225
311
  // {{/if}}
226
312
  // {{#if ORM=none}}
227
- const existing = dpiaStore.get(id);
228
- if (!existing) {
229
- return res.status(404).json({ error: 'DPIA record not found' });
230
- }
231
- const record: DPIARecord = { ...existing, ...req.body, id, updatedAt: new Date() };
232
- dpiaStore.set(id, record);
233
- auditLog.push({ id: newId(), module: 'dpia', action: 'updated', entityId: record.id, at: new Date() });
313
+ const existing = dpiaStore.get(id); let record: DPIARow | null = null; if (existing?.tenantId === context.tenantId && existing.removedAt === null) { const normalized = buildDPIAMutation(req.body, actorId, existing); if (!normalized.ok) validationError = normalized.error; else { record = { ...existing, ...normalized.value, id }; dpiaStore.set(id, record); auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'updated', entityId: id, performedBy: actorId, changes: auditChanges(normalized.value), at: normalized.value.updatedAt }); } }
234
314
  // {{/if}}
235
-
236
- return res.json(record);
315
+ if (validationError) return res.status(400).json({ error: validationError });
316
+ return record ? res.json(record) : res.status(404).json({ error: 'DPIA record not found' });
237
317
  });
238
318
 
239
- // DELETE /dpia/:id
240
319
  dpiaRouter.delete('/:id', async (req, res) => {
241
- const { id } = req.params;
242
-
320
+ const context = await resolveNDPRRequestContext(req); const problem = getNDPRContextProblem(context, 'staff'); if (problem) return res.status(problem.status).json({ error: problem.error });
321
+ const actorId = context.actorId as string; const id = req.params.id; const archivedAt = new Date();
243
322
  // {{#if ORM=prisma}}
244
- const existing = await prisma.dPIARecord.findUnique({ where: { id } });
245
- if (!existing) {
246
- return res.status(404).json({ error: 'DPIA record not found' });
247
- }
248
-
249
- await prisma.dPIARecord.delete({ where: { id } });
250
-
251
- await prisma.complianceAuditLog.create({
252
- data: {
253
- module: 'dpia',
254
- action: 'deleted',
255
- entityId: id,
256
- entityType: 'DPIARecord',
257
- changes: { projectName: existing.projectName },
258
- },
259
- });
323
+ const archived = await prisma.$transaction(async (tx) => { const existing = await tx.dPIARecord.findFirst({ where: { id, tenantId: context.tenantId, removedAt: null } }); if (!existing) return false; const result = await tx.dPIARecord.updateMany({ where: { id, tenantId: context.tenantId, removedAt: null }, data: { removedAt: archivedAt, updatedAt: archivedAt } }); if (result.count !== 1) return false; await tx.complianceAuditLog.create({ data: { tenantId: context.tenantId, module: 'dpia', action: 'archived', entityId: id, entityType: 'DPIARecord', performedBy: actorId, changes: { projectName: existing.projectName, removedAt: archivedAt.toISOString() } } }); return true; });
260
324
  // {{/if}}
261
325
  // {{#if ORM=drizzle}}
262
- const [existing] = await db.select().from(dpiaRecords).where(eq(dpiaRecords.id, id)).limit(1);
263
- if (!existing) {
264
- return res.status(404).json({ error: 'DPIA record not found' });
265
- }
266
-
267
- await db.delete(dpiaRecords).where(eq(dpiaRecords.id, id));
268
-
269
- await db.insert(complianceAuditLog).values({
270
- module: 'dpia',
271
- action: 'deleted',
272
- entityId: id,
273
- entityType: 'DPIARecord',
274
- changes: { projectName: existing.projectName },
275
- });
326
+ const archived = await db.transaction(async (tx) => { const [existing] = await tx.select().from(dpiaRecords).where(and(eq(dpiaRecords.id, id), eq(dpiaRecords.tenantId, context.tenantId), isNull(dpiaRecords.removedAt))).limit(1); if (!existing) return false; const [updated] = await tx.update(dpiaRecords).set({ removedAt: archivedAt, updatedAt: archivedAt }).where(and(eq(dpiaRecords.id, id), eq(dpiaRecords.tenantId, context.tenantId), isNull(dpiaRecords.removedAt))).returning(); if (!updated) return false; await tx.insert(complianceAuditLog).values({ tenantId: context.tenantId, module: 'dpia', action: 'archived', entityId: id, entityType: 'DPIARecord', performedBy: actorId, changes: { projectName: existing.projectName, removedAt: archivedAt.toISOString() } }); return true; });
276
327
  // {{/if}}
277
328
  // {{#if ORM=none}}
278
- const existing = dpiaStore.get(id);
279
- if (!existing) {
280
- return res.status(404).json({ error: 'DPIA record not found' });
281
- }
282
- dpiaStore.delete(id);
283
- auditLog.push({ id: newId(), module: 'dpia', action: 'deleted', entityId: id, at: new Date() });
329
+ const existing = dpiaStore.get(id); const archived = existing?.tenantId === context.tenantId && existing.removedAt === null; if (archived && existing) { dpiaStore.set(id, { ...existing, removedAt: archivedAt, updatedAt: archivedAt }); auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'archived', entityId: id, performedBy: actorId, changes: { projectName: existing.projectName, removedAt: archivedAt.toISOString() }, at: archivedAt }); }
284
330
  // {{/if}}
285
-
286
- return res.json({ success: true });
331
+ return archived ? res.json({ success: true, archived: true }) : res.status(404).json({ error: 'DPIA record not found' });
287
332
  });
@@ -0,0 +1,76 @@
1
+ /** Express — tenant-scoped data-subject request router for {{ORG_NAME_COMMENT}}. */
2
+ import { Router } from 'express';
3
+ import { getNDPRContextProblem, resolveNDPRRequestContext } from '{{NDPR_CONTEXT_IMPORT}}';
4
+ // {{#if ORM=prisma}}
5
+ import { PrismaClient } from '@prisma/client';
6
+ const prisma = new PrismaClient();
7
+ // {{/if}}
8
+ // {{#if ORM=drizzle}}
9
+ import { db } from '{{NDPR_DB_IMPORT}}';
10
+ import { complianceAuditLog, dsrRequests } from '{{NDPR_SCHEMA_IMPORT}}';
11
+ import { and, desc, eq } from 'drizzle-orm';
12
+ // {{/if}}
13
+ // {{#if ORM=none}}
14
+ // DEVELOPMENT ONLY: replace both stores with one durable transactional store.
15
+ interface DSRRow { id: string; tenantId: string; subjectId: string; type: string; status: string; subjectName: string; subjectEmail: string; subjectPhone: string | null; identifierType: string; identifierValue: string; description: string | null; submittedAt: Date; dueAt: Date }
16
+ const dsrStore = new Map<string, DSRRow>();
17
+ const auditLog: Array<{ id: string; tenantId: string; action: string; entityId: string; performedBy: string | null; at: Date }> = [];
18
+ // {{/if}}
19
+
20
+ const REQUEST_TYPES = new Set(['access', 'rectification', 'erasure', 'portability', 'objection', 'restriction']);
21
+ export const dsrRouter = Router();
22
+
23
+ dsrRouter.get('/', async (req, res) => {
24
+ const context = await resolveNDPRRequestContext(req);
25
+ const problem = getNDPRContextProblem(context, 'staff');
26
+ if (problem) return res.status(problem.status).json({ error: problem.error });
27
+ const status = typeof req.query.status === 'string' ? req.query.status : undefined;
28
+ // {{#if ORM=prisma}}
29
+ const requests = await prisma.dSRRequest.findMany({ where: { tenantId: context.tenantId, ...(status ? { status } : {}) }, orderBy: { submittedAt: 'desc' } });
30
+ // {{/if}}
31
+ // {{#if ORM=drizzle}}
32
+ const requests = status
33
+ ? await db.select().from(dsrRequests).where(and(eq(dsrRequests.tenantId, context.tenantId), eq(dsrRequests.status, status))).orderBy(desc(dsrRequests.submittedAt))
34
+ : await db.select().from(dsrRequests).where(eq(dsrRequests.tenantId, context.tenantId)).orderBy(desc(dsrRequests.submittedAt));
35
+ // {{/if}}
36
+ // {{#if ORM=none}}
37
+ const requests = [...dsrStore.values()].filter((row) => row.tenantId === context.tenantId && (!status || row.status === status)).sort((a, b) => b.submittedAt.getTime() - a.submittedAt.getTime());
38
+ // {{/if}}
39
+ return res.json(requests);
40
+ });
41
+
42
+ dsrRouter.post('/', async (req, res) => {
43
+ const context = await resolveNDPRRequestContext(req);
44
+ const problem = getNDPRContextProblem(context, 'subject');
45
+ if (problem) return res.status(problem.status).json({ error: problem.error });
46
+ if (!req.body || typeof req.body !== 'object' || Array.isArray(req.body)) return res.status(400).json({ error: 'A JSON object is required' });
47
+ const input = req.body as Record<string, unknown>;
48
+ const required = ['type', 'subjectName', 'subjectEmail', 'identifierType', 'identifierValue'] as const;
49
+ if (required.some((field) => typeof input[field] !== 'string' || !(input[field] as string).trim()) || !REQUEST_TYPES.has(input.type as string)) {
50
+ return res.status(400).json({ error: `Required strings: ${required.join(', ')}; type must be one of ${[...REQUEST_TYPES].join(', ')}` });
51
+ }
52
+ const submittedAt = new Date();
53
+ // Operational target; confirm and configure this deadline for your applicable obligations.
54
+ const dueAt = new Date(submittedAt.getTime() + 30 * 24 * 60 * 60 * 1000);
55
+ const data = { tenantId: context.tenantId, subjectId: context.subjectId as string, type: input.type as string, subjectName: input.subjectName as string, subjectEmail: input.subjectEmail as string, subjectPhone: typeof input.subjectPhone === 'string' ? input.subjectPhone : null, identifierType: input.identifierType as string, identifierValue: input.identifierValue as string, description: typeof input.description === 'string' ? input.description : null, status: 'pending', dueAt };
56
+ // {{#if ORM=prisma}}
57
+ const request = await prisma.$transaction(async (tx) => {
58
+ const created = await tx.dSRRequest.create({ data });
59
+ await tx.complianceAuditLog.create({ data: { tenantId: context.tenantId, module: 'dsr', action: 'submitted', entityId: created.id, entityType: 'DSRRequest', performedBy: context.actorId, changes: { type: data.type, subjectId: data.subjectId, status: 'pending' } } });
60
+ return created;
61
+ });
62
+ // {{/if}}
63
+ // {{#if ORM=drizzle}}
64
+ const request = await db.transaction(async (tx) => {
65
+ const [created] = await tx.insert(dsrRequests).values(data).returning();
66
+ await tx.insert(complianceAuditLog).values({ tenantId: context.tenantId, module: 'dsr', action: 'submitted', entityId: created.id, entityType: 'DSRRequest', performedBy: context.actorId, changes: { type: data.type, subjectId: data.subjectId, status: 'pending' } });
67
+ return created;
68
+ });
69
+ // {{/if}}
70
+ // {{#if ORM=none}}
71
+ const request: DSRRow = { id: crypto.randomUUID(), ...data, submittedAt };
72
+ dsrStore.set(request.id, request);
73
+ auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'submitted', entityId: request.id, performedBy: context.actorId, at: submittedAt });
74
+ // {{/if}}
75
+ return res.status(201).json(request);
76
+ });