@tantainnovative/create-ndpr 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -68
- package/bin/index.mjs +366 -526
- package/package.json +1 -1
- package/templates/drizzle-client.ts +44 -0
- package/templates/drizzle-schema.ts +200 -178
- package/templates/env-example +6 -3
- package/templates/express-breach-route.ts +98 -0
- package/templates/express-consent-route.ts +304 -153
- package/templates/express-cross-border-route.ts +94 -259
- package/templates/express-dpia-route.ts +267 -222
- package/templates/express-dsr-route.ts +76 -0
- package/templates/express-lawful-basis-route.ts +88 -234
- package/templates/express-request-context.ts +130 -0
- package/templates/github-ndpr-audit.yml +3 -3
- package/templates/nextjs-breach-route.ts +117 -183
- package/templates/nextjs-consent-route.ts +312 -104
- package/templates/nextjs-cross-border-route.ts +239 -250
- package/templates/nextjs-dpia-route.ts +434 -214
- package/templates/nextjs-dsr-route.ts +70 -116
- package/templates/nextjs-lawful-basis-route.ts +207 -221
- package/templates/nextjs-layout.tsx +120 -57
- package/templates/nextjs-request-context.ts +137 -0
- package/templates/prisma-schema.prisma +89 -77
- package/templates/express-setup.ts +0 -509
|
@@ -1,290 +1,510 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Next.js App Router — DPIA (Data Protection Impact Assessment) Route
|
|
3
|
-
* Generated by create-ndpr for: {{ORG_NAME}}
|
|
4
|
-
*
|
|
5
|
-
* NDPA §28 — a DPIA must be conducted where processing is likely
|
|
6
|
-
* to result in a high risk to the rights and freedoms of data subjects.
|
|
7
|
-
*
|
|
8
|
-
* Endpoints:
|
|
9
|
-
* GET /api/dpia — List DPIA records (optional ?status= filter)
|
|
10
|
-
* GET /api/dpia?id=xxx — Get a single DPIA record
|
|
11
|
-
* POST /api/dpia — Create a new DPIA record
|
|
12
|
-
* PUT /api/dpia — Update an existing DPIA record
|
|
13
|
-
* DELETE /api/dpia?id=xxx — Delete a DPIA record
|
|
14
|
-
*/
|
|
15
|
-
|
|
1
|
+
/** Next.js App Router — staff-only, tenant-scoped DPIA register for {{ORG_NAME_COMMENT}}. */
|
|
16
2
|
import { NextRequest, NextResponse } from 'next/server';
|
|
3
|
+
import { getNDPRContextProblem, resolveNDPRRequestContext } from '{{NDPR_CONTEXT_IMPORT}}';
|
|
17
4
|
// {{#if ORM=prisma}}
|
|
18
|
-
import { PrismaClient } from '@prisma/client';
|
|
19
|
-
|
|
5
|
+
import { Prisma, PrismaClient } from '@prisma/client';
|
|
20
6
|
const prisma = new PrismaClient();
|
|
21
7
|
// {{/if}}
|
|
22
8
|
// {{#if ORM=drizzle}}
|
|
23
|
-
import { db } from '
|
|
24
|
-
import {
|
|
25
|
-
import {
|
|
9
|
+
import { db } from '{{NDPR_DB_IMPORT}}';
|
|
10
|
+
import { complianceAuditLog, dpiaRecords } from '{{NDPR_SCHEMA_IMPORT}}';
|
|
11
|
+
import { and, desc, eq, isNull } from 'drizzle-orm';
|
|
26
12
|
// {{/if}}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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 {
|
|
33
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 {
|
|
34
49
|
projectName: string;
|
|
35
50
|
description: string;
|
|
36
51
|
dpiaData: unknown;
|
|
37
|
-
overallRisk: string;
|
|
38
|
-
score: number;
|
|
39
52
|
status: string;
|
|
40
53
|
conductedBy: string;
|
|
41
54
|
approvedBy: string | null;
|
|
42
|
-
|
|
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;
|
|
43
65
|
updatedAt: Date;
|
|
44
66
|
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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 }> = [];
|
|
48
73
|
// {{/if}}
|
|
49
74
|
|
|
50
|
-
|
|
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
|
+
|
|
51
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 });
|
|
52
354
|
const id = req.nextUrl.searchParams.get('id');
|
|
53
|
-
|
|
54
355
|
if (id) {
|
|
55
356
|
// {{#if ORM=prisma}}
|
|
56
|
-
const record = await prisma.dPIARecord.
|
|
357
|
+
const record = await prisma.dPIARecord.findFirst({ where: { id, tenantId: context.tenantId, removedAt: null } });
|
|
57
358
|
// {{/if}}
|
|
58
359
|
// {{#if ORM=drizzle}}
|
|
59
|
-
const [record] = await db.select().from(dpiaRecords).where(eq(dpiaRecords.id, id)).limit(1);
|
|
360
|
+
const [record] = await db.select().from(dpiaRecords).where(and(eq(dpiaRecords.id, id), eq(dpiaRecords.tenantId, context.tenantId), isNull(dpiaRecords.removedAt))).limit(1);
|
|
60
361
|
// {{/if}}
|
|
61
362
|
// {{#if ORM=none}}
|
|
62
|
-
const
|
|
363
|
+
const candidate = dpiaStore.get(id);
|
|
364
|
+
const record = candidate?.tenantId === context.tenantId && candidate.removedAt === null ? candidate : null;
|
|
63
365
|
// {{/if}}
|
|
64
|
-
|
|
65
|
-
if (!record) {
|
|
66
|
-
return NextResponse.json({ error: 'DPIA record not found' }, { status: 404 });
|
|
67
|
-
}
|
|
68
|
-
return NextResponse.json(record);
|
|
366
|
+
return record ? NextResponse.json(record) : NextResponse.json({ error: 'DPIA record not found' }, { status: 404 });
|
|
69
367
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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;
|
|
73
371
|
// {{#if ORM=prisma}}
|
|
74
|
-
const records = await prisma.dPIARecord.findMany({
|
|
75
|
-
where: status ? { status } : undefined,
|
|
76
|
-
orderBy: { createdAt: 'desc' },
|
|
77
|
-
});
|
|
372
|
+
const records = await prisma.dPIARecord.findMany({ where: { tenantId: context.tenantId, removedAt: null, ...(status ? { status } : {}) }, orderBy: { createdAt: 'desc' } });
|
|
78
373
|
// {{/if}}
|
|
79
374
|
// {{#if ORM=drizzle}}
|
|
80
375
|
const records = status
|
|
81
|
-
? await db.select().from(dpiaRecords).where(eq(dpiaRecords.status, status)).orderBy(desc(dpiaRecords.createdAt))
|
|
82
|
-
: await db.select().from(dpiaRecords).orderBy(desc(dpiaRecords.createdAt));
|
|
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));
|
|
83
378
|
// {{/if}}
|
|
84
379
|
// {{#if ORM=none}}
|
|
85
|
-
const records = [...dpiaStore.values()]
|
|
86
|
-
.filter((r) => (status ? r.status === status : true))
|
|
87
|
-
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
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());
|
|
88
381
|
// {{/if}}
|
|
89
|
-
|
|
90
382
|
return NextResponse.json(records);
|
|
91
383
|
}
|
|
92
384
|
|
|
93
|
-
// POST /api/dpia
|
|
94
385
|
export async function POST(req: NextRequest) {
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
if (!projectName || !description || !dpiaData || !overallRisk || score == null || !conductedBy) {
|
|
107
|
-
return NextResponse.json(
|
|
108
|
-
{ error: 'projectName, description, dpiaData, overallRisk, score, and conductedBy are required' },
|
|
109
|
-
{ status: 400 },
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
|
|
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;
|
|
113
396
|
// {{#if ORM=prisma}}
|
|
114
|
-
const record = await prisma
|
|
115
|
-
data: {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
dpiaData,
|
|
119
|
-
overallRisk,
|
|
120
|
-
score,
|
|
121
|
-
status: 'draft',
|
|
122
|
-
conductedBy,
|
|
123
|
-
approvedBy: approvedBy ?? null,
|
|
124
|
-
},
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
await prisma.complianceAuditLog.create({
|
|
128
|
-
data: {
|
|
129
|
-
module: 'dpia',
|
|
130
|
-
action: 'created',
|
|
131
|
-
entityId: record.id,
|
|
132
|
-
entityType: 'DPIARecord',
|
|
133
|
-
changes: { projectName, overallRisk, score, status: 'draft' },
|
|
134
|
-
},
|
|
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;
|
|
135
401
|
});
|
|
136
402
|
// {{/if}}
|
|
137
403
|
// {{#if ORM=drizzle}}
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
overallRisk,
|
|
143
|
-
score,
|
|
144
|
-
status: 'draft',
|
|
145
|
-
conductedBy,
|
|
146
|
-
approvedBy: approvedBy ?? null,
|
|
147
|
-
}).returning();
|
|
148
|
-
|
|
149
|
-
await db.insert(complianceAuditLog).values({
|
|
150
|
-
module: 'dpia',
|
|
151
|
-
action: 'created',
|
|
152
|
-
entityId: record.id,
|
|
153
|
-
entityType: 'DPIARecord',
|
|
154
|
-
changes: { projectName, overallRisk, score, status: 'draft' },
|
|
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;
|
|
155
408
|
});
|
|
156
409
|
// {{/if}}
|
|
157
410
|
// {{#if ORM=none}}
|
|
158
|
-
const now =
|
|
159
|
-
const record:
|
|
160
|
-
id: newId(),
|
|
161
|
-
projectName,
|
|
162
|
-
description,
|
|
163
|
-
dpiaData,
|
|
164
|
-
overallRisk,
|
|
165
|
-
score,
|
|
166
|
-
status: 'draft',
|
|
167
|
-
conductedBy,
|
|
168
|
-
approvedBy: approvedBy ?? null,
|
|
169
|
-
createdAt: now,
|
|
170
|
-
updatedAt: now,
|
|
171
|
-
};
|
|
411
|
+
const now = data.updatedAt;
|
|
412
|
+
const record: DPIARow = { id: crypto.randomUUID(), ...data, tenantId: context.tenantId, createdAt: now, removedAt: null };
|
|
172
413
|
dpiaStore.set(record.id, record);
|
|
173
|
-
auditLog.push({ id:
|
|
414
|
+
auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'created', entityId: record.id, performedBy: actorId, changes: auditChanges(data), at: now });
|
|
174
415
|
// {{/if}}
|
|
175
|
-
|
|
176
416
|
return NextResponse.json(record, { status: 201 });
|
|
177
417
|
}
|
|
178
418
|
|
|
179
|
-
// PUT /api/dpia
|
|
180
419
|
export async function PUT(req: NextRequest) {
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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;
|
|
188
432
|
// {{#if ORM=prisma}}
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
where: { id },
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
await prisma.complianceAuditLog.create({
|
|
200
|
-
data: {
|
|
201
|
-
module: 'dpia',
|
|
202
|
-
action: 'updated',
|
|
203
|
-
entityId: record.id,
|
|
204
|
-
entityType: 'DPIARecord',
|
|
205
|
-
changes: data,
|
|
206
|
-
},
|
|
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;
|
|
207
443
|
});
|
|
208
444
|
// {{/if}}
|
|
209
445
|
// {{#if ORM=drizzle}}
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
action: 'updated',
|
|
220
|
-
entityId: record.id,
|
|
221
|
-
entityType: 'DPIARecord',
|
|
222
|
-
changes: data,
|
|
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;
|
|
223
455
|
});
|
|
224
456
|
// {{/if}}
|
|
225
457
|
// {{#if ORM=none}}
|
|
226
458
|
const existing = dpiaStore.get(id);
|
|
227
|
-
|
|
228
|
-
|
|
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
|
+
}
|
|
229
468
|
}
|
|
230
|
-
const record: DPIARecord = { ...existing, ...data, id, updatedAt: new Date() };
|
|
231
|
-
dpiaStore.set(id, record);
|
|
232
|
-
auditLog.push({ id: newId(), module: 'dpia', action: 'updated', entityId: record.id, at: new Date() });
|
|
233
469
|
// {{/if}}
|
|
234
|
-
|
|
235
|
-
return NextResponse.json(record);
|
|
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 });
|
|
236
472
|
}
|
|
237
473
|
|
|
238
|
-
// DELETE /api/dpia?id=xxx
|
|
239
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;
|
|
240
478
|
const id = req.nextUrl.searchParams.get('id');
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
return NextResponse.json({ error: 'id is required' }, { status: 400 });
|
|
244
|
-
}
|
|
245
|
-
|
|
479
|
+
if (!id?.trim()) return NextResponse.json({ error: 'id is required' }, { status: 400 });
|
|
480
|
+
const archivedAt = new Date();
|
|
246
481
|
// {{#if ORM=prisma}}
|
|
247
|
-
const
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
await prisma.complianceAuditLog.create({
|
|
255
|
-
data: {
|
|
256
|
-
module: 'dpia',
|
|
257
|
-
action: 'deleted',
|
|
258
|
-
entityId: id,
|
|
259
|
-
entityType: 'DPIARecord',
|
|
260
|
-
changes: { projectName: existing.projectName },
|
|
261
|
-
},
|
|
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;
|
|
262
489
|
});
|
|
263
490
|
// {{/if}}
|
|
264
491
|
// {{#if ORM=drizzle}}
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
await db.insert(complianceAuditLog).values({
|
|
273
|
-
module: 'dpia',
|
|
274
|
-
action: 'deleted',
|
|
275
|
-
entityId: id,
|
|
276
|
-
entityType: 'DPIARecord',
|
|
277
|
-
changes: { projectName: existing.projectName },
|
|
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;
|
|
278
499
|
});
|
|
279
500
|
// {{/if}}
|
|
280
501
|
// {{#if ORM=none}}
|
|
281
502
|
const existing = dpiaStore.get(id);
|
|
282
|
-
|
|
283
|
-
|
|
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 });
|
|
284
507
|
}
|
|
285
|
-
dpiaStore.delete(id);
|
|
286
|
-
auditLog.push({ id: newId(), module: 'dpia', action: 'deleted', entityId: id, at: new Date() });
|
|
287
508
|
// {{/if}}
|
|
288
|
-
|
|
289
|
-
return NextResponse.json({ success: true });
|
|
509
|
+
return archived ? NextResponse.json({ success: true, archived: true }) : NextResponse.json({ error: 'DPIA record not found' }, { status: 404 });
|
|
290
510
|
}
|