@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
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { Request } from 'express';
|
|
2
|
+
|
|
3
|
+
export interface NDPRVerifiedActor {
|
|
4
|
+
id: string;
|
|
5
|
+
displayName: string;
|
|
6
|
+
email: string;
|
|
7
|
+
department?: string;
|
|
8
|
+
subjectId?: string;
|
|
9
|
+
roles: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface NDPRRequestContext {
|
|
13
|
+
tenantId: string;
|
|
14
|
+
actor: NDPRVerifiedActor | null;
|
|
15
|
+
actorId: string | null;
|
|
16
|
+
subjectId: string | null;
|
|
17
|
+
subjectSource: 'verified-account-subject' | 'anonymous-uuid-capability' | null;
|
|
18
|
+
roles: readonly string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type NDPRContextRequirement = 'tenant' | 'subject' | 'staff';
|
|
22
|
+
export interface NDPRContextProblem { status: 401 | 403 | 503; error: string }
|
|
23
|
+
export type NDPRVerifiedActorResolver = (
|
|
24
|
+
request: Request,
|
|
25
|
+
) => Promise<NDPRVerifiedActor | null>;
|
|
26
|
+
|
|
27
|
+
const ANONYMOUS_SUBJECT_PATTERN =
|
|
28
|
+
/^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;
|
|
29
|
+
const STAFF_ROLES = new Set(['ndpr:staff', 'ndpr:admin']);
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Replace this safe default with a verified Express auth/session integration.
|
|
33
|
+
* Never trust actor, account subject, profile, role, or tenant fields from
|
|
34
|
+
* request-controlled input.
|
|
35
|
+
*/
|
|
36
|
+
export async function resolveVerifiedNDPRActor(
|
|
37
|
+
_request: Request,
|
|
38
|
+
): Promise<NDPRVerifiedActor | null> {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The tenant comes only from server configuration. The only client-provided
|
|
44
|
+
* identity accepted by default is a random anon_<UUID> subject capability;
|
|
45
|
+
* it never grants staff access.
|
|
46
|
+
*/
|
|
47
|
+
export async function resolveNDPRRequestContext(
|
|
48
|
+
request: Request,
|
|
49
|
+
actorResolver: NDPRVerifiedActorResolver = resolveVerifiedNDPRActor,
|
|
50
|
+
): Promise<NDPRRequestContext> {
|
|
51
|
+
const tenantId = process.env.NDPR_TENANT_ID?.trim() ?? '';
|
|
52
|
+
const candidate = request.get('x-ndpr-subject-id')?.trim() ?? '';
|
|
53
|
+
const anonymousSubjectId = ANONYMOUS_SUBJECT_PATTERN.test(candidate)
|
|
54
|
+
? candidate
|
|
55
|
+
: null;
|
|
56
|
+
const actor = normalizeVerifiedActor(await actorResolver(request));
|
|
57
|
+
const subjectId = actor?.subjectId ?? anonymousSubjectId;
|
|
58
|
+
const subjectSource = actor?.subjectId
|
|
59
|
+
? 'verified-account-subject' as const
|
|
60
|
+
: anonymousSubjectId
|
|
61
|
+
? 'anonymous-uuid-capability' as const
|
|
62
|
+
: null;
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
tenantId,
|
|
66
|
+
actor,
|
|
67
|
+
actorId: actor?.id ?? null,
|
|
68
|
+
subjectId,
|
|
69
|
+
subjectSource,
|
|
70
|
+
roles: actor?.roles ?? [],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getNDPRContextProblem(
|
|
75
|
+
context: NDPRRequestContext,
|
|
76
|
+
requirement: NDPRContextRequirement,
|
|
77
|
+
): NDPRContextProblem | null {
|
|
78
|
+
if (!context.tenantId) {
|
|
79
|
+
return {
|
|
80
|
+
status: 503,
|
|
81
|
+
error: 'NDPR_TENANT_ID is not configured on the server',
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (requirement === 'subject' && !context.subjectId) {
|
|
85
|
+
return { status: 401, error: 'A verified data-subject identity is required' };
|
|
86
|
+
}
|
|
87
|
+
if (requirement === 'staff' && !context.actorId) {
|
|
88
|
+
return {
|
|
89
|
+
status: 401,
|
|
90
|
+
error: 'Connect resolveVerifiedNDPRActor to verified staff authentication',
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (requirement === 'staff'
|
|
94
|
+
&& !context.roles.some((role) => STAFF_ROLES.has(role))) {
|
|
95
|
+
return { status: 403, error: 'NDPR staff or administrator role required' };
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function normalizeVerifiedActor(
|
|
101
|
+
actor: NDPRVerifiedActor | null,
|
|
102
|
+
): NDPRVerifiedActor | null {
|
|
103
|
+
if (!actor
|
|
104
|
+
|| typeof actor.id !== 'string'
|
|
105
|
+
|| typeof actor.displayName !== 'string'
|
|
106
|
+
|| typeof actor.email !== 'string'
|
|
107
|
+
|| !Array.isArray(actor.roles)) {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
const id = actor.id.trim();
|
|
111
|
+
const displayName = actor.displayName.trim();
|
|
112
|
+
const email = actor.email.trim();
|
|
113
|
+
if (!id || !displayName || !email) return null;
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
id,
|
|
117
|
+
displayName,
|
|
118
|
+
email,
|
|
119
|
+
department: typeof actor.department === 'string'
|
|
120
|
+
? actor.department.trim() || undefined
|
|
121
|
+
: undefined,
|
|
122
|
+
subjectId: typeof actor.subjectId === 'string'
|
|
123
|
+
? actor.subjectId.trim() || undefined
|
|
124
|
+
: undefined,
|
|
125
|
+
roles: actor.roles
|
|
126
|
+
.filter((role): role is string => typeof role === 'string')
|
|
127
|
+
.map((role) => role.trim())
|
|
128
|
+
.filter(Boolean),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# NDPA 2023 / NDPC GAID 2025 compliance gate — generated by create-ndpr for {{ORG_NAME_COMMENT}}.
|
|
2
|
+
#
|
|
3
|
+
# Runs `ndpr audit` on every push and pull request. The audit scores your
|
|
4
|
+
# compliance config (consent, DSR, DPIA, breach, policy, lawful basis,
|
|
5
|
+
# cross-border, RoPA) plus your GAID 2025 DCPMI designation inputs, and exits
|
|
6
|
+
# non-zero when the overall score drops below the threshold — so a regression
|
|
7
|
+
# fails CI the same way a broken test would.
|
|
8
|
+
#
|
|
9
|
+
# Edit ndpr.audit.json to reflect your real posture, then raise --min-score
|
|
10
|
+
# over time as you close gaps. Docs: https://ndprtoolkit.com.ng/docs/guides/audit-cli
|
|
11
|
+
name: NDPA compliance audit
|
|
12
|
+
|
|
13
|
+
on:
|
|
14
|
+
push:
|
|
15
|
+
branches: [main]
|
|
16
|
+
pull_request:
|
|
17
|
+
|
|
18
|
+
jobs:
|
|
19
|
+
ndpr-audit:
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@v4
|
|
23
|
+
- uses: actions/setup-node@v4
|
|
24
|
+
with:
|
|
25
|
+
node-version: 20
|
|
26
|
+
# The `ndpr` CLI ships with the toolkit. --no-save keeps it out of your
|
|
27
|
+
# package.json if you don't already depend on it directly.
|
|
28
|
+
- name: Install the NDPR toolkit (provides the `ndpr` CLI)
|
|
29
|
+
run: npm install --no-save @tantainnovative/ndpr-toolkit@{{TOOLKIT_VERSION}}
|
|
30
|
+
- name: Run the NDPA compliance audit
|
|
31
|
+
run: npx ndpr audit --min-score 70
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"minScore": 70,
|
|
3
|
+
"compliance": {
|
|
4
|
+
"consent": { "hasConsentMechanism": true, "hasPurposeSpecification": true, "hasWithdrawalMechanism": true, "hasMinorProtection": false, "consentRecordsRetained": true },
|
|
5
|
+
"dsr": { "hasRequestMechanism": true, "supportsAccess": true, "supportsRectification": true, "supportsErasure": false, "supportsPortability": false, "supportsObjection": false, "responseTimelineDays": 30 },
|
|
6
|
+
"dpia": { "conductedForHighRisk": true, "documentedRisks": true, "mitigationMeasures": true },
|
|
7
|
+
"breach": { "hasNotificationProcess": true, "notifiesWithin72Hours": true, "hasRiskAssessment": true, "hasRecordKeeping": true },
|
|
8
|
+
"policy": { "hasPrivacyPolicy": true, "isPubliclyAccessible": true, "lastUpdated": "2026-01-01", "coversAllSections": true },
|
|
9
|
+
"lawfulBasis": { "documentedForAllProcessing": true, "hasLegitimateInterestAssessment": false },
|
|
10
|
+
"crossBorder": { "hasTransferMechanisms": true, "adequacyAssessed": true, "ndpcApprovalObtained": false },
|
|
11
|
+
"ropa": { "maintained": true, "includesAllProcessing": true, "lastReviewed": "2026-01-01" }
|
|
12
|
+
},
|
|
13
|
+
"dcpmi": { "dataSubjectsInSixMonths": 0 }
|
|
14
|
+
}
|
|
@@ -1,107 +1,173 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Next.js App Router —
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* NDPA §40 — data controllers must notify the NDPC within 72 hours of
|
|
6
|
-
* discovering a breach that poses a risk to data subject rights and freedoms.
|
|
7
|
-
*
|
|
8
|
-
* Endpoints:
|
|
9
|
-
* GET /api/breach — List breach reports (optional ?status= filter)
|
|
10
|
-
* POST /api/breach — Create a new breach report
|
|
2
|
+
* Next.js App Router — tenant-scoped breach register for {{ORG_NAME_COMMENT}}.
|
|
3
|
+
* All operations require verified staff context. Readiness is advisory; it is
|
|
4
|
+
* not evidence that a regulator or affected subject was notified.
|
|
11
5
|
*/
|
|
12
|
-
|
|
13
6
|
import { NextRequest, NextResponse } from 'next/server';
|
|
7
|
+
import { assessBreachNotification } from '@tantainnovative/ndpr-toolkit/server';
|
|
8
|
+
import { getNDPRContextProblem, resolveNDPRRequestContext } from '{{NDPR_CONTEXT_IMPORT}}';
|
|
9
|
+
// {{#if ORM=prisma}}
|
|
14
10
|
import { PrismaClient } from '@prisma/client';
|
|
15
|
-
|
|
16
11
|
const prisma = new PrismaClient();
|
|
12
|
+
// {{/if}}
|
|
13
|
+
// {{#if ORM=drizzle}}
|
|
14
|
+
import { db } from '{{NDPR_DB_IMPORT}}';
|
|
15
|
+
import { breachReports, complianceAuditLog } from '{{NDPR_SCHEMA_IMPORT}}';
|
|
16
|
+
import { and, desc, eq } from 'drizzle-orm';
|
|
17
|
+
// {{/if}}
|
|
18
|
+
// {{#if ORM=none}}
|
|
19
|
+
// DEVELOPMENT ONLY: replace both stores with one durable transactional store.
|
|
20
|
+
interface BreachRow {
|
|
21
|
+
id: string; tenantId: string; title: string; description: string; category: string;
|
|
22
|
+
severity: Severity; status: string; discoveredAt: Date; occurredAt: Date | null;
|
|
23
|
+
reportedAt: Date; reporterName: string; reporterEmail: string; reporterDepartment: string | null;
|
|
24
|
+
affectedSystems: string[]; dataTypes: string[]; estimatedAffected: number | null;
|
|
25
|
+
approximateRecordCount: number | null; dataSubjectCategories: string[];
|
|
26
|
+
likelyConsequences: string | null; mitigationMeasures: string | null;
|
|
27
|
+
involvesSensitiveData: boolean; isPhasedReport: boolean; supplementsReportId: string | null;
|
|
28
|
+
initialActions: string | null;
|
|
29
|
+
}
|
|
30
|
+
const breachStore = new Map<string, BreachRow>();
|
|
31
|
+
const auditLog: Array<{ id: string; tenantId: string; action: string; entityId: string; performedBy: string; at: Date }> = [];
|
|
32
|
+
// {{/if}}
|
|
17
33
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
)
|
|
22
|
-
const highRisk = ['unauthorized_access', 'ransomware', 'data_exfiltration', 'identity_theft'];
|
|
23
|
-
if (highRisk.includes(category)) {
|
|
24
|
-
return (estimatedAffected ?? 0) > 1000 ? 'critical' : 'high';
|
|
25
|
-
}
|
|
34
|
+
type Severity = 'critical' | 'high' | 'medium' | 'low';
|
|
35
|
+
function calculateSeverity(category: string, estimatedAffected: number | null): Severity {
|
|
36
|
+
const highRisk = new Set(['unauthorized_access', 'ransomware', 'data_exfiltration', 'identity_theft']);
|
|
37
|
+
if (highRisk.has(category)) return (estimatedAffected ?? 0) > 1000 ? 'critical' : 'high';
|
|
26
38
|
if ((estimatedAffected ?? 0) > 500) return 'high';
|
|
27
39
|
if ((estimatedAffected ?? 0) > 50) return 'medium';
|
|
28
40
|
return 'low';
|
|
29
41
|
}
|
|
42
|
+
function finiteNonNegative(value: unknown): number | null {
|
|
43
|
+
return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
44
|
+
}
|
|
30
45
|
|
|
31
|
-
// GET /api/breach?status=ongoing
|
|
32
46
|
export async function GET(req: NextRequest) {
|
|
47
|
+
const context = await resolveNDPRRequestContext(req);
|
|
48
|
+
const problem = getNDPRContextProblem(context, 'staff');
|
|
49
|
+
if (problem) return NextResponse.json({ error: problem.error }, { status: problem.status });
|
|
33
50
|
const status = req.nextUrl.searchParams.get('status');
|
|
34
51
|
|
|
52
|
+
// {{#if ORM=prisma}}
|
|
35
53
|
const reports = await prisma.breachReport.findMany({
|
|
36
|
-
where: status ? { status } :
|
|
54
|
+
where: { tenantId: context.tenantId, ...(status ? { status } : {}) },
|
|
37
55
|
orderBy: { reportedAt: 'desc' },
|
|
38
56
|
});
|
|
39
|
-
|
|
57
|
+
// {{/if}}
|
|
58
|
+
// {{#if ORM=drizzle}}
|
|
59
|
+
const reports = status
|
|
60
|
+
? await db.select().from(breachReports).where(and(eq(breachReports.tenantId, context.tenantId), eq(breachReports.status, status))).orderBy(desc(breachReports.reportedAt))
|
|
61
|
+
: await db.select().from(breachReports).where(eq(breachReports.tenantId, context.tenantId)).orderBy(desc(breachReports.reportedAt));
|
|
62
|
+
// {{/if}}
|
|
63
|
+
// {{#if ORM=none}}
|
|
64
|
+
const reports = [...breachStore.values()]
|
|
65
|
+
.filter((row) => row.tenantId === context.tenantId && (!status || row.status === status))
|
|
66
|
+
.sort((a, b) => b.reportedAt.getTime() - a.reportedAt.getTime());
|
|
67
|
+
// {{/if}}
|
|
40
68
|
return NextResponse.json(reports);
|
|
41
69
|
}
|
|
42
70
|
|
|
43
|
-
// POST /api/breach
|
|
44
71
|
export async function POST(req: NextRequest) {
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
occurredAt,
|
|
52
|
-
reporterName,
|
|
53
|
-
reporterEmail,
|
|
54
|
-
reporterDepartment,
|
|
55
|
-
affectedSystems,
|
|
56
|
-
dataTypes,
|
|
57
|
-
estimatedAffected,
|
|
58
|
-
initialActions,
|
|
59
|
-
} = body;
|
|
60
|
-
|
|
61
|
-
if (!title || !description || !category || !discoveredAt || !reporterName || !reporterEmail) {
|
|
62
|
-
return NextResponse.json(
|
|
63
|
-
{ error: 'title, description, category, discoveredAt, reporterName, and reporterEmail are required' },
|
|
64
|
-
{ status: 400 },
|
|
65
|
-
);
|
|
72
|
+
const context = await resolveNDPRRequestContext(req);
|
|
73
|
+
const problem = getNDPRContextProblem(context, 'staff');
|
|
74
|
+
if (problem) return NextResponse.json({ error: problem.error }, { status: problem.status });
|
|
75
|
+
const actor = context.actor;
|
|
76
|
+
if (!actor) {
|
|
77
|
+
return NextResponse.json({ error: 'Verified staff profile is required' }, { status: 401 });
|
|
66
78
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
79
|
+
const actorId = actor.id;
|
|
80
|
+
const body: unknown = await req.json().catch(() => null);
|
|
81
|
+
if (!body || typeof body !== 'object') return NextResponse.json({ error: 'A JSON object is required' }, { status: 400 });
|
|
82
|
+
const input = body as Record<string, unknown>;
|
|
83
|
+
const required = ['title', 'description', 'category', 'discoveredAt'] as const;
|
|
84
|
+
if (required.some((field) => typeof input[field] !== 'string' || !(input[field] as string).trim())) {
|
|
85
|
+
return NextResponse.json({ error: `${required.join(', ')} are required strings` }, { status: 400 });
|
|
73
86
|
}
|
|
87
|
+
if (!Array.isArray(input.affectedSystems) || !input.affectedSystems.every((v) => typeof v === 'string') ||
|
|
88
|
+
!Array.isArray(input.dataTypes) || !input.dataTypes.every((v) => typeof v === 'string')) {
|
|
89
|
+
return NextResponse.json({ error: 'affectedSystems and dataTypes must be string arrays' }, { status: 400 });
|
|
90
|
+
}
|
|
91
|
+
const discoveredAt = new Date(input.discoveredAt as string);
|
|
92
|
+
const occurredAt = typeof input.occurredAt === 'string' ? new Date(input.occurredAt) : null;
|
|
93
|
+
if (!Number.isFinite(discoveredAt.getTime()) || discoveredAt.getTime() > Date.now() ||
|
|
94
|
+
(occurredAt && (!Number.isFinite(occurredAt.getTime()) || occurredAt > discoveredAt))) {
|
|
95
|
+
return NextResponse.json({ error: 'Breach dates are invalid or inconsistent' }, { status: 400 });
|
|
96
|
+
}
|
|
97
|
+
const estimatedAffected = input.estimatedAffected == null ? null : finiteNonNegative(input.estimatedAffected);
|
|
98
|
+
const approximateRecordCount = input.approximateRecordCount == null ? null : finiteNonNegative(input.approximateRecordCount);
|
|
99
|
+
if ((input.estimatedAffected != null && estimatedAffected === null) ||
|
|
100
|
+
(input.approximateRecordCount != null && approximateRecordCount === null)) {
|
|
101
|
+
return NextResponse.json({ error: 'Affected-subject and record counts must be finite non-negative numbers' }, { status: 400 });
|
|
102
|
+
}
|
|
103
|
+
const dataSubjectCategories = Array.isArray(input.dataSubjectCategories) && input.dataSubjectCategories.every((v) => typeof v === 'string')
|
|
104
|
+
? input.dataSubjectCategories as string[] : [];
|
|
105
|
+
const severity = calculateSeverity(input.category as string, estimatedAffected);
|
|
106
|
+
const reportedAt = new Date();
|
|
107
|
+
const data = {
|
|
108
|
+
tenantId: context.tenantId,
|
|
109
|
+
title: input.title as string, description: input.description as string,
|
|
110
|
+
category: input.category as string, severity, status: 'ongoing', discoveredAt, occurredAt,
|
|
111
|
+
reporterName: actor.displayName, reporterEmail: actor.email,
|
|
112
|
+
reporterDepartment: actor.department ?? null,
|
|
113
|
+
affectedSystems: input.affectedSystems as string[], dataTypes: input.dataTypes as string[],
|
|
114
|
+
estimatedAffected, approximateRecordCount, dataSubjectCategories,
|
|
115
|
+
likelyConsequences: typeof input.likelyConsequences === 'string' ? input.likelyConsequences : null,
|
|
116
|
+
mitigationMeasures: typeof input.mitigationMeasures === 'string' ? input.mitigationMeasures : null,
|
|
117
|
+
involvesSensitiveData: input.involvesSensitiveData === true,
|
|
118
|
+
isPhasedReport: input.isPhasedReport === true,
|
|
119
|
+
supplementsReportId: typeof input.supplementsReportId === 'string' ? input.supplementsReportId : null,
|
|
120
|
+
initialActions: typeof input.initialActions === 'string' ? input.initialActions : null,
|
|
121
|
+
};
|
|
74
122
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
data: {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
category,
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
discoveredAt: new Date(discoveredAt),
|
|
85
|
-
occurredAt: occurredAt ? new Date(occurredAt) : null,
|
|
86
|
-
reporterName,
|
|
87
|
-
reporterEmail,
|
|
88
|
-
reporterDepartment: reporterDepartment ?? null,
|
|
89
|
-
affectedSystems,
|
|
90
|
-
dataTypes,
|
|
91
|
-
estimatedAffected: estimatedAffected ?? null,
|
|
92
|
-
initialActions: initialActions ?? null,
|
|
93
|
-
},
|
|
123
|
+
// {{#if ORM=prisma}}
|
|
124
|
+
const report = await prisma.$transaction(async (tx) => {
|
|
125
|
+
const created = await tx.breachReport.create({ data });
|
|
126
|
+
await tx.complianceAuditLog.create({ data: {
|
|
127
|
+
tenantId: context.tenantId, module: 'breach', action: 'reported',
|
|
128
|
+
entityId: created.id, entityType: 'BreachReport', performedBy: actorId,
|
|
129
|
+
changes: { title: data.title, category: data.category, severity, status: 'ongoing' },
|
|
130
|
+
} });
|
|
131
|
+
return created;
|
|
94
132
|
});
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
entityType: 'BreachReport',
|
|
102
|
-
changes: { title, category, severity, status: 'ongoing' },
|
|
103
|
-
}
|
|
133
|
+
// {{/if}}
|
|
134
|
+
// {{#if ORM=drizzle}}
|
|
135
|
+
const report = await db.transaction(async (tx) => {
|
|
136
|
+
const [created] = await tx.insert(breachReports).values(data).returning();
|
|
137
|
+
await tx.insert(complianceAuditLog).values({
|
|
138
|
+
tenantId: context.tenantId, module: 'breach', action: 'reported',
|
|
139
|
+
entityId: created.id, entityType: 'BreachReport', performedBy: actorId,
|
|
140
|
+
changes: { title: data.title, category: data.category, severity, status: 'ongoing' },
|
|
141
|
+
});
|
|
142
|
+
return created;
|
|
104
143
|
});
|
|
144
|
+
// {{/if}}
|
|
145
|
+
// {{#if ORM=none}}
|
|
146
|
+
const report: BreachRow = { id: crypto.randomUUID(), ...data, reportedAt };
|
|
147
|
+
breachStore.set(report.id, report);
|
|
148
|
+
auditLog.push({ id: crypto.randomUUID(), tenantId: context.tenantId, action: 'reported', entityId: report.id, performedBy: actorId, at: reportedAt });
|
|
149
|
+
// {{/if}}
|
|
105
150
|
|
|
106
|
-
|
|
151
|
+
const ndpcReadiness = assessBreachNotification({
|
|
152
|
+
id: report.id, title: data.title, description: data.description, category: data.category,
|
|
153
|
+
discoveredAt: discoveredAt.getTime(), occurredAt: occurredAt?.getTime(), reportedAt: reportedAt.getTime(),
|
|
154
|
+
reporter: { name: data.reporterName, email: data.reporterEmail, department: data.reporterDepartment ?? '' },
|
|
155
|
+
affectedSystems: data.affectedSystems, dataTypes: data.dataTypes,
|
|
156
|
+
involvesSensitiveData: data.involvesSensitiveData,
|
|
157
|
+
estimatedAffectedSubjects: estimatedAffected ?? undefined,
|
|
158
|
+
approximateRecordCount: approximateRecordCount ?? undefined,
|
|
159
|
+
dataSubjectCategories, likelyConsequences: data.likelyConsequences ?? undefined,
|
|
160
|
+
mitigationMeasures: data.mitigationMeasures ?? undefined,
|
|
161
|
+
isPhasedReport: data.isPhasedReport, supplementsReportId: data.supplementsReportId ?? undefined,
|
|
162
|
+
initialActions: data.initialActions ?? undefined,
|
|
163
|
+
dpoContact: { name: `{{ORG_NAME_TEMPLATE}} DPO`, email: `{{DPO_EMAIL_TEMPLATE}}` }, status: 'ongoing',
|
|
164
|
+
});
|
|
165
|
+
return NextResponse.json({ ...report, ndpcReadiness: {
|
|
166
|
+
complete: ndpcReadiness.complete, ready: ndpcReadiness.ready, valid: ndpcReadiness.valid,
|
|
167
|
+
completeness: ndpcReadiness.completeness, missing: ndpcReadiness.missing,
|
|
168
|
+
validationErrors: ndpcReadiness.validationErrors,
|
|
169
|
+
notificationRequired: ndpcReadiness.notificationRequired,
|
|
170
|
+
hoursRemaining: ndpcReadiness.timing.hoursRemaining,
|
|
171
|
+
evidenceRecorded: ndpcReadiness.evidence.notificationProvided,
|
|
172
|
+
} }, { status: 201 });
|
|
107
173
|
}
|