@tantainnovative/ndpr-recipes 0.1.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 +408 -0
- package/package.json +25 -0
- package/prisma/schema.prisma +104 -0
- package/src/adapters/drizzle-consent.ts +135 -0
- package/src/adapters/drizzle-dsr.ts +190 -0
- package/src/adapters/index.ts +39 -0
- package/src/adapters/prisma-breach.ts +181 -0
- package/src/adapters/prisma-consent.ts +129 -0
- package/src/adapters/prisma-dsr.ts +163 -0
- package/src/adapters/prisma-ropa.ts +205 -0
- package/src/drizzle/schema.ts +484 -0
- package/src/express/index.ts +82 -0
- package/src/express/middleware/consent-check.ts +133 -0
- package/src/express/routes/breach.ts +259 -0
- package/src/express/routes/compliance.ts +130 -0
- package/src/express/routes/consent.ts +163 -0
- package/src/express/routes/dsr.ts +203 -0
- package/src/express/routes/ropa.ts +225 -0
- package/src/nextjs/app-router/api/breach/[id]/route.ts +121 -0
- package/src/nextjs/app-router/api/breach/route.ts +182 -0
- package/src/nextjs/app-router/api/compliance/route.ts +162 -0
- package/src/nextjs/app-router/api/consent/route.ts +161 -0
- package/src/nextjs/app-router/api/dsr/[id]/route.ts +115 -0
- package/src/nextjs/app-router/api/dsr/route.ts +128 -0
- package/src/nextjs/app-router/api/ropa/route.ts +234 -0
- package/src/nextjs/app-router/layout-example.tsx +178 -0
- package/src/nextjs/app-router/middleware.ts +113 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js App Router — Breach Notification List/Create Route
|
|
3
|
+
*
|
|
4
|
+
* Handles listing and creating data breach reports as required by NDPA
|
|
5
|
+
* Section 40, which mandates that controllers notify the NDPC within 72 hours
|
|
6
|
+
* of discovering a breach that poses a risk to data subject rights and freedoms.
|
|
7
|
+
*
|
|
8
|
+
* Endpoints
|
|
9
|
+
* ---------
|
|
10
|
+
* GET /api/breach — List breach reports (optional ?status= filter)
|
|
11
|
+
* POST /api/breach — Create a new breach report
|
|
12
|
+
*
|
|
13
|
+
* How to use
|
|
14
|
+
* ----------
|
|
15
|
+
* Copy this file to `app/api/breach/route.ts` in your Next.js project.
|
|
16
|
+
* For single-report operations see `app/api/breach/[id]/route.ts`.
|
|
17
|
+
*
|
|
18
|
+
* @module breach/route
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
22
|
+
import { PrismaClient } from '@prisma/client';
|
|
23
|
+
|
|
24
|
+
const prisma = new PrismaClient();
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Severity auto-calculation helper
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Derive an initial severity rating from the breach category and the number
|
|
32
|
+
* of estimated affected subjects. This is a heuristic — the DPO should review
|
|
33
|
+
* and adjust before the NDPC notification is sent.
|
|
34
|
+
*
|
|
35
|
+
* @param category - Breach category string (e.g. 'unauthorized_access')
|
|
36
|
+
* @param estimatedAffected - Approximate number of affected data subjects
|
|
37
|
+
* @returns 'critical' | 'high' | 'medium' | 'low'
|
|
38
|
+
*/
|
|
39
|
+
function calculateSeverity(
|
|
40
|
+
category: string,
|
|
41
|
+
estimatedAffected?: number,
|
|
42
|
+
): 'critical' | 'high' | 'medium' | 'low' {
|
|
43
|
+
const highRiskCategories = [
|
|
44
|
+
'unauthorized_access',
|
|
45
|
+
'ransomware',
|
|
46
|
+
'data_exfiltration',
|
|
47
|
+
'identity_theft',
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
if (highRiskCategories.includes(category)) {
|
|
51
|
+
if ((estimatedAffected ?? 0) > 1000) return 'critical';
|
|
52
|
+
return 'high';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if ((estimatedAffected ?? 0) > 500) return 'high';
|
|
56
|
+
if ((estimatedAffected ?? 0) > 50) return 'medium';
|
|
57
|
+
return 'low';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// GET /api/breach?status=ongoing
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* List all breach reports, optionally filtered by status.
|
|
66
|
+
*
|
|
67
|
+
* Supports the DPO dashboard view. Returns reports ordered by reportedAt
|
|
68
|
+
* descending so the most recent (and likely most urgent) items appear first.
|
|
69
|
+
* Combine with the ?status=ongoing filter to see active incidents.
|
|
70
|
+
*
|
|
71
|
+
* Query params:
|
|
72
|
+
* status (optional) — ongoing | investigating | resolved | closed
|
|
73
|
+
*
|
|
74
|
+
* Returns 200 with an array of BreachReport rows.
|
|
75
|
+
*/
|
|
76
|
+
export async function GET(req: NextRequest) {
|
|
77
|
+
const status = req.nextUrl.searchParams.get('status');
|
|
78
|
+
|
|
79
|
+
const reports = await prisma.breachReport.findMany({
|
|
80
|
+
where: status ? { status } : undefined,
|
|
81
|
+
orderBy: { reportedAt: 'desc' },
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return NextResponse.json(reports);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// POST /api/breach
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Create a new data breach report.
|
|
93
|
+
*
|
|
94
|
+
* The NDPA Section 40 72-hour notification window begins from the moment a
|
|
95
|
+
* breach is discovered. This endpoint captures the initial report and
|
|
96
|
+
* auto-calculates an initial severity so the DPO can prioritise accordingly.
|
|
97
|
+
*
|
|
98
|
+
* Body (JSON):
|
|
99
|
+
* title (required) — short descriptive title
|
|
100
|
+
* description (required) — detailed description of the incident
|
|
101
|
+
* category (required) — breach category (e.g. 'unauthorized_access')
|
|
102
|
+
* discoveredAt (required) — ISO timestamp when breach was discovered
|
|
103
|
+
* reporterName (required) — name of the person filing the report
|
|
104
|
+
* reporterEmail (required) — reporter's email address
|
|
105
|
+
* affectedSystems (required) — array of system/service names affected
|
|
106
|
+
* dataTypes (required) — array of data type labels (e.g. ['PII', 'Financial'])
|
|
107
|
+
* reporterDepartment (optional) — reporter's department
|
|
108
|
+
* occurredAt (optional) — ISO timestamp when breach occurred (if known)
|
|
109
|
+
* estimatedAffected (optional) — approximate number of affected data subjects
|
|
110
|
+
* initialActions (optional) — immediate containment actions already taken
|
|
111
|
+
*
|
|
112
|
+
* Returns 201 with the newly created BreachReport row including auto-severity.
|
|
113
|
+
*/
|
|
114
|
+
export async function POST(req: NextRequest) {
|
|
115
|
+
const body = await req.json();
|
|
116
|
+
const {
|
|
117
|
+
title,
|
|
118
|
+
description,
|
|
119
|
+
category,
|
|
120
|
+
discoveredAt,
|
|
121
|
+
occurredAt,
|
|
122
|
+
reporterName,
|
|
123
|
+
reporterEmail,
|
|
124
|
+
reporterDepartment,
|
|
125
|
+
affectedSystems,
|
|
126
|
+
dataTypes,
|
|
127
|
+
estimatedAffected,
|
|
128
|
+
initialActions,
|
|
129
|
+
} = body;
|
|
130
|
+
|
|
131
|
+
if (!title || !description || !category || !discoveredAt || !reporterName || !reporterEmail) {
|
|
132
|
+
return NextResponse.json(
|
|
133
|
+
{
|
|
134
|
+
error:
|
|
135
|
+
'title, description, category, discoveredAt, reporterName, and reporterEmail are required',
|
|
136
|
+
},
|
|
137
|
+
{ status: 400 },
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!Array.isArray(affectedSystems) || !Array.isArray(dataTypes)) {
|
|
142
|
+
return NextResponse.json(
|
|
143
|
+
{ error: 'affectedSystems and dataTypes must be arrays' },
|
|
144
|
+
{ status: 400 },
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Auto-calculate severity from category and scale of impact.
|
|
149
|
+
const severity = calculateSeverity(category, estimatedAffected);
|
|
150
|
+
|
|
151
|
+
const report = await prisma.breachReport.create({
|
|
152
|
+
data: {
|
|
153
|
+
title,
|
|
154
|
+
description,
|
|
155
|
+
category,
|
|
156
|
+
severity,
|
|
157
|
+
status: 'ongoing',
|
|
158
|
+
discoveredAt: new Date(discoveredAt),
|
|
159
|
+
occurredAt: occurredAt ? new Date(occurredAt) : null,
|
|
160
|
+
reporterName,
|
|
161
|
+
reporterEmail,
|
|
162
|
+
reporterDepartment: reporterDepartment ?? null,
|
|
163
|
+
affectedSystems,
|
|
164
|
+
dataTypes,
|
|
165
|
+
estimatedAffected: estimatedAffected ?? null,
|
|
166
|
+
initialActions: initialActions ?? null,
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// Audit log — breach report creation is a high-significance event.
|
|
171
|
+
await prisma.complianceAuditLog.create({
|
|
172
|
+
data: {
|
|
173
|
+
module: 'breach',
|
|
174
|
+
action: 'reported',
|
|
175
|
+
entityId: report.id,
|
|
176
|
+
entityType: 'BreachReport',
|
|
177
|
+
changes: { title, category, severity, status: 'ongoing' },
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return NextResponse.json(report, { status: 201 });
|
|
182
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js App Router — Compliance Score Route
|
|
3
|
+
*
|
|
4
|
+
* Returns an overall NDPA compliance score by reading the current state of
|
|
5
|
+
* all compliance-related tables. The score can be displayed on a DPO dashboard
|
|
6
|
+
* to surface gaps and track improvement over time.
|
|
7
|
+
*
|
|
8
|
+
* The score is calculated across five pillars (each 0–100):
|
|
9
|
+
* - Consent (NDPA Section 25) — % of subjects with active consent
|
|
10
|
+
* - DSR (NDPA Sections 34–38) — % of requests resolved on time
|
|
11
|
+
* - Breach (NDPA Section 40) — % of incidents with NDPC notified
|
|
12
|
+
* - ROPA (NDPA Section 2 / general accountability) — active records exist
|
|
13
|
+
* - Audit trail (NDPA Section 44) — recent audit activity
|
|
14
|
+
*
|
|
15
|
+
* Endpoints
|
|
16
|
+
* ---------
|
|
17
|
+
* GET /api/compliance — Returns the current compliance score object
|
|
18
|
+
*
|
|
19
|
+
* How to use
|
|
20
|
+
* ----------
|
|
21
|
+
* Copy this file to `app/api/compliance/route.ts` in your Next.js project.
|
|
22
|
+
*
|
|
23
|
+
* @module compliance/route
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { NextResponse } from 'next/server';
|
|
27
|
+
import { PrismaClient } from '@prisma/client';
|
|
28
|
+
|
|
29
|
+
const prisma = new PrismaClient();
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Score calculation helpers
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Calculate the consent pillar score.
|
|
37
|
+
* Score = 100 if any active consent records exist; penalised otherwise.
|
|
38
|
+
*/
|
|
39
|
+
async function consentScore(): Promise<number> {
|
|
40
|
+
const total = await prisma.consentRecord.count({ where: { revokedAt: null } });
|
|
41
|
+
return total > 0 ? 100 : 0;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Calculate the DSR pillar score.
|
|
46
|
+
* Score = percentage of all DSR requests that were completed within the 30-day window.
|
|
47
|
+
* A score of 100 means all completed requests met the statutory deadline.
|
|
48
|
+
*/
|
|
49
|
+
async function dsrScore(): Promise<number> {
|
|
50
|
+
const total = await prisma.dSRRequest.count();
|
|
51
|
+
if (total === 0) return 100; // No requests yet — full marks (no violations)
|
|
52
|
+
|
|
53
|
+
const onTime = await prisma.dSRRequest.count({
|
|
54
|
+
where: {
|
|
55
|
+
status: 'completed',
|
|
56
|
+
completedAt: { not: null },
|
|
57
|
+
// completedAt <= dueAt
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Fall back to counting completed vs total when date comparison isn't trivial in Prisma.
|
|
62
|
+
const completed = await prisma.dSRRequest.count({ where: { status: 'completed' } });
|
|
63
|
+
const overdue = await prisma.dSRRequest.count({
|
|
64
|
+
where: {
|
|
65
|
+
status: { in: ['pending', 'in_progress'] },
|
|
66
|
+
dueAt: { lt: new Date() },
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const violationRate = total > 0 ? overdue / total : 0;
|
|
71
|
+
return Math.max(0, Math.round((1 - violationRate) * 100));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Calculate the breach pillar score.
|
|
76
|
+
* Score = percentage of breach reports that have been NDPC-notified.
|
|
77
|
+
* New breaches (< 72 hours old) are excluded from the penalty calculation.
|
|
78
|
+
*/
|
|
79
|
+
async function breachScore(): Promise<number> {
|
|
80
|
+
const cutoff = new Date(Date.now() - 72 * 60 * 60 * 1000); // 72 hours ago
|
|
81
|
+
|
|
82
|
+
const totalMature = await prisma.breachReport.count({
|
|
83
|
+
where: { reportedAt: { lt: cutoff } },
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
if (totalMature === 0) return 100; // No mature incidents — full marks
|
|
87
|
+
|
|
88
|
+
const notified = await prisma.breachReport.count({
|
|
89
|
+
where: {
|
|
90
|
+
reportedAt: { lt: cutoff },
|
|
91
|
+
ndpcNotificationSent: true,
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return Math.round((notified / totalMature) * 100);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Calculate the ROPA pillar score.
|
|
100
|
+
* Score = 100 if at least one active processing record exists; 0 otherwise.
|
|
101
|
+
* NDPA accountability principle requires controllers to maintain a ROPA.
|
|
102
|
+
*/
|
|
103
|
+
async function ropaScore(): Promise<number> {
|
|
104
|
+
const activeRecords = await prisma.processingRecord.count({ where: { status: 'active' } });
|
|
105
|
+
return activeRecords > 0 ? 100 : 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Calculate the audit trail pillar score.
|
|
110
|
+
* Score = 100 if there has been audit activity in the last 30 days; 0 otherwise.
|
|
111
|
+
*/
|
|
112
|
+
async function auditScore(): Promise<number> {
|
|
113
|
+
const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
|
|
114
|
+
const recentLogs = await prisma.complianceAuditLog.count({
|
|
115
|
+
where: { createdAt: { gte: thirtyDaysAgo } },
|
|
116
|
+
});
|
|
117
|
+
return recentLogs > 0 ? 100 : 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// GET /api/compliance
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Return the overall NDPA compliance score and per-pillar breakdown.
|
|
126
|
+
*
|
|
127
|
+
* The overall score is the arithmetic mean of all five pillar scores.
|
|
128
|
+
* Scores are integers from 0–100, where 100 = fully compliant.
|
|
129
|
+
*
|
|
130
|
+
* Returns 200 with:
|
|
131
|
+
* {
|
|
132
|
+
* overall: number, — aggregate score (0–100)
|
|
133
|
+
* pillars: {
|
|
134
|
+
* consent: number,
|
|
135
|
+
* dsr: number,
|
|
136
|
+
* breach: number,
|
|
137
|
+
* ropa: number,
|
|
138
|
+
* audit: number,
|
|
139
|
+
* },
|
|
140
|
+
* calculatedAt: string, — ISO timestamp
|
|
141
|
+
* }
|
|
142
|
+
*/
|
|
143
|
+
export async function GET() {
|
|
144
|
+
const [consent, dsr, breach, ropa, audit] = await Promise.all([
|
|
145
|
+
consentScore(),
|
|
146
|
+
dsrScore(),
|
|
147
|
+
breachScore(),
|
|
148
|
+
ropaScore(),
|
|
149
|
+
auditScore(),
|
|
150
|
+
]);
|
|
151
|
+
|
|
152
|
+
const pillars = { consent, dsr, breach, ropa, audit };
|
|
153
|
+
const overall = Math.round(
|
|
154
|
+
Object.values(pillars).reduce((sum, v) => sum + v, 0) / Object.keys(pillars).length,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
return NextResponse.json({
|
|
158
|
+
overall,
|
|
159
|
+
pillars,
|
|
160
|
+
calculatedAt: new Date().toISOString(),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js App Router — Consent API Route
|
|
3
|
+
*
|
|
4
|
+
* Handles reading, writing, and revoking data subject consent records as
|
|
5
|
+
* required by NDPA Section 25 (lawful basis) and Section 26 (consent withdrawal).
|
|
6
|
+
*
|
|
7
|
+
* Endpoints
|
|
8
|
+
* ---------
|
|
9
|
+
* GET /api/consent?subjectId=xxx — Load the active consent record
|
|
10
|
+
* POST /api/consent — Save new consent (revokes previous)
|
|
11
|
+
* DELETE /api/consent?subjectId=xxx — Revoke all active consent
|
|
12
|
+
*
|
|
13
|
+
* How to use
|
|
14
|
+
* ----------
|
|
15
|
+
* Copy this file to `app/api/consent/route.ts` in your Next.js project.
|
|
16
|
+
* Ensure the `ndpr_consent_records` table exists (run the ndpr-recipes migration).
|
|
17
|
+
*
|
|
18
|
+
* @module consent/route
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
22
|
+
import { PrismaClient } from '@prisma/client';
|
|
23
|
+
|
|
24
|
+
const prisma = new PrismaClient();
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// GET /api/consent?subjectId=xxx
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Load the most recent active (non-revoked) consent record for a data subject.
|
|
32
|
+
*
|
|
33
|
+
* NDPA Section 25 requires that data controllers retain evidence of consent —
|
|
34
|
+
* this endpoint lets your front-end verify whether a subject has already
|
|
35
|
+
* consented so you can skip the consent banner on return visits.
|
|
36
|
+
*
|
|
37
|
+
* Query params:
|
|
38
|
+
* subjectId (required) — stable identifier for the data subject
|
|
39
|
+
*
|
|
40
|
+
* Returns 200 with the ConsentRecord, or 200 with `null` if none exists.
|
|
41
|
+
*/
|
|
42
|
+
export async function GET(req: NextRequest) {
|
|
43
|
+
const subjectId = req.nextUrl.searchParams.get('subjectId');
|
|
44
|
+
|
|
45
|
+
if (!subjectId) {
|
|
46
|
+
return NextResponse.json({ error: 'subjectId required' }, { status: 400 });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const record = await prisma.consentRecord.findFirst({
|
|
50
|
+
where: { subjectId, revokedAt: null },
|
|
51
|
+
orderBy: { createdAt: 'desc' },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return NextResponse.json(record);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// POST /api/consent
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Persist a new consent decision for a data subject.
|
|
63
|
+
*
|
|
64
|
+
* The route follows the immutable-audit pattern mandated by NDPA Section 25:
|
|
65
|
+
* any previously active record is soft-revoked before the new one is inserted,
|
|
66
|
+
* so the full consent history is preserved for accountability purposes.
|
|
67
|
+
*
|
|
68
|
+
* Body (JSON):
|
|
69
|
+
* subjectId (required) — stable subject identifier
|
|
70
|
+
* consents (required) — map of consent category → boolean
|
|
71
|
+
* version (required) — consent policy version string
|
|
72
|
+
* method (optional) — how consent was captured (default: 'api')
|
|
73
|
+
* lawfulBasis (optional) — NDPA lawful basis, e.g. 'consent', 'legitimate_interest'
|
|
74
|
+
*
|
|
75
|
+
* Returns 201 with the newly created ConsentRecord.
|
|
76
|
+
*/
|
|
77
|
+
export async function POST(req: NextRequest) {
|
|
78
|
+
const body = await req.json();
|
|
79
|
+
const { subjectId, consents, version, method, lawfulBasis } = body;
|
|
80
|
+
|
|
81
|
+
if (!subjectId || !consents || !version) {
|
|
82
|
+
return NextResponse.json(
|
|
83
|
+
{ error: 'subjectId, consents, and version are required' },
|
|
84
|
+
{ status: 400 },
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Revoke any previously active consent records so there is at most one
|
|
89
|
+
// active record per subject at all times (immutable-audit pattern).
|
|
90
|
+
await prisma.consentRecord.updateMany({
|
|
91
|
+
where: { subjectId, revokedAt: null },
|
|
92
|
+
data: { revokedAt: new Date() },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Insert the new consent record, capturing request metadata for evidence.
|
|
96
|
+
const record = await prisma.consentRecord.create({
|
|
97
|
+
data: {
|
|
98
|
+
subjectId,
|
|
99
|
+
consents,
|
|
100
|
+
version,
|
|
101
|
+
method: method ?? 'api',
|
|
102
|
+
lawfulBasis: lawfulBasis ?? null,
|
|
103
|
+
ipAddress: req.headers.get('x-forwarded-for'),
|
|
104
|
+
userAgent: req.headers.get('user-agent'),
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Write an audit log entry for accountability (NDPA Section 44).
|
|
109
|
+
await prisma.complianceAuditLog.create({
|
|
110
|
+
data: {
|
|
111
|
+
module: 'consent',
|
|
112
|
+
action: 'created',
|
|
113
|
+
entityId: record.id,
|
|
114
|
+
entityType: 'ConsentRecord',
|
|
115
|
+
changes: { subjectId, version, consents },
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return NextResponse.json(record, { status: 201 });
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// DELETE /api/consent?subjectId=xxx
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Revoke all active consent records for a data subject.
|
|
128
|
+
*
|
|
129
|
+
* NDPA Section 26 grants data subjects the right to withdraw consent at any
|
|
130
|
+
* time. This endpoint soft-revokes records rather than deleting them so the
|
|
131
|
+
* audit trail remains intact for regulatory inspection.
|
|
132
|
+
*
|
|
133
|
+
* Query params:
|
|
134
|
+
* subjectId (required) — stable identifier for the data subject
|
|
135
|
+
*
|
|
136
|
+
* Returns 200 `{ success: true }` when complete.
|
|
137
|
+
*/
|
|
138
|
+
export async function DELETE(req: NextRequest) {
|
|
139
|
+
const subjectId = req.nextUrl.searchParams.get('subjectId');
|
|
140
|
+
|
|
141
|
+
if (!subjectId) {
|
|
142
|
+
return NextResponse.json({ error: 'subjectId required' }, { status: 400 });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
await prisma.consentRecord.updateMany({
|
|
146
|
+
where: { subjectId, revokedAt: null },
|
|
147
|
+
data: { revokedAt: new Date() },
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// Write an audit log entry for the revocation.
|
|
151
|
+
await prisma.complianceAuditLog.create({
|
|
152
|
+
data: {
|
|
153
|
+
module: 'consent',
|
|
154
|
+
action: 'revoked',
|
|
155
|
+
entityId: subjectId,
|
|
156
|
+
entityType: 'ConsentRecord',
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
return NextResponse.json({ success: true });
|
|
161
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js App Router — DSR Single-Request Route
|
|
3
|
+
*
|
|
4
|
+
* Handles reading and updating individual Data Subject Rights requests.
|
|
5
|
+
* Used by the DPO/admin interface to view request details and move requests
|
|
6
|
+
* through the processing workflow as required by NDPA Sections 34–38.
|
|
7
|
+
*
|
|
8
|
+
* Endpoints
|
|
9
|
+
* ---------
|
|
10
|
+
* GET /api/dsr/[id] — Fetch a single DSR request by ID
|
|
11
|
+
* PATCH /api/dsr/[id] — Update request status, assignee, or internal notes
|
|
12
|
+
*
|
|
13
|
+
* How to use
|
|
14
|
+
* ----------
|
|
15
|
+
* Copy this file to `app/api/dsr/[id]/route.ts` in your Next.js project.
|
|
16
|
+
*
|
|
17
|
+
* @module dsr/[id]/route
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
21
|
+
import { PrismaClient } from '@prisma/client';
|
|
22
|
+
|
|
23
|
+
const prisma = new PrismaClient();
|
|
24
|
+
|
|
25
|
+
/** Next.js App Router route context — contains dynamic segment params */
|
|
26
|
+
interface RouteContext {
|
|
27
|
+
params: { id: string };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// GET /api/dsr/[id]
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Fetch a single DSR request by its ID.
|
|
36
|
+
*
|
|
37
|
+
* Used by the admin detail view to display full request information including
|
|
38
|
+
* the submission timestamp, due date, and any internal notes added by the DPO.
|
|
39
|
+
*
|
|
40
|
+
* Path params:
|
|
41
|
+
* id (required) — the DSRRequest record ID
|
|
42
|
+
*
|
|
43
|
+
* Returns 200 with the DSRRequest row, or 404 if not found.
|
|
44
|
+
*/
|
|
45
|
+
export async function GET(_req: NextRequest, { params }: RouteContext) {
|
|
46
|
+
const { id } = params;
|
|
47
|
+
|
|
48
|
+
const request = await prisma.dSRRequest.findUnique({ where: { id } });
|
|
49
|
+
|
|
50
|
+
if (!request) {
|
|
51
|
+
return NextResponse.json({ error: 'DSR request not found' }, { status: 404 });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return NextResponse.json(request);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// PATCH /api/dsr/[id]
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Update a DSR request's status, assignee, or internal notes.
|
|
63
|
+
*
|
|
64
|
+
* This route is called by DPO tooling to progress a request through the
|
|
65
|
+
* statutory workflow: pending → in_progress → completed (or rejected).
|
|
66
|
+
* Status transitions are timestamped so the 30-day SLA can be tracked.
|
|
67
|
+
*
|
|
68
|
+
* Body (JSON, all fields optional):
|
|
69
|
+
* status — pending | in_progress | completed | rejected
|
|
70
|
+
* assignedTo — name/ID of the staff member handling the request
|
|
71
|
+
* internalNotes — free-text notes visible only to internal staff
|
|
72
|
+
*
|
|
73
|
+
* Returns 200 with the updated DSRRequest row.
|
|
74
|
+
*/
|
|
75
|
+
export async function PATCH(req: NextRequest, { params }: RouteContext) {
|
|
76
|
+
const { id } = params;
|
|
77
|
+
const body = await req.json();
|
|
78
|
+
const { status, assignedTo, internalNotes } = body;
|
|
79
|
+
|
|
80
|
+
// Build the update payload — only include fields the caller supplied.
|
|
81
|
+
const data: Record<string, unknown> = {};
|
|
82
|
+
|
|
83
|
+
if (status !== undefined) {
|
|
84
|
+
data.status = status;
|
|
85
|
+
|
|
86
|
+
// Stamp workflow timestamps when moving to acknowledged or completed states.
|
|
87
|
+
if (status === 'in_progress') data.acknowledgedAt = new Date();
|
|
88
|
+
if (status === 'completed') data.completedAt = new Date();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (assignedTo !== undefined) data.assignedTo = assignedTo;
|
|
92
|
+
if (internalNotes !== undefined) data.internalNotes = internalNotes;
|
|
93
|
+
|
|
94
|
+
if (Object.keys(data).length === 0) {
|
|
95
|
+
return NextResponse.json(
|
|
96
|
+
{ error: 'At least one of status, assignedTo, or internalNotes must be provided' },
|
|
97
|
+
{ status: 400 },
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const updated = await prisma.dSRRequest.update({ where: { id }, data });
|
|
102
|
+
|
|
103
|
+
// Audit log for every status change (NDPA Section 44 accountability).
|
|
104
|
+
await prisma.complianceAuditLog.create({
|
|
105
|
+
data: {
|
|
106
|
+
module: 'dsr',
|
|
107
|
+
action: 'updated',
|
|
108
|
+
entityId: id,
|
|
109
|
+
entityType: 'DSRRequest',
|
|
110
|
+
changes: data as any,
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
return NextResponse.json(updated);
|
|
115
|
+
}
|