@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,297 +1,283 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Next.js App Router — Lawful Basis Route
|
|
3
|
-
* Generated by create-ndpr for: {{ORG_NAME}}
|
|
4
|
-
*
|
|
5
|
-
* NDPA §25 — data processing must have a lawful basis. This route
|
|
6
|
-
* manages a register of processing activities and their lawful bases.
|
|
7
|
-
*
|
|
8
|
-
* Endpoints:
|
|
9
|
-
* GET /api/lawful-basis — List lawful basis records (optional ?lawfulBasis= filter)
|
|
10
|
-
* GET /api/lawful-basis?id=xxx — Get a single record
|
|
11
|
-
* POST /api/lawful-basis — Create a new lawful basis record
|
|
12
|
-
* PUT /api/lawful-basis — Update an existing record
|
|
13
|
-
* DELETE /api/lawful-basis?id=xxx — Delete a record
|
|
14
|
-
*/
|
|
15
|
-
|
|
1
|
+
/** Next.js App Router — staff-only, tenant-scoped lawful-basis 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
5
|
import { PrismaClient } from '@prisma/client';
|
|
19
|
-
|
|
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, lawfulBasisRecords } from '{{NDPR_SCHEMA_IMPORT}}';
|
|
11
|
+
import { and, desc, eq, isNull } from 'drizzle-orm';
|
|
26
12
|
// {{/if}}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
interface LawfulBasisRecord {
|
|
33
|
-
id: string;
|
|
13
|
+
|
|
14
|
+
const LAWFUL_BASES = ['consent', 'contract', 'legal_obligation', 'vital_interests', 'public_interest', 'legitimate_interests'] as const;
|
|
15
|
+
type LawfulBasis = (typeof LAWFUL_BASES)[number];
|
|
16
|
+
type Validation<T> = { ok: true; value: T } | { ok: false; error: string };
|
|
17
|
+
interface BasisExisting {
|
|
34
18
|
activityName: string;
|
|
35
19
|
lawfulBasis: string;
|
|
36
20
|
justification: string;
|
|
21
|
+
dataCategories: unknown;
|
|
22
|
+
purposes: unknown;
|
|
23
|
+
reviewDate: Date | null;
|
|
24
|
+
}
|
|
25
|
+
interface BasisMutation {
|
|
26
|
+
activityName: string;
|
|
27
|
+
lawfulBasis: LawfulBasis;
|
|
28
|
+
justification: string;
|
|
37
29
|
dataCategories: string[];
|
|
38
30
|
purposes: string[];
|
|
39
31
|
assessedBy: string;
|
|
32
|
+
assessedAt: Date;
|
|
40
33
|
reviewDate: Date | null;
|
|
41
|
-
createdAt: Date;
|
|
42
34
|
updatedAt: Date;
|
|
43
35
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
36
|
+
|
|
37
|
+
// {{#if ORM=none}}
|
|
38
|
+
// DEVELOPMENT ONLY: replace both stores with one durable transactional store.
|
|
39
|
+
interface BasisRow extends BasisMutation { id: string; tenantId: string; createdAt: Date; removedAt: Date | null }
|
|
40
|
+
const lawfulBasisStore = new Map<string, BasisRow>();
|
|
41
|
+
const auditLog: Array<{ id: string; tenantId: string; action: string; entityId: string; performedBy: string; changes?: unknown; at: Date }> = [];
|
|
47
42
|
// {{/if}}
|
|
48
43
|
|
|
49
|
-
|
|
44
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
45
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
46
|
+
}
|
|
47
|
+
function hasOwn(value: Record<string, unknown>, key: string): boolean {
|
|
48
|
+
return Object.prototype.hasOwnProperty.call(value, key);
|
|
49
|
+
}
|
|
50
|
+
function rejectUnknownFields(value: Record<string, unknown>, allowed: readonly string[]): string | null {
|
|
51
|
+
const allowedSet = new Set(allowed);
|
|
52
|
+
const unknown = Object.keys(value).filter((key) => !allowedSet.has(key));
|
|
53
|
+
return unknown.length > 0 ? `Unsupported field(s): ${unknown.join(', ')}` : null;
|
|
54
|
+
}
|
|
55
|
+
function nonEmptyText(value: unknown, field: string, maxLength = 10_000): Validation<string> {
|
|
56
|
+
if (typeof value !== 'string' || !value.trim() || value.trim().length > maxLength) {
|
|
57
|
+
return { ok: false, error: `${field} must be non-empty text of at most ${maxLength} characters` };
|
|
58
|
+
}
|
|
59
|
+
return { ok: true, value: value.trim() };
|
|
60
|
+
}
|
|
61
|
+
function nonEmptyStringArray(value: unknown, field: string): Validation<string[]> {
|
|
62
|
+
if (!Array.isArray(value) || value.length === 0 || value.length > 100) {
|
|
63
|
+
return { ok: false, error: `${field} must be a non-empty array with at most 100 items` };
|
|
64
|
+
}
|
|
65
|
+
const result: string[] = [];
|
|
66
|
+
for (const item of value) {
|
|
67
|
+
if (typeof item !== 'string' || !item.trim()) return { ok: false, error: `${field} must contain only non-empty strings` };
|
|
68
|
+
if (!result.includes(item.trim())) result.push(item.trim());
|
|
69
|
+
}
|
|
70
|
+
return { ok: true, value: result };
|
|
71
|
+
}
|
|
72
|
+
function isLawfulBasis(value: unknown): value is LawfulBasis {
|
|
73
|
+
return typeof value === 'string' && (LAWFUL_BASES as readonly string[]).includes(value);
|
|
74
|
+
}
|
|
75
|
+
function parseReviewDate(value: unknown): Date | null | undefined {
|
|
76
|
+
if (value === null || value === '') return null;
|
|
77
|
+
if (value instanceof Date) return Number.isFinite(value.getTime()) ? new Date(value.getTime()) : undefined;
|
|
78
|
+
if (typeof value !== 'string') return undefined;
|
|
79
|
+
const input = value.trim();
|
|
80
|
+
const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(input);
|
|
81
|
+
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);
|
|
82
|
+
if (!dateOnly && !dateTime) return undefined;
|
|
83
|
+
const timestamp = Date.parse(input);
|
|
84
|
+
if (!Number.isFinite(timestamp)) return undefined;
|
|
85
|
+
const parts = dateOnly ?? dateTime;
|
|
86
|
+
const check = new Date(Date.UTC(Number(parts![1]), Number(parts![2]) - 1, Number(parts![3])));
|
|
87
|
+
if (check.getUTCFullYear() !== Number(parts![1]) || check.getUTCMonth() + 1 !== Number(parts![2]) || check.getUTCDate() !== Number(parts![3])) return undefined;
|
|
88
|
+
return new Date(timestamp);
|
|
89
|
+
}
|
|
90
|
+
function buildBasisMutation(input: Record<string, unknown>, actorId: string, existing?: BasisExisting, now = new Date()): Validation<BasisMutation> {
|
|
91
|
+
const activityName = hasOwn(input, 'activityName') ? nonEmptyText(input.activityName, 'activityName', 500) : existing ? nonEmptyText(existing.activityName, 'stored activityName', 500) : { ok: false as const, error: 'activityName is required' };
|
|
92
|
+
if (!activityName.ok) return activityName;
|
|
93
|
+
const basisValue = hasOwn(input, 'lawfulBasis') ? input.lawfulBasis : existing?.lawfulBasis;
|
|
94
|
+
if (!isLawfulBasis(basisValue)) return { ok: false, error: 'lawfulBasis is not one of the six supported NDPA bases' };
|
|
95
|
+
const justification = hasOwn(input, 'justification') ? nonEmptyText(input.justification, 'justification') : existing ? nonEmptyText(existing.justification, 'stored justification') : { ok: false as const, error: 'justification is required' };
|
|
96
|
+
if (!justification.ok) return justification;
|
|
97
|
+
if (basisValue === 'legitimate_interests' && justification.value.length < 20) {
|
|
98
|
+
return { ok: false, error: 'legitimate_interests requires a detailed justification covering the interest, necessity, and balancing assessment' };
|
|
99
|
+
}
|
|
100
|
+
const categories = nonEmptyStringArray(hasOwn(input, 'dataCategories') ? input.dataCategories : existing?.dataCategories, 'dataCategories');
|
|
101
|
+
if (!categories.ok) return categories;
|
|
102
|
+
const purposes = nonEmptyStringArray(hasOwn(input, 'purposes') ? input.purposes : existing?.purposes, 'purposes');
|
|
103
|
+
if (!purposes.ok) return purposes;
|
|
104
|
+
let reviewDate: Date | null;
|
|
105
|
+
if (hasOwn(input, 'reviewDate')) {
|
|
106
|
+
const parsed = parseReviewDate(input.reviewDate);
|
|
107
|
+
if (parsed === undefined) return { ok: false, error: 'reviewDate must be null, YYYY-MM-DD, or an ISO timestamp with a timezone' };
|
|
108
|
+
if (parsed !== null && parsed.getTime() <= now.getTime()) return { ok: false, error: 'reviewDate must be in the future' };
|
|
109
|
+
reviewDate = parsed;
|
|
110
|
+
} else {
|
|
111
|
+
const parsed = parseReviewDate(existing?.reviewDate ?? null);
|
|
112
|
+
if (parsed === undefined) return { ok: false, error: 'Stored reviewDate is invalid' };
|
|
113
|
+
reviewDate = parsed;
|
|
114
|
+
}
|
|
115
|
+
return { ok: true, value: { activityName: activityName.value, lawfulBasis: basisValue, justification: justification.value, dataCategories: categories.value, purposes: purposes.value, assessedBy: actorId, assessedAt: now, reviewDate, updatedAt: now } };
|
|
116
|
+
}
|
|
117
|
+
function auditChanges(data: BasisMutation) {
|
|
118
|
+
return { activityName: data.activityName, lawfulBasis: data.lawfulBasis, assessedBy: data.assessedBy, reviewDate: data.reviewDate?.toISOString() ?? null };
|
|
119
|
+
}
|
|
120
|
+
async function staffContext(req: NextRequest) {
|
|
121
|
+
const context = await resolveNDPRRequestContext(req);
|
|
122
|
+
return { context, problem: getNDPRContextProblem(context, 'staff') };
|
|
123
|
+
}
|
|
124
|
+
|
|
50
125
|
export async function GET(req: NextRequest) {
|
|
126
|
+
const { context, problem } = await staffContext(req);
|
|
127
|
+
if (problem) return NextResponse.json({ error: problem.error }, { status: problem.status });
|
|
51
128
|
const id = req.nextUrl.searchParams.get('id');
|
|
52
|
-
|
|
53
129
|
if (id) {
|
|
54
130
|
// {{#if ORM=prisma}}
|
|
55
|
-
const record = await prisma.lawfulBasisRecord.
|
|
131
|
+
const record = await prisma.lawfulBasisRecord.findFirst({ where: { id, tenantId: context.tenantId, removedAt: null } });
|
|
56
132
|
// {{/if}}
|
|
57
133
|
// {{#if ORM=drizzle}}
|
|
58
|
-
const [record] = await db.select().from(lawfulBasisRecords).where(eq(lawfulBasisRecords.id, id)).limit(1);
|
|
134
|
+
const [record] = await db.select().from(lawfulBasisRecords).where(and(eq(lawfulBasisRecords.id, id), eq(lawfulBasisRecords.tenantId, context.tenantId), isNull(lawfulBasisRecords.removedAt))).limit(1);
|
|
59
135
|
// {{/if}}
|
|
60
136
|
// {{#if ORM=none}}
|
|
61
|
-
const
|
|
137
|
+
const candidate = lawfulBasisStore.get(id);
|
|
138
|
+
const record = candidate?.tenantId === context.tenantId && candidate.removedAt === null ? candidate : null;
|
|
62
139
|
// {{/if}}
|
|
63
|
-
|
|
64
|
-
if (!record) {
|
|
65
|
-
return NextResponse.json({ error: 'Lawful basis record not found' }, { status: 404 });
|
|
66
|
-
}
|
|
67
|
-
return NextResponse.json(record);
|
|
140
|
+
return record ? NextResponse.json(record) : NextResponse.json({ error: 'Lawful-basis record not found' }, { status: 404 });
|
|
68
141
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
142
|
+
const basisValue = req.nextUrl.searchParams.get('lawfulBasis');
|
|
143
|
+
if (basisValue !== null && !isLawfulBasis(basisValue)) return NextResponse.json({ error: 'lawfulBasis filter is not supported' }, { status: 400 });
|
|
144
|
+
const basis = basisValue ?? undefined;
|
|
72
145
|
// {{#if ORM=prisma}}
|
|
73
|
-
const records = await prisma.lawfulBasisRecord.findMany({
|
|
74
|
-
where: lawfulBasis ? { lawfulBasis } : undefined,
|
|
75
|
-
orderBy: { createdAt: 'desc' },
|
|
76
|
-
});
|
|
146
|
+
const records = await prisma.lawfulBasisRecord.findMany({ where: { tenantId: context.tenantId, removedAt: null, ...(basis ? { lawfulBasis: basis } : {}) }, orderBy: { createdAt: 'desc' } });
|
|
77
147
|
// {{/if}}
|
|
78
148
|
// {{#if ORM=drizzle}}
|
|
79
|
-
const records =
|
|
80
|
-
? await db.select().from(lawfulBasisRecords).where(eq(lawfulBasisRecords.lawfulBasis,
|
|
81
|
-
: await db.select().from(lawfulBasisRecords).orderBy(desc(lawfulBasisRecords.createdAt));
|
|
149
|
+
const records = basis
|
|
150
|
+
? await db.select().from(lawfulBasisRecords).where(and(eq(lawfulBasisRecords.tenantId, context.tenantId), isNull(lawfulBasisRecords.removedAt), eq(lawfulBasisRecords.lawfulBasis, basis))).orderBy(desc(lawfulBasisRecords.createdAt))
|
|
151
|
+
: await db.select().from(lawfulBasisRecords).where(and(eq(lawfulBasisRecords.tenantId, context.tenantId), isNull(lawfulBasisRecords.removedAt))).orderBy(desc(lawfulBasisRecords.createdAt));
|
|
82
152
|
// {{/if}}
|
|
83
153
|
// {{#if ORM=none}}
|
|
84
|
-
const records = [...lawfulBasisStore.values()]
|
|
85
|
-
.filter((r) => (lawfulBasis ? r.lawfulBasis === lawfulBasis : true))
|
|
86
|
-
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
154
|
+
const records = [...lawfulBasisStore.values()].filter((row) => row.tenantId === context.tenantId && row.removedAt === null && (!basis || row.lawfulBasis === basis)).sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
|
87
155
|
// {{/if}}
|
|
88
|
-
|
|
89
156
|
return NextResponse.json(records);
|
|
90
157
|
}
|
|
91
158
|
|
|
92
|
-
// POST /api/lawful-basis
|
|
93
159
|
export async function POST(req: NextRequest) {
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if (!activityName || !lawfulBasis || !justification || !dataCategories || !purposes || !assessedBy) {
|
|
106
|
-
return NextResponse.json(
|
|
107
|
-
{ error: 'activityName, lawfulBasis, justification, dataCategories, purposes, and assessedBy are required' },
|
|
108
|
-
{ status: 400 },
|
|
109
|
-
);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (!Array.isArray(dataCategories) || !Array.isArray(purposes)) {
|
|
113
|
-
return NextResponse.json(
|
|
114
|
-
{ error: 'dataCategories and purposes must be arrays' },
|
|
115
|
-
{ status: 400 },
|
|
116
|
-
);
|
|
117
|
-
}
|
|
118
|
-
|
|
160
|
+
const { context, problem } = await staffContext(req);
|
|
161
|
+
if (problem) return NextResponse.json({ error: problem.error }, { status: problem.status });
|
|
162
|
+
const actorId = context.actorId as string;
|
|
163
|
+
const body: unknown = await req.json().catch(() => null);
|
|
164
|
+
if (!isRecord(body)) return NextResponse.json({ error: 'A JSON object is required' }, { status: 400 });
|
|
165
|
+
const unknown = rejectUnknownFields(body, ['activityName', 'lawfulBasis', 'justification', 'dataCategories', 'purposes', 'reviewDate']);
|
|
166
|
+
if (unknown) return NextResponse.json({ error: unknown }, { status: 400 });
|
|
167
|
+
const normalized = buildBasisMutation(body, actorId);
|
|
168
|
+
if (!normalized.ok) return NextResponse.json({ error: normalized.error }, { status: 400 });
|
|
169
|
+
const data = normalized.value;
|
|
119
170
|
// {{#if ORM=prisma}}
|
|
120
|
-
const record = await prisma
|
|
121
|
-
data: {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
justification,
|
|
125
|
-
dataCategories,
|
|
126
|
-
purposes,
|
|
127
|
-
assessedBy,
|
|
128
|
-
reviewDate: reviewDate ? new Date(reviewDate) : null,
|
|
129
|
-
},
|
|
130
|
-
});
|
|
131
|
-
|
|
132
|
-
await prisma.complianceAuditLog.create({
|
|
133
|
-
data: {
|
|
134
|
-
module: 'lawful-basis',
|
|
135
|
-
action: 'created',
|
|
136
|
-
entityId: record.id,
|
|
137
|
-
entityType: 'LawfulBasisRecord',
|
|
138
|
-
changes: { activityName, lawfulBasis, assessedBy },
|
|
139
|
-
},
|
|
171
|
+
const record = await prisma.$transaction(async (tx) => {
|
|
172
|
+
const created = await tx.lawfulBasisRecord.create({ data: { ...data, tenantId: context.tenantId, removedAt: null } });
|
|
173
|
+
await tx.complianceAuditLog.create({ data: { tenantId: context.tenantId, module: 'lawful-basis', action: 'created', entityId: created.id, entityType: 'LawfulBasisRecord', performedBy: actorId, changes: auditChanges(data) } });
|
|
174
|
+
return created;
|
|
140
175
|
});
|
|
141
176
|
// {{/if}}
|
|
142
177
|
// {{#if ORM=drizzle}}
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
dataCategories,
|
|
148
|
-
purposes,
|
|
149
|
-
assessedBy,
|
|
150
|
-
reviewDate: reviewDate ? new Date(reviewDate) : null,
|
|
151
|
-
}).returning();
|
|
152
|
-
|
|
153
|
-
await db.insert(complianceAuditLog).values({
|
|
154
|
-
module: 'lawful-basis',
|
|
155
|
-
action: 'created',
|
|
156
|
-
entityId: record.id,
|
|
157
|
-
entityType: 'LawfulBasisRecord',
|
|
158
|
-
changes: { activityName, lawfulBasis, assessedBy },
|
|
178
|
+
const record = await db.transaction(async (tx) => {
|
|
179
|
+
const [created] = await tx.insert(lawfulBasisRecords).values({ ...data, tenantId: context.tenantId, removedAt: null }).returning();
|
|
180
|
+
await tx.insert(complianceAuditLog).values({ tenantId: context.tenantId, module: 'lawful-basis', action: 'created', entityId: created.id, entityType: 'LawfulBasisRecord', performedBy: actorId, changes: auditChanges(data) });
|
|
181
|
+
return created;
|
|
159
182
|
});
|
|
160
183
|
// {{/if}}
|
|
161
184
|
// {{#if ORM=none}}
|
|
162
|
-
const
|
|
163
|
-
const record: LawfulBasisRecord = {
|
|
164
|
-
id: newId(),
|
|
165
|
-
activityName,
|
|
166
|
-
lawfulBasis,
|
|
167
|
-
justification,
|
|
168
|
-
dataCategories,
|
|
169
|
-
purposes,
|
|
170
|
-
assessedBy,
|
|
171
|
-
reviewDate: reviewDate ? new Date(reviewDate) : null,
|
|
172
|
-
createdAt: now,
|
|
173
|
-
updatedAt: now,
|
|
174
|
-
};
|
|
185
|
+
const record: BasisRow = { id: crypto.randomUUID(), ...data, tenantId: context.tenantId, createdAt: data.updatedAt, removedAt: null };
|
|
175
186
|
lawfulBasisStore.set(record.id, record);
|
|
176
|
-
auditLog.push({ id:
|
|
187
|
+
auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'created', entityId: record.id, performedBy: actorId, changes: auditChanges(data), at: data.updatedAt });
|
|
177
188
|
// {{/if}}
|
|
178
|
-
|
|
179
189
|
return NextResponse.json(record, { status: 201 });
|
|
180
190
|
}
|
|
181
191
|
|
|
182
|
-
// PUT /api/lawful-basis
|
|
183
192
|
export async function PUT(req: NextRequest) {
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
}
|
|
194
|
-
|
|
193
|
+
const { context, problem } = await staffContext(req);
|
|
194
|
+
if (problem) return NextResponse.json({ error: problem.error }, { status: problem.status });
|
|
195
|
+
const actorId = context.actorId as string;
|
|
196
|
+
const body: unknown = await req.json().catch(() => null);
|
|
197
|
+
if (!isRecord(body)) return NextResponse.json({ error: 'A JSON object is required' }, { status: 400 });
|
|
198
|
+
const unknown = rejectUnknownFields(body, ['id', 'activityName', 'lawfulBasis', 'justification', 'dataCategories', 'purposes', 'reviewDate']);
|
|
199
|
+
if (unknown) return NextResponse.json({ error: unknown }, { status: 400 });
|
|
200
|
+
const idResult = nonEmptyText(body.id, 'id', 200);
|
|
201
|
+
if (!idResult.ok) return NextResponse.json({ error: idResult.error }, { status: 400 });
|
|
202
|
+
if (!['activityName', 'lawfulBasis', 'justification', 'dataCategories', 'purposes', 'reviewDate'].some((field) => hasOwn(body, field))) return NextResponse.json({ error: 'Provide at least one allowlisted lawful-basis field to update' }, { status: 400 });
|
|
203
|
+
const id = idResult.value;
|
|
204
|
+
let validationError: string | null = null;
|
|
195
205
|
// {{#if ORM=prisma}}
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
where: { id },
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
await prisma.complianceAuditLog.create({
|
|
207
|
-
data: {
|
|
208
|
-
module: 'lawful-basis',
|
|
209
|
-
action: 'updated',
|
|
210
|
-
entityId: record.id,
|
|
211
|
-
entityType: 'LawfulBasisRecord',
|
|
212
|
-
changes: data,
|
|
213
|
-
},
|
|
206
|
+
const record = await prisma.$transaction(async (tx) => {
|
|
207
|
+
const existing = await tx.lawfulBasisRecord.findFirst({ where: { id, tenantId: context.tenantId, removedAt: null } });
|
|
208
|
+
if (!existing) return null;
|
|
209
|
+
const normalized = buildBasisMutation(body, actorId, existing);
|
|
210
|
+
if (!normalized.ok) { validationError = normalized.error; return null; }
|
|
211
|
+
const data = normalized.value;
|
|
212
|
+
await tx.lawfulBasisRecord.updateMany({ where: { id, tenantId: context.tenantId, removedAt: null }, data });
|
|
213
|
+
const updated = await tx.lawfulBasisRecord.findFirstOrThrow({ where: { id, tenantId: context.tenantId, removedAt: null } });
|
|
214
|
+
await tx.complianceAuditLog.create({ data: { tenantId: context.tenantId, module: 'lawful-basis', action: 'updated', entityId: id, entityType: 'LawfulBasisRecord', performedBy: actorId, changes: auditChanges(data) } });
|
|
215
|
+
return updated;
|
|
214
216
|
});
|
|
215
217
|
// {{/if}}
|
|
216
218
|
// {{#if ORM=drizzle}}
|
|
217
|
-
const
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
action: 'updated',
|
|
227
|
-
entityId: record.id,
|
|
228
|
-
entityType: 'LawfulBasisRecord',
|
|
229
|
-
changes: data,
|
|
219
|
+
const record = await db.transaction(async (tx) => {
|
|
220
|
+
const [existing] = await tx.select().from(lawfulBasisRecords).where(and(eq(lawfulBasisRecords.id, id), eq(lawfulBasisRecords.tenantId, context.tenantId), isNull(lawfulBasisRecords.removedAt))).limit(1);
|
|
221
|
+
if (!existing) return null;
|
|
222
|
+
const normalized = buildBasisMutation(body, actorId, existing);
|
|
223
|
+
if (!normalized.ok) { validationError = normalized.error; return null; }
|
|
224
|
+
const data = normalized.value;
|
|
225
|
+
const [updated] = await tx.update(lawfulBasisRecords).set(data).where(and(eq(lawfulBasisRecords.id, id), eq(lawfulBasisRecords.tenantId, context.tenantId), isNull(lawfulBasisRecords.removedAt))).returning();
|
|
226
|
+
await tx.insert(complianceAuditLog).values({ tenantId: context.tenantId, module: 'lawful-basis', action: 'updated', entityId: id, entityType: 'LawfulBasisRecord', performedBy: actorId, changes: auditChanges(data) });
|
|
227
|
+
return updated;
|
|
230
228
|
});
|
|
231
229
|
// {{/if}}
|
|
232
230
|
// {{#if ORM=none}}
|
|
233
231
|
const existing = lawfulBasisStore.get(id);
|
|
234
|
-
|
|
235
|
-
|
|
232
|
+
let record: BasisRow | null = null;
|
|
233
|
+
if (existing?.tenantId === context.tenantId && existing.removedAt === null) {
|
|
234
|
+
const normalized = buildBasisMutation(body, actorId, existing);
|
|
235
|
+
if (!normalized.ok) validationError = normalized.error;
|
|
236
|
+
else {
|
|
237
|
+
record = { ...existing, ...normalized.value, id };
|
|
238
|
+
lawfulBasisStore.set(id, record);
|
|
239
|
+
auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'updated', entityId: id, performedBy: actorId, changes: auditChanges(normalized.value), at: normalized.value.updatedAt });
|
|
240
|
+
}
|
|
236
241
|
}
|
|
237
|
-
const record: LawfulBasisRecord = { ...existing, ...data, id, updatedAt: new Date() };
|
|
238
|
-
lawfulBasisStore.set(id, record);
|
|
239
|
-
auditLog.push({ id: newId(), module: 'lawful-basis', action: 'updated', entityId: record.id, at: new Date() });
|
|
240
242
|
// {{/if}}
|
|
241
|
-
|
|
242
|
-
return NextResponse.json(record);
|
|
243
|
+
if (validationError) return NextResponse.json({ error: validationError }, { status: 400 });
|
|
244
|
+
return record ? NextResponse.json(record) : NextResponse.json({ error: 'Lawful-basis record not found' }, { status: 404 });
|
|
243
245
|
}
|
|
244
246
|
|
|
245
|
-
// DELETE /api/lawful-basis?id=xxx
|
|
246
247
|
export async function DELETE(req: NextRequest) {
|
|
248
|
+
const { context, problem } = await staffContext(req);
|
|
249
|
+
if (problem) return NextResponse.json({ error: problem.error }, { status: problem.status });
|
|
250
|
+
const actorId = context.actorId as string;
|
|
247
251
|
const id = req.nextUrl.searchParams.get('id');
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
return NextResponse.json({ error: 'id is required' }, { status: 400 });
|
|
251
|
-
}
|
|
252
|
-
|
|
252
|
+
if (!id?.trim()) return NextResponse.json({ error: 'id is required' }, { status: 400 });
|
|
253
|
+
const archivedAt = new Date();
|
|
253
254
|
// {{#if ORM=prisma}}
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
await prisma.complianceAuditLog.create({
|
|
262
|
-
data: {
|
|
263
|
-
module: 'lawful-basis',
|
|
264
|
-
action: 'deleted',
|
|
265
|
-
entityId: id,
|
|
266
|
-
entityType: 'LawfulBasisRecord',
|
|
267
|
-
changes: { activityName: existing.activityName },
|
|
268
|
-
},
|
|
255
|
+
const archived = await prisma.$transaction(async (tx) => {
|
|
256
|
+
const existing = await tx.lawfulBasisRecord.findFirst({ where: { id, tenantId: context.tenantId, removedAt: null } });
|
|
257
|
+
if (!existing) return false;
|
|
258
|
+
const result = await tx.lawfulBasisRecord.updateMany({ where: { id, tenantId: context.tenantId, removedAt: null }, data: { removedAt: archivedAt, updatedAt: archivedAt } });
|
|
259
|
+
if (result.count !== 1) return false;
|
|
260
|
+
await tx.complianceAuditLog.create({ data: { tenantId: context.tenantId, module: 'lawful-basis', action: 'archived', entityId: id, entityType: 'LawfulBasisRecord', performedBy: actorId, changes: { activityName: existing.activityName, removedAt: archivedAt.toISOString() } } });
|
|
261
|
+
return true;
|
|
269
262
|
});
|
|
270
263
|
// {{/if}}
|
|
271
264
|
// {{#if ORM=drizzle}}
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
await db.insert(complianceAuditLog).values({
|
|
280
|
-
module: 'lawful-basis',
|
|
281
|
-
action: 'deleted',
|
|
282
|
-
entityId: id,
|
|
283
|
-
entityType: 'LawfulBasisRecord',
|
|
284
|
-
changes: { activityName: existing.activityName },
|
|
265
|
+
const archived = await db.transaction(async (tx) => {
|
|
266
|
+
const [existing] = await tx.select().from(lawfulBasisRecords).where(and(eq(lawfulBasisRecords.id, id), eq(lawfulBasisRecords.tenantId, context.tenantId), isNull(lawfulBasisRecords.removedAt))).limit(1);
|
|
267
|
+
if (!existing) return false;
|
|
268
|
+
const [updated] = await tx.update(lawfulBasisRecords).set({ removedAt: archivedAt, updatedAt: archivedAt }).where(and(eq(lawfulBasisRecords.id, id), eq(lawfulBasisRecords.tenantId, context.tenantId), isNull(lawfulBasisRecords.removedAt))).returning();
|
|
269
|
+
if (!updated) return false;
|
|
270
|
+
await tx.insert(complianceAuditLog).values({ tenantId: context.tenantId, module: 'lawful-basis', action: 'archived', entityId: id, entityType: 'LawfulBasisRecord', performedBy: actorId, changes: { activityName: existing.activityName, removedAt: archivedAt.toISOString() } });
|
|
271
|
+
return true;
|
|
285
272
|
});
|
|
286
273
|
// {{/if}}
|
|
287
274
|
// {{#if ORM=none}}
|
|
288
275
|
const existing = lawfulBasisStore.get(id);
|
|
289
|
-
|
|
290
|
-
|
|
276
|
+
const archived = existing?.tenantId === context.tenantId && existing.removedAt === null;
|
|
277
|
+
if (archived && existing) {
|
|
278
|
+
lawfulBasisStore.set(id, { ...existing, removedAt: archivedAt, updatedAt: archivedAt });
|
|
279
|
+
auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'archived', entityId: id, performedBy: actorId, changes: { activityName: existing.activityName, removedAt: archivedAt.toISOString() }, at: archivedAt });
|
|
291
280
|
}
|
|
292
|
-
lawfulBasisStore.delete(id);
|
|
293
|
-
auditLog.push({ id: newId(), module: 'lawful-basis', action: 'deleted', entityId: id, at: new Date() });
|
|
294
281
|
// {{/if}}
|
|
295
|
-
|
|
296
|
-
return NextResponse.json({ success: true });
|
|
282
|
+
return archived ? NextResponse.json({ success: true, archived: true }) : NextResponse.json({ error: 'Lawful-basis record not found' }, { status: 404 });
|
|
297
283
|
}
|