@tantainnovative/create-ndpr 0.1.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -56
- package/bin/index.mjs +369 -394
- package/package.json +14 -4
- package/templates/drizzle-client.ts +44 -0
- package/templates/drizzle-schema.ts +224 -117
- package/templates/env-example +6 -3
- package/templates/express-breach-route.ts +98 -0
- package/templates/express-consent-route.ts +336 -79
- package/templates/express-cross-border-route.ts +152 -0
- package/templates/express-dpia-route.ts +332 -0
- package/templates/express-dsr-route.ts +76 -0
- package/templates/express-lawful-basis-route.ts +146 -0
- package/templates/express-request-context.ts +130 -0
- package/templates/github-ndpr-audit.yml +31 -0
- package/templates/ndpr-audit.json +14 -0
- package/templates/nextjs-breach-route.ts +145 -79
- package/templates/nextjs-consent-route.ts +343 -70
- package/templates/nextjs-cross-border-route.ts +314 -0
- package/templates/nextjs-dpia-route.ts +510 -0
- package/templates/nextjs-dsr-route.ts +93 -58
- package/templates/nextjs-lawful-basis-route.ts +283 -0
- package/templates/nextjs-layout.tsx +122 -50
- package/templates/nextjs-request-context.ts +137 -0
- package/templates/prisma-schema.prisma +121 -51
- package/templates/express-setup.ts +0 -250
|
@@ -1,80 +1,115 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Next.js App Router —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* NDPA Part IV §34–38 — rights to access, rectification, erasure,
|
|
6
|
-
* portability, and objection. All requests must be acknowledged
|
|
7
|
-
* within 72 hours and fulfilled within 30 days.
|
|
8
|
-
*
|
|
9
|
-
* Endpoints:
|
|
10
|
-
* GET /api/dsr — List DSR requests (optional ?status= filter)
|
|
11
|
-
* POST /api/dsr — Submit a new DSR request
|
|
2
|
+
* Next.js App Router — tenant-scoped data-subject request route for {{ORG_NAME_COMMENT}}.
|
|
3
|
+
* Submission requires a verified subject; listing requires verified staff.
|
|
12
4
|
*/
|
|
13
|
-
|
|
14
5
|
import { NextRequest, NextResponse } from 'next/server';
|
|
6
|
+
import { getNDPRContextProblem, resolveNDPRRequestContext } from '{{NDPR_CONTEXT_IMPORT}}';
|
|
7
|
+
// {{#if ORM=prisma}}
|
|
15
8
|
import { PrismaClient } from '@prisma/client';
|
|
16
|
-
|
|
17
9
|
const prisma = new PrismaClient();
|
|
10
|
+
// {{/if}}
|
|
11
|
+
// {{#if ORM=drizzle}}
|
|
12
|
+
import { db } from '{{NDPR_DB_IMPORT}}';
|
|
13
|
+
import { complianceAuditLog, dsrRequests } from '{{NDPR_SCHEMA_IMPORT}}';
|
|
14
|
+
import { and, desc, eq } from 'drizzle-orm';
|
|
15
|
+
// {{/if}}
|
|
16
|
+
// {{#if ORM=none}}
|
|
17
|
+
// DEVELOPMENT ONLY: replace both stores with one durable transactional store.
|
|
18
|
+
interface DSRRow {
|
|
19
|
+
id: string; tenantId: string; subjectId: string; type: string; status: string;
|
|
20
|
+
subjectName: string; subjectEmail: string; subjectPhone: string | null;
|
|
21
|
+
identifierType: string; identifierValue: string; description: string | null;
|
|
22
|
+
submittedAt: Date; dueAt: Date;
|
|
23
|
+
}
|
|
24
|
+
const dsrStore = new Map<string, DSRRow>();
|
|
25
|
+
const auditLog: Array<{ id: string; tenantId: string; action: string; entityId: string; performedBy: string | null; at: Date }> = [];
|
|
26
|
+
// {{/if}}
|
|
27
|
+
|
|
28
|
+
const REQUEST_TYPES = new Set(['access', 'rectification', 'erasure', 'portability', 'objection', 'restriction']);
|
|
18
29
|
|
|
19
|
-
// GET /api/dsr?status=pending
|
|
20
30
|
export async function GET(req: NextRequest) {
|
|
31
|
+
const context = await resolveNDPRRequestContext(req);
|
|
32
|
+
const problem = getNDPRContextProblem(context, 'staff');
|
|
33
|
+
if (problem) return NextResponse.json({ error: problem.error }, { status: problem.status });
|
|
21
34
|
const status = req.nextUrl.searchParams.get('status');
|
|
22
35
|
|
|
36
|
+
// {{#if ORM=prisma}}
|
|
23
37
|
const requests = await prisma.dSRRequest.findMany({
|
|
24
|
-
where: status ? { status } :
|
|
38
|
+
where: { tenantId: context.tenantId, ...(status ? { status } : {}) },
|
|
25
39
|
orderBy: { submittedAt: 'desc' },
|
|
26
40
|
});
|
|
27
|
-
|
|
41
|
+
// {{/if}}
|
|
42
|
+
// {{#if ORM=drizzle}}
|
|
43
|
+
const requests = status
|
|
44
|
+
? await db.select().from(dsrRequests).where(and(eq(dsrRequests.tenantId, context.tenantId), eq(dsrRequests.status, status))).orderBy(desc(dsrRequests.submittedAt))
|
|
45
|
+
: await db.select().from(dsrRequests).where(eq(dsrRequests.tenantId, context.tenantId)).orderBy(desc(dsrRequests.submittedAt));
|
|
46
|
+
// {{/if}}
|
|
47
|
+
// {{#if ORM=none}}
|
|
48
|
+
const requests = [...dsrStore.values()]
|
|
49
|
+
.filter((row) => row.tenantId === context.tenantId && (!status || row.status === status))
|
|
50
|
+
.sort((a, b) => b.submittedAt.getTime() - a.submittedAt.getTime());
|
|
51
|
+
// {{/if}}
|
|
28
52
|
return NextResponse.json(requests);
|
|
29
53
|
}
|
|
30
54
|
|
|
31
|
-
// POST /api/dsr
|
|
32
55
|
export async function POST(req: NextRequest) {
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (!type || !subjectName || !subjectEmail || !identifierType || !identifierValue) {
|
|
45
|
-
return NextResponse.json(
|
|
46
|
-
{ error: 'type, subjectName, subjectEmail, identifierType, and identifierValue are required' },
|
|
47
|
-
{ status: 400 },
|
|
48
|
-
);
|
|
56
|
+
const context = await resolveNDPRRequestContext(req);
|
|
57
|
+
const problem = getNDPRContextProblem(context, 'subject');
|
|
58
|
+
if (problem) return NextResponse.json({ error: problem.error }, { status: problem.status });
|
|
59
|
+
const subjectId = context.subjectId as string;
|
|
60
|
+
const body: unknown = await req.json().catch(() => null);
|
|
61
|
+
if (!body || typeof body !== 'object') return NextResponse.json({ error: 'A JSON object is required' }, { status: 400 });
|
|
62
|
+
const input = body as Record<string, unknown>;
|
|
63
|
+
const required = ['type', 'subjectName', 'subjectEmail', 'identifierType', 'identifierValue'] as const;
|
|
64
|
+
if (required.some((field) => typeof input[field] !== 'string' || !(input[field] as string).trim())) {
|
|
65
|
+
return NextResponse.json({ error: `${required.join(', ')} are required strings` }, { status: 400 });
|
|
49
66
|
}
|
|
67
|
+
if (!REQUEST_TYPES.has(input.type as string)) {
|
|
68
|
+
return NextResponse.json({ error: `type must be one of: ${[...REQUEST_TYPES].join(', ')}` }, { status: 400 });
|
|
69
|
+
}
|
|
70
|
+
const submittedAt = new Date();
|
|
71
|
+
// Operational target; confirm and configure this deadline for your applicable obligations.
|
|
72
|
+
const dueAt = new Date(submittedAt.getTime() + 30 * 24 * 60 * 60 * 1000);
|
|
73
|
+
const data = {
|
|
74
|
+
tenantId: context.tenantId,
|
|
75
|
+
subjectId,
|
|
76
|
+
type: input.type as string,
|
|
77
|
+
subjectName: input.subjectName as string,
|
|
78
|
+
subjectEmail: input.subjectEmail as string,
|
|
79
|
+
subjectPhone: typeof input.subjectPhone === 'string' ? input.subjectPhone : null,
|
|
80
|
+
identifierType: input.identifierType as string,
|
|
81
|
+
identifierValue: input.identifierValue as string,
|
|
82
|
+
description: typeof input.description === 'string' ? input.description : null,
|
|
83
|
+
status: 'pending',
|
|
84
|
+
dueAt,
|
|
85
|
+
};
|
|
50
86
|
|
|
51
|
-
//
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
type,
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
subjectPhone: subjectPhone ?? null,
|
|
61
|
-
identifierType,
|
|
62
|
-
identifierValue,
|
|
63
|
-
description: description ?? null,
|
|
64
|
-
status: 'pending',
|
|
65
|
-
dueAt,
|
|
66
|
-
},
|
|
87
|
+
// {{#if ORM=prisma}}
|
|
88
|
+
const request = await prisma.$transaction(async (tx) => {
|
|
89
|
+
const created = await tx.dSRRequest.create({ data });
|
|
90
|
+
await tx.complianceAuditLog.create({ data: {
|
|
91
|
+
tenantId: context.tenantId, module: 'dsr', action: 'submitted',
|
|
92
|
+
entityId: created.id, entityType: 'DSRRequest', performedBy: context.actorId,
|
|
93
|
+
changes: { type: data.type, subjectId, status: 'pending' },
|
|
94
|
+
} });
|
|
95
|
+
return created;
|
|
67
96
|
});
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
entityType: 'DSRRequest',
|
|
75
|
-
changes: { type,
|
|
76
|
-
}
|
|
97
|
+
// {{/if}}
|
|
98
|
+
// {{#if ORM=drizzle}}
|
|
99
|
+
const request = await db.transaction(async (tx) => {
|
|
100
|
+
const [created] = await tx.insert(dsrRequests).values(data).returning();
|
|
101
|
+
await tx.insert(complianceAuditLog).values({
|
|
102
|
+
tenantId: context.tenantId, module: 'dsr', action: 'submitted',
|
|
103
|
+
entityId: created.id, entityType: 'DSRRequest', performedBy: context.actorId,
|
|
104
|
+
changes: { type: data.type, subjectId, status: 'pending' },
|
|
105
|
+
});
|
|
106
|
+
return created;
|
|
77
107
|
});
|
|
78
|
-
|
|
108
|
+
// {{/if}}
|
|
109
|
+
// {{#if ORM=none}}
|
|
110
|
+
const request: DSRRow = { id: crypto.randomUUID(), ...data, submittedAt };
|
|
111
|
+
dsrStore.set(request.id, request);
|
|
112
|
+
auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'submitted', entityId: request.id, performedBy: context.actorId, at: submittedAt });
|
|
113
|
+
// {{/if}}
|
|
79
114
|
return NextResponse.json(request, { status: 201 });
|
|
80
115
|
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/** Next.js App Router — staff-only, tenant-scoped lawful-basis register for {{ORG_NAME_COMMENT}}. */
|
|
2
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
3
|
+
import { getNDPRContextProblem, resolveNDPRRequestContext } from '{{NDPR_CONTEXT_IMPORT}}';
|
|
4
|
+
// {{#if ORM=prisma}}
|
|
5
|
+
import { PrismaClient } from '@prisma/client';
|
|
6
|
+
const prisma = new PrismaClient();
|
|
7
|
+
// {{/if}}
|
|
8
|
+
// {{#if ORM=drizzle}}
|
|
9
|
+
import { db } from '{{NDPR_DB_IMPORT}}';
|
|
10
|
+
import { complianceAuditLog, lawfulBasisRecords } from '{{NDPR_SCHEMA_IMPORT}}';
|
|
11
|
+
import { and, desc, eq, isNull } from 'drizzle-orm';
|
|
12
|
+
// {{/if}}
|
|
13
|
+
|
|
14
|
+
const LAWFUL_BASES = ['consent', 'contract', 'legal_obligation', 'vital_interests', 'public_interest', 'legitimate_interests'] as const;
|
|
15
|
+
type LawfulBasis = (typeof LAWFUL_BASES)[number];
|
|
16
|
+
type Validation<T> = { ok: true; value: T } | { ok: false; error: string };
|
|
17
|
+
interface BasisExisting {
|
|
18
|
+
activityName: string;
|
|
19
|
+
lawfulBasis: string;
|
|
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;
|
|
29
|
+
dataCategories: string[];
|
|
30
|
+
purposes: string[];
|
|
31
|
+
assessedBy: string;
|
|
32
|
+
assessedAt: Date;
|
|
33
|
+
reviewDate: Date | null;
|
|
34
|
+
updatedAt: Date;
|
|
35
|
+
}
|
|
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 }> = [];
|
|
42
|
+
// {{/if}}
|
|
43
|
+
|
|
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
|
+
|
|
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 });
|
|
128
|
+
const id = req.nextUrl.searchParams.get('id');
|
|
129
|
+
if (id) {
|
|
130
|
+
// {{#if ORM=prisma}}
|
|
131
|
+
const record = await prisma.lawfulBasisRecord.findFirst({ where: { id, tenantId: context.tenantId, removedAt: null } });
|
|
132
|
+
// {{/if}}
|
|
133
|
+
// {{#if ORM=drizzle}}
|
|
134
|
+
const [record] = await db.select().from(lawfulBasisRecords).where(and(eq(lawfulBasisRecords.id, id), eq(lawfulBasisRecords.tenantId, context.tenantId), isNull(lawfulBasisRecords.removedAt))).limit(1);
|
|
135
|
+
// {{/if}}
|
|
136
|
+
// {{#if ORM=none}}
|
|
137
|
+
const candidate = lawfulBasisStore.get(id);
|
|
138
|
+
const record = candidate?.tenantId === context.tenantId && candidate.removedAt === null ? candidate : null;
|
|
139
|
+
// {{/if}}
|
|
140
|
+
return record ? NextResponse.json(record) : NextResponse.json({ error: 'Lawful-basis record not found' }, { status: 404 });
|
|
141
|
+
}
|
|
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;
|
|
145
|
+
// {{#if ORM=prisma}}
|
|
146
|
+
const records = await prisma.lawfulBasisRecord.findMany({ where: { tenantId: context.tenantId, removedAt: null, ...(basis ? { lawfulBasis: basis } : {}) }, orderBy: { createdAt: 'desc' } });
|
|
147
|
+
// {{/if}}
|
|
148
|
+
// {{#if ORM=drizzle}}
|
|
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));
|
|
152
|
+
// {{/if}}
|
|
153
|
+
// {{#if ORM=none}}
|
|
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());
|
|
155
|
+
// {{/if}}
|
|
156
|
+
return NextResponse.json(records);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function POST(req: NextRequest) {
|
|
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;
|
|
170
|
+
// {{#if ORM=prisma}}
|
|
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;
|
|
175
|
+
});
|
|
176
|
+
// {{/if}}
|
|
177
|
+
// {{#if ORM=drizzle}}
|
|
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;
|
|
182
|
+
});
|
|
183
|
+
// {{/if}}
|
|
184
|
+
// {{#if ORM=none}}
|
|
185
|
+
const record: BasisRow = { id: crypto.randomUUID(), ...data, tenantId: context.tenantId, createdAt: data.updatedAt, removedAt: null };
|
|
186
|
+
lawfulBasisStore.set(record.id, record);
|
|
187
|
+
auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'created', entityId: record.id, performedBy: actorId, changes: auditChanges(data), at: data.updatedAt });
|
|
188
|
+
// {{/if}}
|
|
189
|
+
return NextResponse.json(record, { status: 201 });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function PUT(req: NextRequest) {
|
|
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;
|
|
205
|
+
// {{#if ORM=prisma}}
|
|
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;
|
|
216
|
+
});
|
|
217
|
+
// {{/if}}
|
|
218
|
+
// {{#if ORM=drizzle}}
|
|
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;
|
|
228
|
+
});
|
|
229
|
+
// {{/if}}
|
|
230
|
+
// {{#if ORM=none}}
|
|
231
|
+
const existing = lawfulBasisStore.get(id);
|
|
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
|
+
}
|
|
241
|
+
}
|
|
242
|
+
// {{/if}}
|
|
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 });
|
|
245
|
+
}
|
|
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;
|
|
251
|
+
const id = req.nextUrl.searchParams.get('id');
|
|
252
|
+
if (!id?.trim()) return NextResponse.json({ error: 'id is required' }, { status: 400 });
|
|
253
|
+
const archivedAt = new Date();
|
|
254
|
+
// {{#if ORM=prisma}}
|
|
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;
|
|
262
|
+
});
|
|
263
|
+
// {{/if}}
|
|
264
|
+
// {{#if ORM=drizzle}}
|
|
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;
|
|
272
|
+
});
|
|
273
|
+
// {{/if}}
|
|
274
|
+
// {{#if ORM=none}}
|
|
275
|
+
const existing = lawfulBasisStore.get(id);
|
|
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 });
|
|
280
|
+
}
|
|
281
|
+
// {{/if}}
|
|
282
|
+
return archived ? NextResponse.json({ success: true, archived: true }) : NextResponse.json({ error: 'Lawful-basis record not found' }, { status: 404 });
|
|
283
|
+
}
|
|
@@ -1,64 +1,136 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
*
|
|
6
|
-
* Wraps your app with the NDPA-compliant consent banner and provider.
|
|
7
|
-
*
|
|
8
|
-
* Usage in app/layout.tsx:
|
|
9
|
-
*
|
|
10
|
-
* import NDPRLayout from '@/app/ndpr-layout';
|
|
11
|
-
*
|
|
12
|
-
* export default async function RootLayout({ children }) {
|
|
13
|
-
* const session = await getServerSession();
|
|
14
|
-
* return (
|
|
15
|
-
* <html lang="en">
|
|
16
|
-
* <body>
|
|
17
|
-
* <NDPRLayout userId={session?.user?.id}>
|
|
18
|
-
* {children}
|
|
19
|
-
* </NDPRLayout>
|
|
20
|
-
* </body>
|
|
21
|
-
* </html>
|
|
22
|
-
* );
|
|
23
|
-
* }
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import React from 'react';
|
|
27
|
-
import { NDPRProvider } from '@tantainnovative/ndpr-toolkit/core';
|
|
3
|
+
import React, { useEffect, useMemo, useState } from 'react';
|
|
4
|
+
import { NDPRProvider } from '@tantainnovative/ndpr-toolkit';
|
|
28
5
|
import { NDPRConsent } from '@tantainnovative/ndpr-toolkit/presets';
|
|
29
6
|
import { apiAdapter } from '@tantainnovative/ndpr-toolkit/adapters';
|
|
7
|
+
import type {
|
|
8
|
+
StorageAdapter,
|
|
9
|
+
ConsentSettings,
|
|
10
|
+
} from '@tantainnovative/ndpr-toolkit/core';
|
|
11
|
+
|
|
12
|
+
const SUBJECT_STORAGE_KEY = 'ndpr_anonymous_subject_id';
|
|
13
|
+
const SUBJECT_COOKIE = 'ndpr_subject_id';
|
|
14
|
+
const ANONYMOUS_SUBJECT_PATTERN =
|
|
15
|
+
/^anon_[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
16
|
+
|
|
17
|
+
interface StoredConsent {
|
|
18
|
+
consents: Record<string, boolean>;
|
|
19
|
+
version: string;
|
|
20
|
+
method: string;
|
|
21
|
+
lawfulBasis?: ConsentSettings['lawfulBasis'] | null;
|
|
22
|
+
hasInteracted?: boolean;
|
|
23
|
+
createdAt: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getOrCreateAnonymousSubjectId(): string {
|
|
27
|
+
try {
|
|
28
|
+
const stored = window.localStorage.getItem(SUBJECT_STORAGE_KEY);
|
|
29
|
+
if (stored && ANONYMOUS_SUBJECT_PATTERN.test(stored)) return stored;
|
|
30
|
+
} catch {
|
|
31
|
+
// Storage can be disabled; the first-party cookie remains a fallback.
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const cookie = document.cookie
|
|
35
|
+
.split(';')
|
|
36
|
+
.map((part) => part.trim())
|
|
37
|
+
.find((part) => part.startsWith(`${SUBJECT_COOKIE}=`));
|
|
38
|
+
if (cookie) {
|
|
39
|
+
try {
|
|
40
|
+
const existing = decodeURIComponent(cookie.slice(cookie.indexOf('=') + 1));
|
|
41
|
+
if (ANONYMOUS_SUBJECT_PATTERN.test(existing)) return existing;
|
|
42
|
+
} catch {
|
|
43
|
+
// Ignore malformed percent encoding and replace the invalid cookie below.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
30
46
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
47
|
+
const subjectId = `anon_${crypto.randomUUID()}`;
|
|
48
|
+
try {
|
|
49
|
+
window.localStorage.setItem(SUBJECT_STORAGE_KEY, subjectId);
|
|
50
|
+
} catch {
|
|
51
|
+
// Cookie persistence below may still succeed.
|
|
52
|
+
}
|
|
53
|
+
document.cookie = [
|
|
54
|
+
`${SUBJECT_COOKIE}=${encodeURIComponent(subjectId)}`,
|
|
55
|
+
'path=/',
|
|
56
|
+
'max-age=31536000',
|
|
57
|
+
'samesite=lax',
|
|
58
|
+
location.protocol === 'https:' ? 'secure' : '',
|
|
59
|
+
].filter(Boolean).join('; ');
|
|
60
|
+
return subjectId;
|
|
39
61
|
}
|
|
40
62
|
|
|
41
|
-
|
|
42
|
-
|
|
63
|
+
function createConsentAdapter(
|
|
64
|
+
subjectId: string,
|
|
65
|
+
): StorageAdapter<ConsentSettings> {
|
|
66
|
+
const remote = apiAdapter<StoredConsent | ConsentSettings>('/api/consent', {
|
|
67
|
+
credentials: 'same-origin',
|
|
68
|
+
headers: { 'X-NDPR-Subject-Id': subjectId },
|
|
69
|
+
idempotencyKey: ({ method, payload }) => {
|
|
70
|
+
const timestamp = payload && 'timestamp' in payload
|
|
71
|
+
? payload.timestamp
|
|
72
|
+
: crypto.randomUUID();
|
|
73
|
+
return `${subjectId}:${method}:${timestamp}`;
|
|
74
|
+
},
|
|
75
|
+
});
|
|
43
76
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
77
|
+
return {
|
|
78
|
+
capabilities: remote.capabilities,
|
|
79
|
+
async load() {
|
|
80
|
+
const record = await remote.load();
|
|
81
|
+
if (!record) return null;
|
|
82
|
+
if (
|
|
83
|
+
'timestamp' in record
|
|
84
|
+
&& typeof record.timestamp === 'number'
|
|
85
|
+
&& Number.isSafeInteger(record.timestamp)
|
|
86
|
+
) {
|
|
87
|
+
return record;
|
|
88
|
+
}
|
|
89
|
+
if (!('createdAt' in record)) return null;
|
|
90
|
+
const timestamp = Date.parse(record.createdAt);
|
|
91
|
+
if (!Number.isFinite(timestamp)) return null;
|
|
92
|
+
return {
|
|
93
|
+
consents: record.consents,
|
|
94
|
+
version: record.version,
|
|
95
|
+
method: record.method,
|
|
96
|
+
lawfulBasis: record.lawfulBasis ?? undefined,
|
|
97
|
+
timestamp,
|
|
98
|
+
hasInteracted: record.hasInteracted !== false,
|
|
99
|
+
};
|
|
100
|
+
},
|
|
101
|
+
save(settings) {
|
|
102
|
+
return remote.save(settings);
|
|
103
|
+
},
|
|
104
|
+
remove() {
|
|
105
|
+
return remote.remove();
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
interface NDPRClientProviderProps { children: React.ReactNode }
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Client boundary for {{ORG_NAME_COMMENT}}. Authenticated identity is resolved by
|
|
114
|
+
* the server-side request-context hook; never pass account IDs from the
|
|
115
|
+
* browser as trusted identity.
|
|
116
|
+
*/
|
|
117
|
+
export default function NDPRClientProvider({ children }: NDPRClientProviderProps) {
|
|
118
|
+
const [anonymousSubjectId, setAnonymousSubjectId] = useState<string | null>(null);
|
|
119
|
+
useEffect(() => {
|
|
120
|
+
setAnonymousSubjectId(getOrCreateAnonymousSubjectId());
|
|
121
|
+
}, []);
|
|
122
|
+
|
|
123
|
+
const consentAdapter = useMemo(
|
|
124
|
+
() => anonymousSubjectId
|
|
125
|
+
? createConsentAdapter(anonymousSubjectId)
|
|
126
|
+
: null,
|
|
127
|
+
[anonymousSubjectId],
|
|
128
|
+
);
|
|
48
129
|
|
|
49
130
|
return (
|
|
50
|
-
<NDPRProvider
|
|
51
|
-
organizationName="{{ORG_NAME}}"
|
|
52
|
-
dpoEmail="{{DPO_EMAIL}}"
|
|
53
|
-
>
|
|
131
|
+
<NDPRProvider organizationName={`{{ORG_NAME_TEMPLATE}}`} dpoEmail={`{{DPO_EMAIL_TEMPLATE}}`}>
|
|
54
132
|
{children}
|
|
55
|
-
|
|
56
|
-
{/*
|
|
57
|
-
* NDPA-compliant consent banner.
|
|
58
|
-
* The adapter persists decisions server-side so the banner is hidden
|
|
59
|
-
* on return visits without flicker or double-prompting.
|
|
60
|
-
*/}
|
|
61
|
-
<NDPRConsent adapter={consentStorageAdapter} />
|
|
133
|
+
{consentAdapter ? <NDPRConsent adapter={consentAdapter} /> : null}
|
|
62
134
|
</NDPRProvider>
|
|
63
135
|
);
|
|
64
136
|
}
|