@tantainnovative/create-ndpr 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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 '@/drizzle';
24
- import { dpiaRecords, complianceAuditLog } from '@/drizzle/ndpr-schema';
25
- import { eq, desc } from 'drizzle-orm';
9
+ import { db } from '{{NDPR_DB_IMPORT}}';
10
+ import { complianceAuditLog, dpiaRecords } from '{{NDPR_SCHEMA_IMPORT}}';
11
+ import { and, desc, eq, isNull } from 'drizzle-orm';
26
12
  // {{/if}}
27
- // {{#if ORM=none}}
28
- // TODO ({{ORG_NAME}}): wire this to your persistent store of choice. The
29
- // in-memory map below is enough to develop against locally but DOES NOT
30
- // satisfy NDPA Section 44 (record-keeping) or the auditability NDPC asks
31
- // about. Replace `dpiaStore`/`auditLog` with your DB / KV / API.
32
- interface DPIARecord {
13
+
14
+ const DPIA_STATUSES = ['draft', 'in_progress', 'completed', 'approved', 'rejected'] as const;
15
+ const RISK_LEVELS = ['low', 'medium', 'high', 'critical'] as const;
16
+ const LAWFUL_BASES = ['consent', 'contract', 'legal_obligation', 'vital_interests', 'public_interest', 'legitimate_interests'] as const;
17
+ type DPIAStatus = (typeof DPIA_STATUSES)[number];
18
+ type RiskLevel = (typeof RISK_LEVELS)[number];
19
+ type AnswerValue = string | number | boolean | string[];
20
+ type Validation<T> = { ok: true; value: T } | { ok: false; error: string };
21
+
22
+ interface DPIARiskEvidence {
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
- createdAt: Date;
55
+ }
56
+ interface DPIAMutation {
57
+ projectName: string;
58
+ description: string;
59
+ dpiaData: DPIAEvidence;
60
+ overallRisk: RiskLevel;
61
+ score: number;
62
+ status: DPIAStatus;
63
+ conductedBy: string;
64
+ approvedBy: string | null;
43
65
  updatedAt: Date;
44
66
  }
45
- const dpiaStore = new Map<string, DPIARecord>();
46
- const auditLog: Array<{ id: string; module: string; action: string; entityId: string; at: Date }> = [];
47
- function newId() { return crypto.randomUUID(); }
67
+
68
+ // {{#if ORM=none}}
69
+ // DEVELOPMENT ONLY: replace both stores with one durable transactional store.
70
+ interface DPIARow extends DPIAExisting { id: string; tenantId: string; dpiaData: DPIAEvidence; overallRisk: RiskLevel; score: number; status: DPIAStatus; createdAt: Date; updatedAt: Date; removedAt: Date | null }
71
+ const dpiaStore = new Map<string, DPIARow>();
72
+ const auditLog: Array<{ id: string; tenantId: string; action: string; entityId: string; performedBy: string; changes?: unknown; at: Date }> = [];
48
73
  // {{/if}}
49
74
 
50
- // GET /api/dpia?status=draft OR /api/dpia?id=xxx
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.findUnique({ where: { id } });
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 record = dpiaStore.get(id) ?? null;
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
- const status = req.nextUrl.searchParams.get('status');
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 body = await req.json();
96
- const {
97
- projectName,
98
- description,
99
- dpiaData,
100
- overallRisk,
101
- score,
102
- conductedBy,
103
- approvedBy,
104
- } = body;
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.dPIARecord.create({
115
- data: {
116
- projectName,
117
- description,
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 [record] = await db.insert(dpiaRecords).values({
139
- projectName,
140
- description,
141
- dpiaData,
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 = new Date();
159
- const record: DPIARecord = {
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: newId(), module: 'dpia', action: 'created', entityId: record.id, at: new Date() });
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 body = await req.json();
182
- const { id, ...data } = body;
183
-
184
- if (!id) {
185
- return NextResponse.json({ error: 'id is required' }, { status: 400 });
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 existing = await prisma.dPIARecord.findUnique({ where: { id } });
190
- if (!existing) {
191
- return NextResponse.json({ error: 'DPIA record not found' }, { status: 404 });
192
- }
193
-
194
- const record = await prisma.dPIARecord.update({
195
- where: { id },
196
- data,
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 [existing] = await db.select().from(dpiaRecords).where(eq(dpiaRecords.id, id)).limit(1);
211
- if (!existing) {
212
- return NextResponse.json({ error: 'DPIA record not found' }, { status: 404 });
213
- }
214
-
215
- const [record] = await db.update(dpiaRecords).set(data).where(eq(dpiaRecords.id, id)).returning();
216
-
217
- await db.insert(complianceAuditLog).values({
218
- module: 'dpia',
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
- if (!existing) {
228
- return NextResponse.json({ error: 'DPIA record not found' }, { status: 404 });
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
- if (!id) {
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 existing = await prisma.dPIARecord.findUnique({ where: { id } });
248
- if (!existing) {
249
- return NextResponse.json({ error: 'DPIA record not found' }, { status: 404 });
250
- }
251
-
252
- await prisma.dPIARecord.delete({ where: { id } });
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 [existing] = await db.select().from(dpiaRecords).where(eq(dpiaRecords.id, id)).limit(1);
266
- if (!existing) {
267
- return NextResponse.json({ error: 'DPIA record not found' }, { status: 404 });
268
- }
269
-
270
- await db.delete(dpiaRecords).where(eq(dpiaRecords.id, id));
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
- if (!existing) {
283
- return NextResponse.json({ error: 'DPIA record not found' }, { status: 404 });
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
  }