@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/README.md +75 -56
- package/bin/index.mjs +369 -394
- package/package.json +14 -4
- package/templates/drizzle-client.ts +44 -0
- package/templates/drizzle-schema.ts +224 -117
- package/templates/env-example +6 -3
- package/templates/express-breach-route.ts +98 -0
- package/templates/express-consent-route.ts +336 -79
- package/templates/express-cross-border-route.ts +152 -0
- package/templates/express-dpia-route.ts +332 -0
- package/templates/express-dsr-route.ts +76 -0
- package/templates/express-lawful-basis-route.ts +146 -0
- package/templates/express-request-context.ts +130 -0
- package/templates/github-ndpr-audit.yml +31 -0
- package/templates/ndpr-audit.json +14 -0
- package/templates/nextjs-breach-route.ts +145 -79
- package/templates/nextjs-consent-route.ts +343 -70
- package/templates/nextjs-cross-border-route.ts +314 -0
- package/templates/nextjs-dpia-route.ts +510 -0
- package/templates/nextjs-dsr-route.ts +93 -58
- package/templates/nextjs-lawful-basis-route.ts +283 -0
- package/templates/nextjs-layout.tsx +122 -50
- package/templates/nextjs-request-context.ts +137 -0
- package/templates/prisma-schema.prisma +121 -51
- package/templates/express-setup.ts +0 -250
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
/** Express — staff-only, tenant-scoped DPIA register for {{ORG_NAME_COMMENT}}. */
|
|
2
|
+
import { Router } from 'express';
|
|
3
|
+
import { getNDPRContextProblem, resolveNDPRRequestContext } from '{{NDPR_CONTEXT_IMPORT}}';
|
|
4
|
+
// {{#if ORM=prisma}}
|
|
5
|
+
import { Prisma, 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, dpiaRecords } from '{{NDPR_SCHEMA_IMPORT}}';
|
|
11
|
+
import { and, desc, eq, isNull } from 'drizzle-orm';
|
|
12
|
+
// {{/if}}
|
|
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 {
|
|
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 {
|
|
49
|
+
projectName: string;
|
|
50
|
+
description: string;
|
|
51
|
+
dpiaData: unknown;
|
|
52
|
+
status: string;
|
|
53
|
+
conductedBy: string;
|
|
54
|
+
approvedBy: string | null;
|
|
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;
|
|
65
|
+
updatedAt: Date;
|
|
66
|
+
}
|
|
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 }> = [];
|
|
73
|
+
// {{/if}}
|
|
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
|
+
|
|
248
|
+
export const dpiaRouter = Router();
|
|
249
|
+
|
|
250
|
+
dpiaRouter.get('/', async (req, res) => {
|
|
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' });
|
|
254
|
+
// {{#if ORM=prisma}}
|
|
255
|
+
const records = await prisma.dPIARecord.findMany({ where: { tenantId: context.tenantId, removedAt: null, ...(statusValue ? { status: statusValue } : {}) }, orderBy: { createdAt: 'desc' } });
|
|
256
|
+
// {{/if}}
|
|
257
|
+
// {{#if ORM=drizzle}}
|
|
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));
|
|
259
|
+
// {{/if}}
|
|
260
|
+
// {{#if ORM=none}}
|
|
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());
|
|
262
|
+
// {{/if}}
|
|
263
|
+
return res.json(records);
|
|
264
|
+
});
|
|
265
|
+
|
|
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 });
|
|
268
|
+
// {{#if ORM=prisma}}
|
|
269
|
+
const record = await prisma.dPIARecord.findFirst({ where: { id: req.params.id, tenantId: context.tenantId, removedAt: null } });
|
|
270
|
+
// {{/if}}
|
|
271
|
+
// {{#if ORM=drizzle}}
|
|
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);
|
|
273
|
+
// {{/if}}
|
|
274
|
+
// {{#if ORM=none}}
|
|
275
|
+
const candidate = dpiaStore.get(req.params.id); const record = candidate?.tenantId === context.tenantId && candidate.removedAt === null ? candidate : null;
|
|
276
|
+
// {{/if}}
|
|
277
|
+
return record ? res.json(record) : res.status(404).json({ error: 'DPIA record not found' });
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
dpiaRouter.post('/', async (req, res) => {
|
|
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;
|
|
287
|
+
// {{#if ORM=prisma}}
|
|
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; });
|
|
289
|
+
// {{/if}}
|
|
290
|
+
// {{#if ORM=drizzle}}
|
|
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; });
|
|
292
|
+
// {{/if}}
|
|
293
|
+
// {{#if ORM=none}}
|
|
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 });
|
|
295
|
+
// {{/if}}
|
|
296
|
+
return res.status(201).json(record);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
dpiaRouter.put('/:id', async (req, res) => {
|
|
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;
|
|
306
|
+
// {{#if ORM=prisma}}
|
|
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; });
|
|
308
|
+
// {{/if}}
|
|
309
|
+
// {{#if ORM=drizzle}}
|
|
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; });
|
|
311
|
+
// {{/if}}
|
|
312
|
+
// {{#if ORM=none}}
|
|
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 }); } }
|
|
314
|
+
// {{/if}}
|
|
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' });
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
dpiaRouter.delete('/:id', async (req, res) => {
|
|
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();
|
|
322
|
+
// {{#if ORM=prisma}}
|
|
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; });
|
|
324
|
+
// {{/if}}
|
|
325
|
+
// {{#if ORM=drizzle}}
|
|
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; });
|
|
327
|
+
// {{/if}}
|
|
328
|
+
// {{#if ORM=none}}
|
|
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 }); }
|
|
330
|
+
// {{/if}}
|
|
331
|
+
return archived ? res.json({ success: true, archived: true }) : res.status(404).json({ error: 'DPIA record not found' });
|
|
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
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/** Express — staff-only, tenant-scoped lawful-basis register 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, lawfulBasisRecords } from '{{NDPR_SCHEMA_IMPORT}}';
|
|
11
|
+
import { and, desc, eq, isNull } from 'drizzle-orm';
|
|
12
|
+
// {{/if}}
|
|
13
|
+
|
|
14
|
+
const LAWFUL_BASES = ['consent', 'contract', 'legal_obligation', 'vital_interests', 'public_interest', 'legitimate_interests'] as const;
|
|
15
|
+
type LawfulBasis = (typeof LAWFUL_BASES)[number];
|
|
16
|
+
type Validation<T> = { ok: true; value: T } | { ok: false; error: string };
|
|
17
|
+
interface BasisExisting { activityName: string; lawfulBasis: string; justification: string; dataCategories: unknown; purposes: unknown; reviewDate: Date | null }
|
|
18
|
+
interface BasisMutation { activityName: string; lawfulBasis: LawfulBasis; justification: string; dataCategories: string[]; purposes: string[]; assessedBy: string; assessedAt: Date; reviewDate: Date | null; updatedAt: Date }
|
|
19
|
+
|
|
20
|
+
// {{#if ORM=none}}
|
|
21
|
+
// DEVELOPMENT ONLY: replace both stores with one durable transactional store.
|
|
22
|
+
interface BasisRow extends BasisMutation { id: string; tenantId: string; createdAt: Date; removedAt: Date | null }
|
|
23
|
+
const basisStore = new Map<string, BasisRow>();
|
|
24
|
+
const auditLog: Array<{ id: string; tenantId: string; action: string; entityId: string; performedBy: string; changes?: unknown; at: Date }> = [];
|
|
25
|
+
// {{/if}}
|
|
26
|
+
|
|
27
|
+
function isRecord(value: unknown): value is Record<string, unknown> { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
|
28
|
+
function hasOwn(value: Record<string, unknown>, key: string): boolean { return Object.prototype.hasOwnProperty.call(value, key); }
|
|
29
|
+
function rejectUnknownFields(value: Record<string, unknown>, allowed: readonly string[]): string | null {
|
|
30
|
+
const allowedSet = new Set(allowed); const unknown = Object.keys(value).filter((key) => !allowedSet.has(key)); return unknown.length > 0 ? `Unsupported field(s): ${unknown.join(', ')}` : null;
|
|
31
|
+
}
|
|
32
|
+
function nonEmptyText(value: unknown, field: string, maxLength = 10_000): Validation<string> {
|
|
33
|
+
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` };
|
|
34
|
+
return { ok: true, value: value.trim() };
|
|
35
|
+
}
|
|
36
|
+
function nonEmptyStringArray(value: unknown, field: string): Validation<string[]> {
|
|
37
|
+
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` };
|
|
38
|
+
const result: string[] = [];
|
|
39
|
+
for (const item of value) { if (typeof item !== 'string' || !item.trim()) return { ok: false, error: `${field} must contain only non-empty strings` }; if (!result.includes(item.trim())) result.push(item.trim()); }
|
|
40
|
+
return { ok: true, value: result };
|
|
41
|
+
}
|
|
42
|
+
function isLawfulBasis(value: unknown): value is LawfulBasis { return typeof value === 'string' && (LAWFUL_BASES as readonly string[]).includes(value); }
|
|
43
|
+
function parseReviewDate(value: unknown): Date | null | undefined {
|
|
44
|
+
if (value === null || value === '') return null;
|
|
45
|
+
if (value instanceof Date) return Number.isFinite(value.getTime()) ? new Date(value.getTime()) : undefined;
|
|
46
|
+
if (typeof value !== 'string') return undefined;
|
|
47
|
+
const input = value.trim(); const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(input); 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);
|
|
48
|
+
if (!dateOnly && !dateTime) return undefined;
|
|
49
|
+
const timestamp = Date.parse(input); if (!Number.isFinite(timestamp)) return undefined;
|
|
50
|
+
const parts = dateOnly ?? dateTime; const check = new Date(Date.UTC(Number(parts![1]), Number(parts![2]) - 1, Number(parts![3])));
|
|
51
|
+
if (check.getUTCFullYear() !== Number(parts![1]) || check.getUTCMonth() + 1 !== Number(parts![2]) || check.getUTCDate() !== Number(parts![3])) return undefined;
|
|
52
|
+
return new Date(timestamp);
|
|
53
|
+
}
|
|
54
|
+
function buildBasisMutation(input: Record<string, unknown>, actorId: string, existing?: BasisExisting, now = new Date()): Validation<BasisMutation> {
|
|
55
|
+
const activityName = hasOwn(input, 'activityName') ? nonEmptyText(input.activityName, 'activityName', 500) : existing ? nonEmptyText(existing.activityName, 'stored activityName', 500) : { ok: false as const, error: 'activityName is required' }; if (!activityName.ok) return activityName;
|
|
56
|
+
const basisValue = hasOwn(input, 'lawfulBasis') ? input.lawfulBasis : existing?.lawfulBasis; if (!isLawfulBasis(basisValue)) return { ok: false, error: 'lawfulBasis is not one of the six supported NDPA bases' };
|
|
57
|
+
const justification = hasOwn(input, 'justification') ? nonEmptyText(input.justification, 'justification') : existing ? nonEmptyText(existing.justification, 'stored justification') : { ok: false as const, error: 'justification is required' }; if (!justification.ok) return justification;
|
|
58
|
+
if (basisValue === 'legitimate_interests' && justification.value.length < 20) return { ok: false, error: 'legitimate_interests requires a detailed justification covering the interest, necessity, and balancing assessment' };
|
|
59
|
+
const categories = nonEmptyStringArray(hasOwn(input, 'dataCategories') ? input.dataCategories : existing?.dataCategories, 'dataCategories'); if (!categories.ok) return categories;
|
|
60
|
+
const purposes = nonEmptyStringArray(hasOwn(input, 'purposes') ? input.purposes : existing?.purposes, 'purposes'); if (!purposes.ok) return purposes;
|
|
61
|
+
let reviewDate: Date | null;
|
|
62
|
+
if (hasOwn(input, 'reviewDate')) { const parsed = parseReviewDate(input.reviewDate); if (parsed === undefined) return { ok: false, error: 'reviewDate must be null, YYYY-MM-DD, or an ISO timestamp with a timezone' }; if (parsed !== null && parsed.getTime() <= now.getTime()) return { ok: false, error: 'reviewDate must be in the future' }; reviewDate = parsed; }
|
|
63
|
+
else { const parsed = parseReviewDate(existing?.reviewDate ?? null); if (parsed === undefined) return { ok: false, error: 'Stored reviewDate is invalid' }; reviewDate = parsed; }
|
|
64
|
+
return { ok: true, value: { activityName: activityName.value, lawfulBasis: basisValue, justification: justification.value, dataCategories: categories.value, purposes: purposes.value, assessedBy: actorId, assessedAt: now, reviewDate, updatedAt: now } };
|
|
65
|
+
}
|
|
66
|
+
function auditChanges(data: BasisMutation) { return { activityName: data.activityName, lawfulBasis: data.lawfulBasis, assessedBy: data.assessedBy, reviewDate: data.reviewDate?.toISOString() ?? null }; }
|
|
67
|
+
|
|
68
|
+
export const lawfulBasisRouter = Router();
|
|
69
|
+
|
|
70
|
+
lawfulBasisRouter.get('/', async (req, res) => {
|
|
71
|
+
const context = await resolveNDPRRequestContext(req); const problem = getNDPRContextProblem(context, 'staff'); if (problem) return res.status(problem.status).json({ error: problem.error });
|
|
72
|
+
const basis = typeof req.query.lawfulBasis === 'string' ? req.query.lawfulBasis : undefined; if (basis !== undefined && !isLawfulBasis(basis)) return res.status(400).json({ error: 'lawfulBasis filter is not supported' });
|
|
73
|
+
// {{#if ORM=prisma}}
|
|
74
|
+
const records = await prisma.lawfulBasisRecord.findMany({ where: { tenantId: context.tenantId, removedAt: null, ...(basis ? { lawfulBasis: basis } : {}) }, orderBy: { createdAt: 'desc' } });
|
|
75
|
+
// {{/if}}
|
|
76
|
+
// {{#if ORM=drizzle}}
|
|
77
|
+
const records = basis ? await db.select().from(lawfulBasisRecords).where(and(eq(lawfulBasisRecords.tenantId, context.tenantId), isNull(lawfulBasisRecords.removedAt), eq(lawfulBasisRecords.lawfulBasis, basis))).orderBy(desc(lawfulBasisRecords.createdAt)) : await db.select().from(lawfulBasisRecords).where(and(eq(lawfulBasisRecords.tenantId, context.tenantId), isNull(lawfulBasisRecords.removedAt))).orderBy(desc(lawfulBasisRecords.createdAt));
|
|
78
|
+
// {{/if}}
|
|
79
|
+
// {{#if ORM=none}}
|
|
80
|
+
const records = [...basisStore.values()].filter((row) => row.tenantId === context.tenantId && row.removedAt === null && (!basis || row.lawfulBasis === basis)).sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
81
|
+
// {{/if}}
|
|
82
|
+
return res.json(records);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
lawfulBasisRouter.get('/:id', async (req, res) => {
|
|
86
|
+
const context = await resolveNDPRRequestContext(req); const problem = getNDPRContextProblem(context, 'staff'); if (problem) return res.status(problem.status).json({ error: problem.error });
|
|
87
|
+
// {{#if ORM=prisma}}
|
|
88
|
+
const record = await prisma.lawfulBasisRecord.findFirst({ where: { id: req.params.id, tenantId: context.tenantId, removedAt: null } });
|
|
89
|
+
// {{/if}}
|
|
90
|
+
// {{#if ORM=drizzle}}
|
|
91
|
+
const [record] = await db.select().from(lawfulBasisRecords).where(and(eq(lawfulBasisRecords.id, req.params.id), eq(lawfulBasisRecords.tenantId, context.tenantId), isNull(lawfulBasisRecords.removedAt))).limit(1);
|
|
92
|
+
// {{/if}}
|
|
93
|
+
// {{#if ORM=none}}
|
|
94
|
+
const candidate = basisStore.get(req.params.id); const record = candidate?.tenantId === context.tenantId && candidate.removedAt === null ? candidate : null;
|
|
95
|
+
// {{/if}}
|
|
96
|
+
return record ? res.json(record) : res.status(404).json({ error: 'Lawful-basis record not found' });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
lawfulBasisRouter.post('/', async (req, res) => {
|
|
100
|
+
const context = await resolveNDPRRequestContext(req); const problem = getNDPRContextProblem(context, 'staff'); if (problem) return res.status(problem.status).json({ error: problem.error }); const actorId = context.actorId as string;
|
|
101
|
+
if (!isRecord(req.body)) return res.status(400).json({ error: 'A JSON object is required' });
|
|
102
|
+
const unknown = rejectUnknownFields(req.body, ['activityName', 'lawfulBasis', 'justification', 'dataCategories', 'purposes', 'reviewDate']); if (unknown) return res.status(400).json({ error: unknown });
|
|
103
|
+
const normalized = buildBasisMutation(req.body, actorId); if (!normalized.ok) return res.status(400).json({ error: normalized.error }); const data = normalized.value;
|
|
104
|
+
// {{#if ORM=prisma}}
|
|
105
|
+
const record = await prisma.$transaction(async (tx) => { const created = await tx.lawfulBasisRecord.create({ data: { ...data, tenantId: context.tenantId, removedAt: null } }); await tx.complianceAuditLog.create({ data: { tenantId: context.tenantId, module: 'lawful-basis', action: 'created', entityId: created.id, entityType: 'LawfulBasisRecord', performedBy: actorId, changes: auditChanges(data) } }); return created; });
|
|
106
|
+
// {{/if}}
|
|
107
|
+
// {{#if ORM=drizzle}}
|
|
108
|
+
const record = await db.transaction(async (tx) => { const [created] = await tx.insert(lawfulBasisRecords).values({ ...data, tenantId: context.tenantId, removedAt: null }).returning(); await tx.insert(complianceAuditLog).values({ tenantId: context.tenantId, module: 'lawful-basis', action: 'created', entityId: created.id, entityType: 'LawfulBasisRecord', performedBy: actorId, changes: auditChanges(data) }); return created; });
|
|
109
|
+
// {{/if}}
|
|
110
|
+
// {{#if ORM=none}}
|
|
111
|
+
const record: BasisRow = { id: crypto.randomUUID(), ...data, tenantId: context.tenantId, createdAt: data.updatedAt, removedAt: null }; basisStore.set(record.id, record); auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'created', entityId: record.id, performedBy: actorId, changes: auditChanges(data), at: data.updatedAt });
|
|
112
|
+
// {{/if}}
|
|
113
|
+
return res.status(201).json(record);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
lawfulBasisRouter.put('/:id', async (req, res) => {
|
|
117
|
+
const context = await resolveNDPRRequestContext(req); const problem = getNDPRContextProblem(context, 'staff'); if (problem) return res.status(problem.status).json({ error: problem.error }); const actorId = context.actorId as string;
|
|
118
|
+
if (!isRecord(req.body)) return res.status(400).json({ error: 'A JSON object is required' });
|
|
119
|
+
const unknown = rejectUnknownFields(req.body, ['activityName', 'lawfulBasis', 'justification', 'dataCategories', 'purposes', 'reviewDate']); if (unknown) return res.status(400).json({ error: unknown });
|
|
120
|
+
if (!['activityName', 'lawfulBasis', 'justification', 'dataCategories', 'purposes', 'reviewDate'].some((field) => hasOwn(req.body, field))) return res.status(400).json({ error: 'Provide at least one allowlisted lawful-basis field to update' });
|
|
121
|
+
const id = req.params.id; let validationError: string | null = null;
|
|
122
|
+
// {{#if ORM=prisma}}
|
|
123
|
+
const record = await prisma.$transaction(async (tx) => { const existing = await tx.lawfulBasisRecord.findFirst({ where: { id, tenantId: context.tenantId, removedAt: null } }); if (!existing) return null; const normalized = buildBasisMutation(req.body, actorId, existing); if (!normalized.ok) { validationError = normalized.error; return null; } const data = normalized.value; await tx.lawfulBasisRecord.updateMany({ where: { id, tenantId: context.tenantId, removedAt: null }, data }); const updated = await tx.lawfulBasisRecord.findFirstOrThrow({ where: { id, tenantId: context.tenantId, removedAt: null } }); await tx.complianceAuditLog.create({ data: { tenantId: context.tenantId, module: 'lawful-basis', action: 'updated', entityId: id, entityType: 'LawfulBasisRecord', performedBy: actorId, changes: auditChanges(data) } }); return updated; });
|
|
124
|
+
// {{/if}}
|
|
125
|
+
// {{#if ORM=drizzle}}
|
|
126
|
+
const record = await db.transaction(async (tx) => { const [existing] = await tx.select().from(lawfulBasisRecords).where(and(eq(lawfulBasisRecords.id, id), eq(lawfulBasisRecords.tenantId, context.tenantId), isNull(lawfulBasisRecords.removedAt))).limit(1); if (!existing) return null; const normalized = buildBasisMutation(req.body, actorId, existing); if (!normalized.ok) { validationError = normalized.error; return null; } const data = normalized.value; const [updated] = await tx.update(lawfulBasisRecords).set(data).where(and(eq(lawfulBasisRecords.id, id), eq(lawfulBasisRecords.tenantId, context.tenantId), isNull(lawfulBasisRecords.removedAt))).returning(); await tx.insert(complianceAuditLog).values({ tenantId: context.tenantId, module: 'lawful-basis', action: 'updated', entityId: id, entityType: 'LawfulBasisRecord', performedBy: actorId, changes: auditChanges(data) }); return updated; });
|
|
127
|
+
// {{/if}}
|
|
128
|
+
// {{#if ORM=none}}
|
|
129
|
+
const existing = basisStore.get(id); let record: BasisRow | null = null; if (existing?.tenantId === context.tenantId && existing.removedAt === null) { const normalized = buildBasisMutation(req.body, actorId, existing); if (!normalized.ok) validationError = normalized.error; else { record = { ...existing, ...normalized.value, id }; basisStore.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 }); } }
|
|
130
|
+
// {{/if}}
|
|
131
|
+
if (validationError) return res.status(400).json({ error: validationError }); return record ? res.json(record) : res.status(404).json({ error: 'Lawful-basis record not found' });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
lawfulBasisRouter.delete('/:id', async (req, res) => {
|
|
135
|
+
const context = await resolveNDPRRequestContext(req); const problem = getNDPRContextProblem(context, 'staff'); if (problem) return res.status(problem.status).json({ error: problem.error }); const actorId = context.actorId as string; const id = req.params.id; const archivedAt = new Date();
|
|
136
|
+
// {{#if ORM=prisma}}
|
|
137
|
+
const archived = await prisma.$transaction(async (tx) => { const existing = await tx.lawfulBasisRecord.findFirst({ where: { id, tenantId: context.tenantId, removedAt: null } }); if (!existing) return false; const result = await tx.lawfulBasisRecord.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: 'lawful-basis', action: 'archived', entityId: id, entityType: 'LawfulBasisRecord', performedBy: actorId, changes: { activityName: existing.activityName, removedAt: archivedAt.toISOString() } } }); return true; });
|
|
138
|
+
// {{/if}}
|
|
139
|
+
// {{#if ORM=drizzle}}
|
|
140
|
+
const archived = await db.transaction(async (tx) => { const [existing] = await tx.select().from(lawfulBasisRecords).where(and(eq(lawfulBasisRecords.id, id), eq(lawfulBasisRecords.tenantId, context.tenantId), isNull(lawfulBasisRecords.removedAt))).limit(1); if (!existing) return false; const [updated] = await tx.update(lawfulBasisRecords).set({ removedAt: archivedAt, updatedAt: archivedAt }).where(and(eq(lawfulBasisRecords.id, id), eq(lawfulBasisRecords.tenantId, context.tenantId), isNull(lawfulBasisRecords.removedAt))).returning(); if (!updated) return false; await tx.insert(complianceAuditLog).values({ tenantId: context.tenantId, module: 'lawful-basis', action: 'archived', entityId: id, entityType: 'LawfulBasisRecord', performedBy: actorId, changes: { activityName: existing.activityName, removedAt: archivedAt.toISOString() } }); return true; });
|
|
141
|
+
// {{/if}}
|
|
142
|
+
// {{#if ORM=none}}
|
|
143
|
+
const existing = basisStore.get(id); const archived = existing?.tenantId === context.tenantId && existing.removedAt === null; if (archived && existing) { basisStore.set(id, { ...existing, removedAt: archivedAt, updatedAt: archivedAt }); auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'archived', entityId: id, performedBy: actorId, changes: { activityName: existing.activityName, removedAt: archivedAt.toISOString() }, at: archivedAt }); }
|
|
144
|
+
// {{/if}}
|
|
145
|
+
return archived ? res.json({ success: true, archived: true }) : res.status(404).json({ error: 'Lawful-basis record not found' });
|
|
146
|
+
});
|