@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,510 @@
|
|
|
1
|
+
/** Next.js App Router — staff-only, tenant-scoped DPIA register for {{ORG_NAME_COMMENT}}. */
|
|
2
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
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) {
|
|
88
|
+
return { ok: false, error: `${field} must be non-empty text of at most ${maxLength} characters` };
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, value: value.trim() };
|
|
91
|
+
}
|
|
92
|
+
function nonEmptyStringArray(value: unknown, field: string): Validation<string[]> {
|
|
93
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 100) {
|
|
94
|
+
return { ok: false, error: `${field} must be a non-empty array with at most 100 items` };
|
|
95
|
+
}
|
|
96
|
+
const result: string[] = [];
|
|
97
|
+
for (const item of value) {
|
|
98
|
+
if (typeof item !== 'string' || !item.trim()) {
|
|
99
|
+
return { ok: false, error: `${field} must contain only non-empty strings` };
|
|
100
|
+
}
|
|
101
|
+
if (!result.includes(item.trim())) result.push(item.trim());
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, value: result };
|
|
104
|
+
}
|
|
105
|
+
function parseTimestamp(value: unknown): number | undefined {
|
|
106
|
+
if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) return value;
|
|
107
|
+
if (typeof value !== 'string') return undefined;
|
|
108
|
+
const input = value.trim();
|
|
109
|
+
const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(input);
|
|
110
|
+
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);
|
|
111
|
+
if (!dateOnly && !dateTime) return undefined;
|
|
112
|
+
const timestamp = Date.parse(input);
|
|
113
|
+
if (!Number.isFinite(timestamp)) return undefined;
|
|
114
|
+
const parts = dateOnly ?? dateTime;
|
|
115
|
+
const check = new Date(Date.UTC(Number(parts![1]), Number(parts![2]) - 1, Number(parts![3])));
|
|
116
|
+
if (check.getUTCFullYear() !== Number(parts![1]) || check.getUTCMonth() + 1 !== Number(parts![2]) || check.getUTCDate() !== Number(parts![3])) return undefined;
|
|
117
|
+
return timestamp;
|
|
118
|
+
}
|
|
119
|
+
function isDPIAStatus(value: unknown): value is DPIAStatus {
|
|
120
|
+
return typeof value === 'string' && (DPIA_STATUSES as readonly string[]).includes(value);
|
|
121
|
+
}
|
|
122
|
+
function riskLevelForScore(score: number): RiskLevel {
|
|
123
|
+
if (score >= 17) return 'critical';
|
|
124
|
+
if (score >= 10) return 'high';
|
|
125
|
+
if (score >= 5) return 'medium';
|
|
126
|
+
return 'low';
|
|
127
|
+
}
|
|
128
|
+
function highestRiskLevel(risks: DPIARiskEvidence[]): RiskLevel {
|
|
129
|
+
const rank: Record<RiskLevel, number> = { low: 0, medium: 1, high: 2, critical: 3 };
|
|
130
|
+
return risks.reduce<RiskLevel>((highest, risk) => rank[risk.level] > rank[highest] ? risk.level : highest, 'low');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function isSafeQuestionId(questionId: string): boolean {
|
|
134
|
+
return questionId.length > 0
|
|
135
|
+
&& questionId.length <= 200
|
|
136
|
+
&& questionId !== '__proto__'
|
|
137
|
+
&& questionId !== 'prototype'
|
|
138
|
+
&& !Object.prototype.hasOwnProperty.call(Object.prototype, questionId);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function normalizeAnswers(value: unknown): Validation<Record<string, AnswerValue>> {
|
|
142
|
+
if (!isRecord(value) || Object.keys(value).length === 0 || Object.keys(value).length > 500) {
|
|
143
|
+
return { ok: false, error: 'dpiaData.answers must be a non-empty object with at most 500 answers' };
|
|
144
|
+
}
|
|
145
|
+
const answers = new Map<string, AnswerValue>();
|
|
146
|
+
for (const [rawQuestionId, answer] of Object.entries(value)) {
|
|
147
|
+
const questionId = rawQuestionId.trim();
|
|
148
|
+
if (!questionId || !isSafeQuestionId(questionId)) {
|
|
149
|
+
return { ok: false, error: 'dpiaData.answers question ids must be non-reserved text with at most 200 characters' };
|
|
150
|
+
}
|
|
151
|
+
if (typeof answer === 'string') {
|
|
152
|
+
if (!answer.trim()) return { ok: false, error: `dpiaData.answers.${questionId} cannot be empty` };
|
|
153
|
+
answers.set(questionId, answer.trim());
|
|
154
|
+
} else if (typeof answer === 'number') {
|
|
155
|
+
if (!Number.isFinite(answer)) return { ok: false, error: `dpiaData.answers.${questionId} must be finite` };
|
|
156
|
+
answers.set(questionId, answer);
|
|
157
|
+
} else if (typeof answer === 'boolean') {
|
|
158
|
+
answers.set(questionId, answer);
|
|
159
|
+
} else if (Array.isArray(answer)) {
|
|
160
|
+
const normalized = nonEmptyStringArray(answer, `dpiaData.answers.${questionId}`);
|
|
161
|
+
if (!normalized.ok) return normalized;
|
|
162
|
+
answers.set(questionId, normalized.value);
|
|
163
|
+
} else {
|
|
164
|
+
return { ok: false, error: `dpiaData.answers.${questionId} must be text, a finite number, boolean, or a non-empty string array` };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { ok: true, value: Object.fromEntries(answers) };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function normalizeRisks(value: unknown, answers: Record<string, AnswerValue>, stored: boolean): Validation<DPIARiskEvidence[]> {
|
|
171
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 100) {
|
|
172
|
+
return { ok: false, error: 'dpiaData.risks must be a non-empty array with at most 100 risks' };
|
|
173
|
+
}
|
|
174
|
+
const risks: DPIARiskEvidence[] = [];
|
|
175
|
+
const ids = new Set<string>();
|
|
176
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
177
|
+
const item = value[index];
|
|
178
|
+
if (!isRecord(item)) return { ok: false, error: `dpiaData.risks[${index}] must be an object` };
|
|
179
|
+
const unknown = rejectUnknownFields(item, stored
|
|
180
|
+
? ['id', 'description', 'likelihood', 'impact', 'mitigated', 'mitigationMeasures', 'residualScore', 'relatedQuestionIds', 'score', 'level']
|
|
181
|
+
: ['id', 'description', 'likelihood', 'impact', 'mitigated', 'mitigationMeasures', 'residualScore', 'relatedQuestionIds']);
|
|
182
|
+
if (unknown) return { ok: false, error: `dpiaData.risks[${index}]: ${unknown}` };
|
|
183
|
+
const description = nonEmptyText(item.description, `dpiaData.risks[${index}].description`, 2_000);
|
|
184
|
+
if (!description.ok) return description;
|
|
185
|
+
if (!Number.isSafeInteger(item.likelihood) || (item.likelihood as number) < 1 || (item.likelihood as number) > 5) {
|
|
186
|
+
return { ok: false, error: `dpiaData.risks[${index}].likelihood must be an integer from 1 to 5` };
|
|
187
|
+
}
|
|
188
|
+
if (!Number.isSafeInteger(item.impact) || (item.impact as number) < 1 || (item.impact as number) > 5) {
|
|
189
|
+
return { ok: false, error: `dpiaData.risks[${index}].impact must be an integer from 1 to 5` };
|
|
190
|
+
}
|
|
191
|
+
if (typeof item.mitigated !== 'boolean') return { ok: false, error: `dpiaData.risks[${index}].mitigated must be boolean` };
|
|
192
|
+
const related = nonEmptyStringArray(item.relatedQuestionIds, `dpiaData.risks[${index}].relatedQuestionIds`);
|
|
193
|
+
if (!related.ok) return related;
|
|
194
|
+
if (related.value.some((questionId) => !hasOwn(answers, questionId))) {
|
|
195
|
+
return { ok: false, error: `dpiaData.risks[${index}].relatedQuestionIds must reference supplied answers` };
|
|
196
|
+
}
|
|
197
|
+
const mitigation = item.mitigationMeasures === undefined
|
|
198
|
+
? undefined
|
|
199
|
+
: nonEmptyStringArray(item.mitigationMeasures, `dpiaData.risks[${index}].mitigationMeasures`);
|
|
200
|
+
if (mitigation && !mitigation.ok) return mitigation;
|
|
201
|
+
if (item.mitigated && !mitigation) return { ok: false, error: `dpiaData.risks[${index}] requires mitigationMeasures when mitigated is true` };
|
|
202
|
+
if (!item.mitigated && item.residualScore !== undefined) return { ok: false, error: `dpiaData.risks[${index}].residualScore is only valid for a mitigated risk` };
|
|
203
|
+
const score = (item.likelihood as number) * (item.impact as number);
|
|
204
|
+
let residualScore: number | undefined;
|
|
205
|
+
if (item.residualScore !== undefined) {
|
|
206
|
+
if (!Number.isSafeInteger(item.residualScore) || (item.residualScore as number) < 0 || (item.residualScore as number) > score) {
|
|
207
|
+
return { ok: false, error: `dpiaData.risks[${index}].residualScore must be an integer from 0 to the computed score (${score})` };
|
|
208
|
+
}
|
|
209
|
+
residualScore = item.residualScore as number;
|
|
210
|
+
}
|
|
211
|
+
if (item.mitigated && residualScore === undefined) return { ok: false, error: `dpiaData.risks[${index}] requires residualScore when mitigated is true` };
|
|
212
|
+
const level = riskLevelForScore(residualScore ?? score);
|
|
213
|
+
if (stored && (item.score !== score || item.level !== level)) {
|
|
214
|
+
return { ok: false, error: `dpiaData.risks[${index}] contains inconsistent server-derived score or level` };
|
|
215
|
+
}
|
|
216
|
+
const idValue = item.id === undefined && !stored
|
|
217
|
+
? crypto.randomUUID()
|
|
218
|
+
: nonEmptyText(item.id, `dpiaData.risks[${index}].id`, 200);
|
|
219
|
+
if (typeof idValue !== 'string' && !idValue.ok) return idValue;
|
|
220
|
+
const id = typeof idValue === 'string' ? idValue : idValue.value;
|
|
221
|
+
if (ids.has(id)) return { ok: false, error: `dpiaData.risks contains duplicate id ${id}` };
|
|
222
|
+
ids.add(id);
|
|
223
|
+
risks.push({
|
|
224
|
+
id,
|
|
225
|
+
description: description.value,
|
|
226
|
+
likelihood: item.likelihood as number,
|
|
227
|
+
impact: item.impact as number,
|
|
228
|
+
score,
|
|
229
|
+
level,
|
|
230
|
+
mitigated: item.mitigated,
|
|
231
|
+
...(mitigation?.ok ? { mitigationMeasures: mitigation.value } : {}),
|
|
232
|
+
...(residualScore !== undefined ? { residualScore } : {}),
|
|
233
|
+
relatedQuestionIds: related.value,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return { ok: true, value: risks };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function normalizeDPIAEvidence(value: unknown, now: number, stored = false): Validation<DPIAEvidence> {
|
|
240
|
+
if (!isRecord(value)) return { ok: false, error: 'dpiaData must be an object' };
|
|
241
|
+
const unknown = rejectUnknownFields(value, stored
|
|
242
|
+
? ['answers', 'risks', 'conclusion', 'version', 'recommendations', 'reviewDate', 'lawfulBasis', 'involvesCrossBorderTransfer', 'overallRiskLevel', 'canProceed', 'ndpcConsultationRequired', 'completedAt']
|
|
243
|
+
: ['answers', 'risks', 'conclusion', 'version', 'recommendations', 'reviewDate', 'lawfulBasis', 'involvesCrossBorderTransfer']);
|
|
244
|
+
if (unknown) return { ok: false, error: `dpiaData: ${unknown}` };
|
|
245
|
+
const answers = normalizeAnswers(value.answers);
|
|
246
|
+
if (!answers.ok) return answers;
|
|
247
|
+
const risks = normalizeRisks(value.risks, answers.value, stored);
|
|
248
|
+
if (!risks.ok) return risks;
|
|
249
|
+
const conclusion = nonEmptyText(value.conclusion, 'dpiaData.conclusion', 10_000);
|
|
250
|
+
if (!conclusion.ok) return conclusion;
|
|
251
|
+
const version = nonEmptyText(value.version, 'dpiaData.version', 100);
|
|
252
|
+
if (!version.ok) return version;
|
|
253
|
+
const recommendations = value.recommendations == null ? undefined : nonEmptyStringArray(value.recommendations, 'dpiaData.recommendations');
|
|
254
|
+
if (recommendations && !recommendations.ok) return recommendations;
|
|
255
|
+
const reviewDate = value.reviewDate == null ? undefined : parseTimestamp(value.reviewDate);
|
|
256
|
+
if (value.reviewDate != null && reviewDate === undefined) return { ok: false, error: 'dpiaData.reviewDate must be epoch milliseconds or an unambiguous ISO date' };
|
|
257
|
+
if (!stored && reviewDate !== undefined && reviewDate <= now) return { ok: false, error: 'dpiaData.reviewDate must be in the future' };
|
|
258
|
+
if (value.lawfulBasis != null && (typeof value.lawfulBasis !== 'string' || !(LAWFUL_BASES as readonly string[]).includes(value.lawfulBasis))) {
|
|
259
|
+
return { ok: false, error: 'dpiaData.lawfulBasis is not a supported NDPA lawful basis' };
|
|
260
|
+
}
|
|
261
|
+
if (value.involvesCrossBorderTransfer !== undefined && typeof value.involvesCrossBorderTransfer !== 'boolean') {
|
|
262
|
+
return { ok: false, error: 'dpiaData.involvesCrossBorderTransfer must be boolean' };
|
|
263
|
+
}
|
|
264
|
+
const overallRiskLevel = highestRiskLevel(risks.value);
|
|
265
|
+
const canProceed = !risks.value.some((risk) => risk.level === 'high' || risk.level === 'critical');
|
|
266
|
+
const ndpcConsultationRequired = overallRiskLevel === 'high' || overallRiskLevel === 'critical';
|
|
267
|
+
const completedAt = stored && value.completedAt != null ? parseTimestamp(value.completedAt) : undefined;
|
|
268
|
+
if (stored && value.completedAt != null && completedAt === undefined) return { ok: false, error: 'dpiaData.completedAt is invalid' };
|
|
269
|
+
if (stored && completedAt !== undefined && completedAt > now) return { ok: false, error: 'dpiaData.completedAt cannot be in the future' };
|
|
270
|
+
if (stored && (value.overallRiskLevel !== overallRiskLevel || value.canProceed !== canProceed || value.ndpcConsultationRequired !== ndpcConsultationRequired)) {
|
|
271
|
+
return { ok: false, error: 'dpiaData contains inconsistent server-derived risk evidence' };
|
|
272
|
+
}
|
|
273
|
+
return {
|
|
274
|
+
ok: true,
|
|
275
|
+
value: {
|
|
276
|
+
answers: answers.value,
|
|
277
|
+
risks: risks.value,
|
|
278
|
+
conclusion: conclusion.value,
|
|
279
|
+
version: version.value,
|
|
280
|
+
...(recommendations?.ok ? { recommendations: recommendations.value } : {}),
|
|
281
|
+
...(reviewDate !== undefined ? { reviewDate } : {}),
|
|
282
|
+
...(typeof value.lawfulBasis === 'string' ? { lawfulBasis: value.lawfulBasis as (typeof LAWFUL_BASES)[number] } : {}),
|
|
283
|
+
...(typeof value.involvesCrossBorderTransfer === 'boolean' ? { involvesCrossBorderTransfer: value.involvesCrossBorderTransfer } : {}),
|
|
284
|
+
overallRiskLevel,
|
|
285
|
+
canProceed,
|
|
286
|
+
ndpcConsultationRequired,
|
|
287
|
+
completedAt: completedAt ?? null,
|
|
288
|
+
},
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const STATUS_TRANSITIONS: Record<DPIAStatus, readonly DPIAStatus[]> = {
|
|
293
|
+
draft: ['draft', 'in_progress'],
|
|
294
|
+
in_progress: ['in_progress', 'draft', 'completed', 'rejected'],
|
|
295
|
+
completed: ['completed', 'in_progress', 'approved', 'rejected'],
|
|
296
|
+
approved: ['approved', 'in_progress'],
|
|
297
|
+
rejected: ['rejected', 'in_progress'],
|
|
298
|
+
};
|
|
299
|
+
function buildDPIAMutation(input: Record<string, unknown>, actorId: string, existing?: DPIAExisting, now = Date.now()): Validation<DPIAMutation> {
|
|
300
|
+
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' };
|
|
301
|
+
if (!projectName.ok) return projectName;
|
|
302
|
+
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' };
|
|
303
|
+
if (!description.ok) return description;
|
|
304
|
+
const evidence = hasOwn(input, 'dpiaData')
|
|
305
|
+
? normalizeDPIAEvidence(input.dpiaData, now)
|
|
306
|
+
: existing
|
|
307
|
+
? normalizeDPIAEvidence(existing.dpiaData, now, true)
|
|
308
|
+
: { ok: false as const, error: 'dpiaData is required' };
|
|
309
|
+
if (!evidence.ok) return evidence;
|
|
310
|
+
if (input.status !== undefined && !isDPIAStatus(input.status)) return { ok: false, error: 'status is not supported' };
|
|
311
|
+
const status: DPIAStatus = isDPIAStatus(input.status) ? input.status : existing && isDPIAStatus(existing.status) ? existing.status : 'draft';
|
|
312
|
+
if (existing) {
|
|
313
|
+
if (!isDPIAStatus(existing.status)) return { ok: false, error: 'Stored DPIA status is invalid' };
|
|
314
|
+
if (!STATUS_TRANSITIONS[existing.status].includes(status)) return { ok: false, error: `DPIA status cannot transition from ${existing.status} to ${status}` };
|
|
315
|
+
const evidenceChanged = hasOwn(input, 'projectName') || hasOwn(input, 'description') || hasOwn(input, 'dpiaData');
|
|
316
|
+
if (evidenceChanged && ['completed', 'approved', 'rejected'].includes(existing.status) && status !== 'in_progress') {
|
|
317
|
+
return { ok: false, error: 'A terminal DPIA must be reopened as in_progress before its evidence is changed' };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (status === 'approved' && !evidence.value.canProceed) return { ok: false, error: 'A DPIA with high or critical residual risk cannot be approved' };
|
|
321
|
+
if (status === 'rejected' && evidence.value.canProceed) return { ok: false, error: 'A rejected DPIA must contain residual risk that prevents proceeding' };
|
|
322
|
+
const terminal = status === 'completed' || status === 'approved' || status === 'rejected';
|
|
323
|
+
const previouslyTerminal = existing && (existing.status === 'completed' || existing.status === 'approved' || existing.status === 'rejected');
|
|
324
|
+
const completedAt = terminal ? (previouslyTerminal ? evidence.value.completedAt ?? now : now) : null;
|
|
325
|
+
const dpiaData: DPIAEvidence = { ...evidence.value, completedAt };
|
|
326
|
+
const score = dpiaData.risks.reduce((highest, risk) => Math.max(highest, risk.residualScore ?? risk.score), 0);
|
|
327
|
+
return {
|
|
328
|
+
ok: true,
|
|
329
|
+
value: {
|
|
330
|
+
projectName: projectName.value,
|
|
331
|
+
description: description.value,
|
|
332
|
+
dpiaData,
|
|
333
|
+
overallRisk: dpiaData.overallRiskLevel,
|
|
334
|
+
score,
|
|
335
|
+
status,
|
|
336
|
+
conductedBy: actorId,
|
|
337
|
+
approvedBy: status === 'approved' ? (existing?.status === 'approved' ? existing.approvedBy ?? actorId : actorId) : null,
|
|
338
|
+
updatedAt: new Date(now),
|
|
339
|
+
},
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
function auditChanges(data: DPIAMutation) {
|
|
343
|
+
return { projectName: data.projectName, overallRisk: data.overallRisk, score: data.score, status: data.status, conductedBy: data.conductedBy, approvedBy: data.approvedBy };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function staffContext(req: NextRequest) {
|
|
347
|
+
const context = await resolveNDPRRequestContext(req);
|
|
348
|
+
return { context, problem: getNDPRContextProblem(context, 'staff') };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export async function GET(req: NextRequest) {
|
|
352
|
+
const { context, problem } = await staffContext(req);
|
|
353
|
+
if (problem) return NextResponse.json({ error: problem.error }, { status: problem.status });
|
|
354
|
+
const id = req.nextUrl.searchParams.get('id');
|
|
355
|
+
if (id) {
|
|
356
|
+
// {{#if ORM=prisma}}
|
|
357
|
+
const record = await prisma.dPIARecord.findFirst({ where: { id, tenantId: context.tenantId, removedAt: null } });
|
|
358
|
+
// {{/if}}
|
|
359
|
+
// {{#if ORM=drizzle}}
|
|
360
|
+
const [record] = await db.select().from(dpiaRecords).where(and(eq(dpiaRecords.id, id), eq(dpiaRecords.tenantId, context.tenantId), isNull(dpiaRecords.removedAt))).limit(1);
|
|
361
|
+
// {{/if}}
|
|
362
|
+
// {{#if ORM=none}}
|
|
363
|
+
const candidate = dpiaStore.get(id);
|
|
364
|
+
const record = candidate?.tenantId === context.tenantId && candidate.removedAt === null ? candidate : null;
|
|
365
|
+
// {{/if}}
|
|
366
|
+
return record ? NextResponse.json(record) : NextResponse.json({ error: 'DPIA record not found' }, { status: 404 });
|
|
367
|
+
}
|
|
368
|
+
const statusValue = req.nextUrl.searchParams.get('status');
|
|
369
|
+
if (statusValue !== null && !isDPIAStatus(statusValue)) return NextResponse.json({ error: 'status filter is not supported' }, { status: 400 });
|
|
370
|
+
const status = statusValue ?? undefined;
|
|
371
|
+
// {{#if ORM=prisma}}
|
|
372
|
+
const records = await prisma.dPIARecord.findMany({ where: { tenantId: context.tenantId, removedAt: null, ...(status ? { status } : {}) }, orderBy: { createdAt: 'desc' } });
|
|
373
|
+
// {{/if}}
|
|
374
|
+
// {{#if ORM=drizzle}}
|
|
375
|
+
const records = status
|
|
376
|
+
? await db.select().from(dpiaRecords).where(and(eq(dpiaRecords.tenantId, context.tenantId), isNull(dpiaRecords.removedAt), eq(dpiaRecords.status, status))).orderBy(desc(dpiaRecords.createdAt))
|
|
377
|
+
: await db.select().from(dpiaRecords).where(and(eq(dpiaRecords.tenantId, context.tenantId), isNull(dpiaRecords.removedAt))).orderBy(desc(dpiaRecords.createdAt));
|
|
378
|
+
// {{/if}}
|
|
379
|
+
// {{#if ORM=none}}
|
|
380
|
+
const records = [...dpiaStore.values()].filter((row) => row.tenantId === context.tenantId && row.removedAt === null && (!status || row.status === status)).sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
381
|
+
// {{/if}}
|
|
382
|
+
return NextResponse.json(records);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export async function POST(req: NextRequest) {
|
|
386
|
+
const { context, problem } = await staffContext(req);
|
|
387
|
+
if (problem) return NextResponse.json({ error: problem.error }, { status: problem.status });
|
|
388
|
+
const actorId = context.actorId as string;
|
|
389
|
+
const body: unknown = await req.json().catch(() => null);
|
|
390
|
+
if (!isRecord(body)) return NextResponse.json({ error: 'A JSON object is required' }, { status: 400 });
|
|
391
|
+
const unknown = rejectUnknownFields(body, ['projectName', 'description', 'dpiaData']);
|
|
392
|
+
if (unknown) return NextResponse.json({ error: unknown }, { status: 400 });
|
|
393
|
+
const normalized = buildDPIAMutation(body, actorId);
|
|
394
|
+
if (!normalized.ok) return NextResponse.json({ error: normalized.error }, { status: 400 });
|
|
395
|
+
const data = normalized.value;
|
|
396
|
+
// {{#if ORM=prisma}}
|
|
397
|
+
const record = await prisma.$transaction(async (tx) => {
|
|
398
|
+
const created = await tx.dPIARecord.create({ data: { ...data, tenantId: context.tenantId, dpiaData: data.dpiaData as unknown as Prisma.InputJsonValue, removedAt: null } });
|
|
399
|
+
await tx.complianceAuditLog.create({ data: { tenantId: context.tenantId, module: 'dpia', action: 'created', entityId: created.id, entityType: 'DPIARecord', performedBy: actorId, changes: auditChanges(data) } });
|
|
400
|
+
return created;
|
|
401
|
+
});
|
|
402
|
+
// {{/if}}
|
|
403
|
+
// {{#if ORM=drizzle}}
|
|
404
|
+
const record = await db.transaction(async (tx) => {
|
|
405
|
+
const [created] = await tx.insert(dpiaRecords).values({ ...data, tenantId: context.tenantId, removedAt: null }).returning();
|
|
406
|
+
await tx.insert(complianceAuditLog).values({ tenantId: context.tenantId, module: 'dpia', action: 'created', entityId: created.id, entityType: 'DPIARecord', performedBy: actorId, changes: auditChanges(data) });
|
|
407
|
+
return created;
|
|
408
|
+
});
|
|
409
|
+
// {{/if}}
|
|
410
|
+
// {{#if ORM=none}}
|
|
411
|
+
const now = data.updatedAt;
|
|
412
|
+
const record: DPIARow = { id: crypto.randomUUID(), ...data, tenantId: context.tenantId, createdAt: now, removedAt: null };
|
|
413
|
+
dpiaStore.set(record.id, record);
|
|
414
|
+
auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'created', entityId: record.id, performedBy: actorId, changes: auditChanges(data), at: now });
|
|
415
|
+
// {{/if}}
|
|
416
|
+
return NextResponse.json(record, { status: 201 });
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
export async function PUT(req: NextRequest) {
|
|
420
|
+
const { context, problem } = await staffContext(req);
|
|
421
|
+
if (problem) return NextResponse.json({ error: problem.error }, { status: problem.status });
|
|
422
|
+
const actorId = context.actorId as string;
|
|
423
|
+
const body: unknown = await req.json().catch(() => null);
|
|
424
|
+
if (!isRecord(body)) return NextResponse.json({ error: 'A JSON object is required' }, { status: 400 });
|
|
425
|
+
const unknown = rejectUnknownFields(body, ['id', 'projectName', 'description', 'dpiaData', 'status']);
|
|
426
|
+
if (unknown) return NextResponse.json({ error: unknown }, { status: 400 });
|
|
427
|
+
const idResult = nonEmptyText(body.id, 'id', 200);
|
|
428
|
+
if (!idResult.ok) return NextResponse.json({ error: idResult.error }, { status: 400 });
|
|
429
|
+
if (!['projectName', 'description', 'dpiaData', 'status'].some((field) => hasOwn(body, field))) return NextResponse.json({ error: 'Provide at least one allowlisted DPIA field to update' }, { status: 400 });
|
|
430
|
+
const id = idResult.value;
|
|
431
|
+
let validationError: string | null = null;
|
|
432
|
+
// {{#if ORM=prisma}}
|
|
433
|
+
const record = await prisma.$transaction(async (tx) => {
|
|
434
|
+
const existing = await tx.dPIARecord.findFirst({ where: { id, tenantId: context.tenantId, removedAt: null } });
|
|
435
|
+
if (!existing) return null;
|
|
436
|
+
const normalized = buildDPIAMutation(body, actorId, existing);
|
|
437
|
+
if (!normalized.ok) { validationError = normalized.error; return null; }
|
|
438
|
+
const data = normalized.value;
|
|
439
|
+
await tx.dPIARecord.updateMany({ where: { id, tenantId: context.tenantId, removedAt: null }, data: { ...data, dpiaData: data.dpiaData as unknown as Prisma.InputJsonValue } });
|
|
440
|
+
const updated = await tx.dPIARecord.findFirstOrThrow({ where: { id, tenantId: context.tenantId, removedAt: null } });
|
|
441
|
+
await tx.complianceAuditLog.create({ data: { tenantId: context.tenantId, module: 'dpia', action: 'updated', entityId: id, entityType: 'DPIARecord', performedBy: actorId, changes: auditChanges(data) } });
|
|
442
|
+
return updated;
|
|
443
|
+
});
|
|
444
|
+
// {{/if}}
|
|
445
|
+
// {{#if ORM=drizzle}}
|
|
446
|
+
const record = await db.transaction(async (tx) => {
|
|
447
|
+
const [existing] = await tx.select().from(dpiaRecords).where(and(eq(dpiaRecords.id, id), eq(dpiaRecords.tenantId, context.tenantId), isNull(dpiaRecords.removedAt))).limit(1);
|
|
448
|
+
if (!existing) return null;
|
|
449
|
+
const normalized = buildDPIAMutation(body, actorId, existing);
|
|
450
|
+
if (!normalized.ok) { validationError = normalized.error; return null; }
|
|
451
|
+
const data = normalized.value;
|
|
452
|
+
const [updated] = await tx.update(dpiaRecords).set(data).where(and(eq(dpiaRecords.id, id), eq(dpiaRecords.tenantId, context.tenantId), isNull(dpiaRecords.removedAt))).returning();
|
|
453
|
+
await tx.insert(complianceAuditLog).values({ tenantId: context.tenantId, module: 'dpia', action: 'updated', entityId: id, entityType: 'DPIARecord', performedBy: actorId, changes: auditChanges(data) });
|
|
454
|
+
return updated;
|
|
455
|
+
});
|
|
456
|
+
// {{/if}}
|
|
457
|
+
// {{#if ORM=none}}
|
|
458
|
+
const existing = dpiaStore.get(id);
|
|
459
|
+
let record: DPIARow | null = null;
|
|
460
|
+
if (existing?.tenantId === context.tenantId && existing.removedAt === null) {
|
|
461
|
+
const normalized = buildDPIAMutation(body, actorId, existing);
|
|
462
|
+
if (!normalized.ok) validationError = normalized.error;
|
|
463
|
+
else {
|
|
464
|
+
record = { ...existing, ...normalized.value, id };
|
|
465
|
+
dpiaStore.set(id, record);
|
|
466
|
+
auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'updated', entityId: id, performedBy: actorId, changes: auditChanges(normalized.value), at: normalized.value.updatedAt });
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// {{/if}}
|
|
470
|
+
if (validationError) return NextResponse.json({ error: validationError }, { status: 400 });
|
|
471
|
+
return record ? NextResponse.json(record) : NextResponse.json({ error: 'DPIA record not found' }, { status: 404 });
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
export async function DELETE(req: NextRequest) {
|
|
475
|
+
const { context, problem } = await staffContext(req);
|
|
476
|
+
if (problem) return NextResponse.json({ error: problem.error }, { status: problem.status });
|
|
477
|
+
const actorId = context.actorId as string;
|
|
478
|
+
const id = req.nextUrl.searchParams.get('id');
|
|
479
|
+
if (!id?.trim()) return NextResponse.json({ error: 'id is required' }, { status: 400 });
|
|
480
|
+
const archivedAt = new Date();
|
|
481
|
+
// {{#if ORM=prisma}}
|
|
482
|
+
const archived = await prisma.$transaction(async (tx) => {
|
|
483
|
+
const existing = await tx.dPIARecord.findFirst({ where: { id, tenantId: context.tenantId, removedAt: null } });
|
|
484
|
+
if (!existing) return false;
|
|
485
|
+
const result = await tx.dPIARecord.updateMany({ where: { id, tenantId: context.tenantId, removedAt: null }, data: { removedAt: archivedAt, updatedAt: archivedAt } });
|
|
486
|
+
if (result.count !== 1) return false;
|
|
487
|
+
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() } } });
|
|
488
|
+
return true;
|
|
489
|
+
});
|
|
490
|
+
// {{/if}}
|
|
491
|
+
// {{#if ORM=drizzle}}
|
|
492
|
+
const archived = await db.transaction(async (tx) => {
|
|
493
|
+
const [existing] = await tx.select().from(dpiaRecords).where(and(eq(dpiaRecords.id, id), eq(dpiaRecords.tenantId, context.tenantId), isNull(dpiaRecords.removedAt))).limit(1);
|
|
494
|
+
if (!existing) return false;
|
|
495
|
+
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();
|
|
496
|
+
if (!updated) return false;
|
|
497
|
+
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() } });
|
|
498
|
+
return true;
|
|
499
|
+
});
|
|
500
|
+
// {{/if}}
|
|
501
|
+
// {{#if ORM=none}}
|
|
502
|
+
const existing = dpiaStore.get(id);
|
|
503
|
+
const archived = existing?.tenantId === context.tenantId && existing.removedAt === null;
|
|
504
|
+
if (archived && existing) {
|
|
505
|
+
dpiaStore.set(id, { ...existing, removedAt: archivedAt, updatedAt: archivedAt });
|
|
506
|
+
auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'archived', entityId: id, performedBy: actorId, changes: { projectName: existing.projectName, removedAt: archivedAt.toISOString() }, at: archivedAt });
|
|
507
|
+
}
|
|
508
|
+
// {{/if}}
|
|
509
|
+
return archived ? NextResponse.json({ success: true, archived: true }) : NextResponse.json({ error: 'DPIA record not found' }, { status: 404 });
|
|
510
|
+
}
|